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 }.
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.