KardGrabber HTTP API

This page describes the HTTP API. Paths are on this same site. A machine-readable copy is at /openapi.json.

Login, register, and password reset are open. Every other call needs a bearer token. Card calls use the company that is current for that user. Examples below use fictional people and contain no real credentials.

Conventions

  • Send Authorization: Bearer <token> except on login, register, forgot password, and reset password.
  • JSON names are camelCase. Send Content-Type: application/json unless the endpoint says multipart.
  • Timestamps are ISO-8601 UTC. A token's expiresAt is about 14 days after it is issued.
  • Passwords need at least 6 characters and one digit.
  • Most failures return { "error": "message" } with status 400, 401, 403, or 404. A duplicate card returns 409.
  • Company roles are Owner, FullAccess, Contributor, and Viewer. Only an owner can add, change, or remove members.
  • There is one current company per user. Switch it before card calls. 400 with No company selected. means none is current.

Authentication

POST /api/auth/login

Anonymous. Returns a bearer token and the user's companies.

Request

{
  "email": "ada@example.com",
  "password": "example1"
}

200 response

{
  "token": "<token>",
  "expiresAt": "2026-10-31T00:00:00Z",
  "user": { "id": "user-id", "email": "ada@example.com", "displayName": "Ada Example" },
  "currentCompany": null,
  "companies": []
}

401 when the email or password does not match. currentCompany is null until the user creates or switches a company.

POST /api/auth/register

Anonymous. Creates a user and returns the same body as login. A new user has no company until they create one.

{
  "displayName": "Ada Example",
  "email": "ada@example.com",
  "password": "example1"
}

400 when the email is already registered or the password is too short.

POST /api/auth/forgot-password

Anonymous. Sends a one-hour reset link when the email belongs to an account. The response is the same when it does not, so the call does not reveal which emails are registered.

{
  "email": "ada@example.com"
}

200 returns { "message": "..." }. On a development server with no outgoing email configured, the body also includes resetUrl. 503 when mail cannot be sent.

POST /api/auth/reset-password

Anonymous. Completes the link from forgot password. The new password needs at least 6 characters and one digit. The website reset page calls the same rules.

{
  "email": "ada@example.com",
  "token": "<token from the link>",
  "newPassword": "example2"
}

200 when the password is saved. 400 when the token is expired or the password does not meet the rules.

GET /api/auth/me

Bearer token required. Returns user, currentCompany, and companies without issuing a new token.

Each company includes id, name, description, role, canAddCards, canEditCards, canDeleteCards, and canManageMembers. description here is the workspace blurb, not a scanned company's summary.

Companies

GET /api/companies

Lists companies the user belongs to. Each item adds isCurrent and memberCount to the company fields above.

POST /api/companies

Creates a company, makes the caller the owner, and selects it as current.

{ "name": "Example Robotics", "description": "Workspace for scanned cards" }

name is required. The response is one company object with role Owner and isCurrent true.

POST /api/companies/switch

Selects the company used by later card calls. The user must already be a member.

{ "companyId": 1 }

200 returns { "companyId": 1 }. 403 when the user is not a member.

GET /api/companies/{id}/briefing

Any member. Returns id, offering, targetCustomers, companyQuestions, personQuestions, hasBriefing, and canEdit. canEdit is true for an owner. Empty fields are null. 403 when the user is not a member. 404 when the company does not exist.

PUT /api/companies/{id}/briefing

Owner only. Replaces the whole briefing. New card lookups use the saved text. Send every field you want to keep. An empty or missing field clears that part.

{
  "offering": "Warehouse robots for returns.",
  "targetCustomers": "Retailers with more than one site.",
  "companyQuestions": "How do they handle returns?",
  "personQuestions": "Do they run the warehouses?"
}

offering, companyQuestions, and personQuestions can be 2000 characters. targetCustomers can be 500. 200 returns the same object as the get briefing call, with canEdit true. 403 when the caller is not an owner.

GET /api/companies/{id}/members

Owner only. Returns id, name, and members. A member has membershipId, userId, email, displayName, role, userRole, the three card permission flags, permissionSummary, and joinedAt. role is the stored value Owner or Member. userRole is the derived label. 404 when the company does not exist.

POST /api/companies/{id}/members

Owner only. Adds a member by email. If that email has no account yet, password registers one. If the email already has an account, password is ignored and their existing login is kept.

{
  "email": "grace@example.com",
  "password": "welcome1",
  "role": "Contributor",
  "template": "Contributor",
  "canAddCards": true,
  "canEditCards": true,
  "canDeleteCards": false
}

password is required only for a new account. It needs at least 6 characters and one digit. role is Owner, FullAccess, Contributor, or Viewer. FullAccess can add, edit, and delete cards. Contributor can add and edit. Viewer can only read. Custom is not a stored role. Sending role Custom without template Custom is saved as full access. To set the three flags, send template Custom and a role that is not one of those four names. The request value Custom is accepted for that check only.

200 returns membershipId, email, role, accountCreated, and message. accountCreated is true when this call registered the login. That role is the derived label. The password is not returned. 404 when the company does not exist.

PUT /api/companies/{id}/members/{membershipId}

Owner only. Body matches add-member without email. The last owner cannot be demoted. 200 returns membershipId, role, and message. 404 when the member does not exist.

DELETE /api/companies/{id}/members/{membershipId}

Owner only. Removes a member. The last owner cannot be removed, and an owner cannot remove themselves. 200 returns { "message": "Member removed from the company." }. 404 when the member does not exist.

Cards

Card text fields are fullName, jobTitle, companyName, companyAcronym, officeLocation, email, phone, mobile, website, address, notes, linkedInUrl, and companyLinkedInUrl.

GET /api/cards

Lists cards in the current company, newest first. Optional query search matches name, company, or job title. Each item has id, the text fields except notes and LinkedIn, imageUrl, backImageUrl, createdAt, and updatedAt. This list does not include the brief or enrichment document.

GET /api/cards/{id}

One card. Adds notes, userNotes, links, both LinkedIn URLs, rawOcrText, enrichedAt, enrichmentJson, companySummary, personSummary, briefJson, canEditCards, and canDeleteCards. notes is the system field written during enrichment. userNotes is an array of notes people added, oldest first. links lists the website, LinkedIn, and other saved links. A checked link has checked true and checkedByName. Each note has id, text, createdAt, createdByUserId, and createdByName. Image fields are absolute URLs, or null when there is no image. Prefer companySummary and personSummary. briefJson is a JSON string, not an object; parse it for talking points, relevance, stated facts, and inferences. 404 when the card is not in the current company.

POST /api/cards/{id}/notes

Adds one user note to the card. This does not change the system notes field. Requires permission to edit cards.

{ "text": "Met at the trade show. Follow up next week." }

text is required and cannot be longer than 4000 characters. 201 returns the saved note: id, text, createdAt, createdByUserId, and createdByName. 404 when the card is not in the current company.

POST /api/cards/{id}/links

Corrects one link on the card. Requires permission to edit cards. This is separate from website verification. check means a person looked at the link and it is right. Everyone in the company then sees that link as checked.

{ "field": "linkedInUrl", "action": "remove" }
{ "field": "linkedInUrl", "action": "update", "url": "https://www.linkedin.com/in/example" }
{ "field": "website", "action": "check", "currentUrl": "https://example.com" }
{ "field": "other", "action": "remove", "currentUrl": "https://example.com/wrong" }

field is website, linkedInUrl, companyLinkedInUrl, twitterUrl, facebookUrl, or other. action is update, remove, or check. url is the replacement for update. currentUrl identifies the link being changed, and is required for other. A removed link stays removed when the card is verified again. 200 returns { "links": [ ... ] }. Each link has field, label, url, checked, and checkedByName.

PUT /api/cards/{id}

Replaces the text fields. Requires permission to edit cards. JSON body uses the card text fields. 200 returns { "id": 1 }.

DELETE /api/cards/{id}

Deletes the card and its images. Requires permission to delete cards. 204 has an empty body.

POST /api/cards

Creates a card. Content type is multipart/form-data. Text parts use the card field names. Optional parts: image and imageBack files, rawOcrText, enrichmentJson, enrichedAt, localId, and forceSave (true saves even when a similar card exists).

201 returns { "id": 1 }. 409 returns the duplicate object when a similar card exists and forceSave is not true. Permission to add cards is required.

POST /api/cards/sync

Same multipart body as create. Used to upload a card that was saved offline. 200 returns { "id": 1, "localId": "device-local-id", "synced": true }. localId is echoed from the form and may be null.

POST /api/cards/check-duplicate

JSON body of card text fields. 200 returns { "duplicate": false, "sameCompany": true } when nothing matches. A match returns the duplicate object with duplicate true.

POST /api/cards/merge

Multipart. Required part existingId. Other parts are the card text fields and optional enrichmentJson and enrichedAt. Empty text parts keep the stored value. replaceFrontImage and replaceBackImage must be true to replace files sent as image and imageBack. Requires permission to edit cards. 200 returns { "id": 1, "merged": true }.

Extraction and enrichment

POST /api/cards/parse

Deprecated. Always 400. Rule-based parsing is disabled. Use AI extraction.

GET /api/cards/ai-status

Returns enabled, configured, hasApiKey, provider, and slow. These are booleans except provider, which is the label AI. hasApiKey is true when the server has a credential configured. The credential itself is never returned.

POST /api/cards/ai-extract

Reads a card photo and returns text fields. It does not save a card. Send JSON or multipart. At least one image is required.

{
  "imageBase64": "<base64-jpeg>",
  "contentType": "image/jpeg",
  "imageBackBase64": null,
  "backContentType": null
}

Multipart field names are image, imageBack, imageBase64, imageBackBase64, contentType, and backContentType. A data-URL prefix on the base64 value is accepted.

200 returns source ai plus the card text fields except notes and LinkedIn. 400 when AI is not available or the image cannot be read.

GET /api/cards/enrich-status

Returns enabled, configured, and provider. provider is a short label for the enrichment mode. It is not a secret and it is not a credential.

POST /api/cards/enrich

Looks up a company website from the supplied card fields. It does not save a card. JSON body:

{
  "fullName": "Ada Example",
  "jobTitle": "Director",
  "companyName": "Example Robotics",
  "companyAcronym": "ER",
  "email": "ada@example.com",
  "website": "https://example.com",
  "phone": "+1 202 555 0100",
  "mobile": null,
  "address": "1 Example Street"
}

200 returns source web, links, companyDescription, companySummary, personSummary, briefJson, achievements, otherLinks, comparisons, correctionsApplied, corrected text fields, enrichedAt, and enrichmentJson. companyDescription and companySummary are the same company paragraph. 400 when enrichment is disabled or the lookup fails.

POST /api/cards/linkedin-search

Same JSON body as enrich. fullName is required. 200 returns success, linkedInUrl, source, error, and searchedAt. linkedInUrl may be null when no public profile is found.

POST /api/cards/{id}/enrich

Use this POST. It reads the saved card, runs the lookup, and writes the result onto that card. It requires permission to edit. GET on the same path is a side effect: it does the same write. Do not prefetch it. It remains for hosts that block POST. 200 returns the saved website and LinkedIn links, the summary fields, briefJson, achievements, otherLinks, enrichedAt, and enrichmentJson.

Shared objects

Duplicate match

{
  "duplicate": true,
  "exactMatch": false,
  "sameCompany": true,
  "canMerge": true,
  "existingId": 4,
  "message": "A similar card already exists in this company.",
  "existingCard": { "id": 4, "fullName": "Ada Example", "email": "ada@example.com" }
}

existingCard includes the card text fields plus imagePath and backImagePath. canMerge is true when the user may edit and the match is not exact.

Comparison

Each comparison has field, cardValue, webValue, status, and recommended. status is match, corrected, conflict, web_only, card_only, or missing.

Link

otherLinks items are { "title": "About", "url": "https://example.com/about", "type": "page" }.

Enrichment document

enrichmentJson is a JSON string, not an object. Parse it. It keeps the website check and the same brief fields clients already read: companyDescription, companySummary, companyTalkingPoints, companyRelevance, personStated, personAssumed, personTalkingPoints, linkedInReadable, briefingApplied, plus comparisons, achievements, links, and corrected fields.

Brief document

briefJson is also a JSON string, not an object. schemaVersion is 1. Prefer the top-level companySummary and personSummary. Use the brief for talking points, relevance, stated facts, and inferences. The column companySummary matches the brief, and the column personSummary matches it too.

{
  "schemaVersion": 1,
  "companySummary": "Example Robotics builds warehouse robots.",
  "companyTalkingPoints": ["Ask how returns are handled."],
  "companyRelevance": "Their warehouses match a returns workflow.",
  "personSummary": "Director of Operations",
  "personStated": [{ "text": "Director of Operations", "source": "card" }],
  "personAssumed": [{ "text": "Likely owns warehouse operations.", "because": "The title is on the card." }],
  "personTalkingPoints": ["Ask which sites they run."],
  "linkedInReadable": false,
  "briefingApplied": true
}

source on a stated fact is card, website, search, or linkedin. linkedInReadable is false when a public profile page could not be read. briefingApplied is false when the workspace has no briefing, and the talking-point lists are then empty. Older cards may only have companySummary filled from a previous company paragraph.