# REST API reference This page lists every HTTP endpoint the Contrack server registers. Use it to write scripts and tools against your own instance. The routes with a contract are also in [`openapi.json`](openapi.json), an OpenAPI 3.1 file with the JSON Schema of each request and answer. They are the contacts, notes, follow-ups, lists, tags and personal tokens, the read-only query routes, the OAuth routes, and a few more, such as the background jobs route. `npm run api:openapi` writes the file from `shared/contracts/`, and a test fails when it is out of date. This page stays the reference for every route, for signing in and for errors. ## Conventions ### Base URL A local install listens on `http://localhost:3210`. Every API path starts with `/api`, except three groups: the health probe `GET /healthz`, the uploaded files under `/uploads`, and the OAuth routes under `/.well-known` and `/oauth`. Requests and responses are JSON unless an endpoint says otherwise. Send `Content-Type: application/json` with every JSON body. While sign-in is off, and until the first account exists, the server answers only to names that a web page cannot own. These are an IP address, `localhost`, a name with no dot, a name under a local suffix such as `.local`, `.lan` or `.internal`, the `PUBLIC_URL` host, and the names in `ALLOWED_HOSTS` (see [Configuration](configuration.md#environment-variables)). Another name gets `403 HOST_NOT_ALLOWED` under `/api` and `/uploads`, and a plain-text `403` elsewhere. `GET /healthz` answers to every name. The server sends no CORS headers. To call the API from a page on another origin, set `CORS_ORIGIN` to that one origin. The OAuth metadata, token, register and revoke routes allow every origin. ### Credentials Sign-in is off by default. Every request then acts as the local owner, and you need no credential. Sign-in is on when `AUTH_REQUIRED=true` is set. The server also turns it on by itself when an account with a password exists. Every `/api` and `/uploads` request then needs one of two credentials: - **A personal token.** Send `Authorization: Bearer ctk_...`. Create one in **Settings → Account**, or with `POST /api/auth/tokens`. A token acts as the account that created it. A read-only token may send `GET`, `HEAD` and `OPTIONS` requests and call the MCP server, which then lists only its read-only tools. Every other request gets `403 TOKEN_READ_ONLY`, and so does the Google sign-in at `/api/connectors/google/`, which adds a connector. - **The session cookie.** The browser gets `contrack_session` when it signs in. The cookie is `HttpOnly`, `SameSite=Lax` and `Path=/`. It is `Secure` when the request arrived over HTTPS, or when a proxy sends `X-Forwarded-Proto: https`. A session lasts 30 days by default, and an admin sets 1 to 365 days. A sign-in with `remember: false` gets a cookie that ends with the browser, and its session lasts at most one day. An MCP client may also send an OAuth access token to `/api/mcp` (see [Apps that sign in with OAuth](#apps-that-sign-in-with-oauth)). A `POST`, `PUT`, `PATCH` or `DELETE` with the cookie must come from this server's own pages. It gets `403 CROSS_SITE_REQUEST` when its `Origin` names another host than the request's own or `PUBLIC_URL`, or, with no `Origin`, when `Sec-Fetch-Site` is `cross-site` or `same-site`. A request with no valid credential gets `401 UNAUTHORIZED`. The answer carries `WWW-Authenticate: Bearer`, with `error="invalid_token"` when the request sent a token that failed. An account whose password an admin set gets `403 PASSWORD_CHANGE_REQUIRED` until it sets its own password, with a session or a token. Every route outside the [Sign in and sign up](#sign-in-and-sign-up) and [Your account](#your-account) tables refuses it, and so does `/uploads`. In those two tables, `POST /api/auth/tokens` and the two passkey registration routes refuse it too. The other routes there stay open, so the account can read and change its settings and call `POST /api/auth/change-password`. ### Access The **Access** column in each table uses these words. They come from the route manifest, `server/tenancy/routeManifest.ts`. | Access | Who can call it | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | public | Anyone who can reach the port. No credential. | | your session | You, for your own account. It needs the session cookie: a personal token gets `403 SESSION_REQUIRED`. With sign-in off, the local owner passes. The three preference routes also take a token. | | your data | You, for the data your account owns. An id that belongs to another account answers `404` with the same body as an id that does not exist. | | admin | An account with the admin role, signed in. Other accounts get `403 ADMIN_REQUIRED`, with a session or a token. An admin's token gets `403 SESSION_REQUIRED`. With sign-in off, the local owner passes. | | any signed-in caller | Any caller with a valid credential. The route reads no owned data. | | dev only | Registered only when `NODE_ENV` is not `production`. | Some "your data" routes also need a session, because they store external credentials. The tables say so in the description. ### Errors Every error uses one envelope: ```json { "error": { "code": "NOT_FOUND", "message": "Contact not found", "requestId": "321c5292", "details": { "entity": "Contact" } } } ``` - `code` is stable. Branch on it, not on `message`. - `details` is optional. When a schema refused the request, `details` holds the list of Zod issues. Other `VALIDATION_ERROR` answers have only a `message`. - `stack` is added outside production. The server never sends the cause of an unexpected error. - A `429` that knows when to retry sends a `Retry-After` header in seconds. | Code | Status | Meaning | | --------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | | `VALIDATION_ERROR` | 400 | The body, query or path failed validation. | | `INVALID_JSON` | 400 | The body is not valid JSON. | | `BAD_REQUEST` | 400 | A rule the route checks itself, such as child arrays on `PATCH`. | | `DB_CONSTRAINT` | 400 | A database constraint refused the write. | | `INVALID_UPLOAD` | 400 | A multipart upload had bad fields. | | `UNSUPPORTED_FILE_TYPE` | 400 | The attachment type is not allowed. | | `UNAUTHORIZED` | 401 | No valid credential. | | `AI_BILLING` | 402 | The AI provider refused the call for billing. | | `SESSION_REQUIRED` | 403 | The route needs the session cookie, not a token. | | `CROSS_SITE_REQUEST` | 403 | A change with the session cookie came from another site's page. | | `HOST_NOT_ALLOWED` | 403 | The server does not answer to this host name (see [Base URL](#base-url)). | | `ORIGIN_NOT_ALLOWED` | 403 | A page on another origin called `POST /api/mcp`. `CORS_ORIGIN` allows one origin. | | `ADMIN_REQUIRED` | 403 | The route needs an admin account. | | `PASSWORD_CHANGE_REQUIRED` | 403 | Set your own password first. | | `TOKEN_READ_ONLY` | 403 | A read-only token sent a request that changes data. | | `AI_OFF_FOR_ACCOUNT` | 403 | You turned AI off for your account. | | `AI_OFF_FOR_INSTANCE` | 403 | An admin turned AI off for the instance. The AI settings routes answer it with `409`, and a model call outside an AI route with `503`. | | `NOT_FOUND` | 404 | The row does not exist, or it is not yours. | | `ROUTE_NOT_FOUND` | 404 | No route has this method and path. | | `CONFLICT` | 409 | The request conflicts with the current state. | | `PAYLOAD_TOO_LARGE` | 413 | The body or the file is over its limit. | | `RATE_LIMITED` | 429 | A rate limit, a run lock or the AI provider's own limit refused the request. | | `AI_BUSY` | 429 | The AI queue is full. Try again shortly. | | `INTERNAL` | 500 | An unexpected error. The server logs it. A fetch of an outside page that failed answers it with `502`. | | `AI_MODEL_UNAVAILABLE` | 502 | The AI provider has no such model for this key. | | `AI_REQUEST_REJECTED` | 502 | The AI provider refused the request. | | `AI_INVALID_JSON` | 502 | The model answered empty or malformed JSON. | | `AI_SCHEMA_MISMATCH` | 502 | The model's answer had the wrong shape. | | `RESPONSE_TOO_LARGE` | 502 | An outside page was over the size limit. | | `SERVICE_UNAVAILABLE` | 503 | A dependency is missing, for example no AI provider. | | `AI_CAPABILITY_UNAVAILABLE` | 503 | No AI provider serves this task. | | `AI_AUTH_FAILED` | 503 | The AI provider refused its key. | | `DB_BUSY` | 503 | The database stayed locked. Retry. | | `DB_READONLY` | 503 | The database is read-only. | | `UPSTREAM_TIMEOUT` | 504 | A call to a provider ran out of time. | Routes add their own codes, such as `NOT_TRACKED` or `USER_HAS_DATA`. The tables name them. A few answers do not use the envelope: - `GET /api/mcp` and `DELETE /api/mcp` answer `405` with `{ "error", "message" }`. - `GET /api/ai/stats/feed` answers a bad query with `400 { "error", "details" }`, and an unknown `operation` with `400 { "error" }`. - `GET /api/logos/:domain` answers its errors as plain text. - The OAuth routes under `/.well-known` and `/oauth` answer `{ "error", "error_description" }`, as OAuth asks. - The host check answers a plain-text `403` outside `/api` and `/uploads`. - `POST /api/search/refresh-index` answers `400` with a confirmation request, not an error (see [Search](#search)). - MCP errors are JSON-RPC errors. ### Request IDs Every response carries `X-Request-Id`, an 8-character id. An error body repeats it in `error.requestId`. The server log writes the same id in brackets on each error line and on most route log lines, so quote it when you report a problem. The access log line does not carry it. ### Rate limits Each limit counts requests in a fixed window. An exceeded limit answers `429 RATE_LIMITED` with `Retry-After`. | What | Limit | Counted per | | --------------------------------------------------------------------------------------- | ---------------- | --------------------------------- | | Routes that call an AI provider or fetch a URL (list below) | 60 a minute | client address | | The same routes | 30 a minute | account | | `POST /api/mcp` | 120 a minute | account (address with no account) | | `GET /api/geo/search` | 30 a minute | account (address with no account) | | Sign-in routes: login, register, invitation, passkeys, password change, link completion | 10 a minute | client address | | `POST /api/auth/setup` | 5 a minute | client address | | `POST /api/auth/tokens` | 10 an hour | account | | `POST /api/auth/password-reset/request`, `POST /api/auth/magic-link/request` | 3 in 15 minutes | client address | | `POST /api/admin/mail/test` | 5 in 10 minutes | account | | `GET /oauth/authorize`, and `POST /oauth/token` | 60 a minute each | client address | | `POST /oauth/revoke` | 30 a minute | client address | | `POST /oauth/register` | 10 an hour | client address | | `POST /api/auth/oauth/requests/:id` | 20 a minute | account (address with no account) | The sign-in routes share one count for each address. The two link requests share one count, and so do the AI and fetch routes. Behind a reverse proxy, set `TRUST_PROXY_HOPS`, or every client counts as the proxy's address. The OAuth token, register and revoke routes refuse with `429 {"error":"temporarily_unavailable"}` and `Retry-After`. The AI and fetch limits cover these paths, in any letter case: `POST /api/search/semantic`, `POST /api/search/synthesize`, `POST /api/parse-contact`, `POST /api/contacts/:id/enrich`, `POST /api/contacts/:id/briefing`, `POST /api/ai-search`, `POST /api/dedupe/backfill-embeddings`, `POST /api/dedupe/scan`, `GET /api/dashboard/insight` and `GET /api/link-preview/unfurl`. Two other answers look like rate limits: - `429 AI_BUSY`: the AI queue holds 2 running calls and 16 waiting calls, and Ask Contrack has 2 slots of its own. A full queue refuses new work. - `429 RATE_LIMITED` with `details.yours` and `details.queued`: the run lock refused a duplicate scan or a research batch. A scan is refused while your own scan runs (`yours: true`) or another account's scan runs (`yours: false`). Then `queued: true` means the server booked your scan a turn, so do not send it again. A research batch is refused only while another account's batch runs, with `Retry-After: 60`. ### Body sizes and uploads | What | Limit | | ------------------------------------- | ----------------------------------------------------------------------------------------- | | A JSON body | 1 MB | | The body of `POST /api/contacts/bulk` | 50 MB | | A contact photo, field `avatar` | 10 MB. JPEG, PNG, GIF, WebP or AVIF. | | Your account photo, field `avatar` | 10 MB. The same types. | | An attachment, field `attachment` | 50 MB. `.eml`, `.txt`, `.md`, `.csv`, `.pdf`, `.png`, `.jpg`, `.jpeg`, `.gif` or `.webp`. | A body or file over its limit answers `413 PAYLOAD_TOO_LARGE`. Uploads use `multipart/form-data`. The server checks a photo by the `Content-Type` of its part, and refuses another type with `400 VALIDATION_ERROR`. It checks an attachment by its file name extension, and refuses another type with `400 UNSUPPORTED_FILE_TYPE`. A file under another field name gets `400 INVALID_UPLOAD`. ### Bulk limits | What | Limit | | ------------------------------------------------------------------- | ------------------------------------------ | | Id lists: bulk delete, bulk update, bulk restore, bulk list members | 1 to 5,000 ids. Duplicates collapse. | | `POST /api/contacts/bulk` | 5,000 contacts | | Child arrays on a contact (emails, phones, tags and the rest) | 100 items each | | `POST /api/contacts/merge-cluster` | 10 duplicates | | `POST /api/contacts/merge-clusters` | 250 merges in all | | `POST /api/action-items/bulk` | 1 to 500 contact ids. Duplicates collapse. | | `PUT /api/lists/reorder` | 5,000 ids | | `POST /api/ai-search` | 1 to 100 unique contact ids | | `POST /api/search/synthesize` | 1 to 30 contact ids | | Search facets | 8 per request | A bulk route acts only on ids you own and reports the number of rows it changed. ### Dates and times - A date field takes a calendar date (`2026-10-06`) or an ISO timestamp. A timestamp with an offset is stored in UTC. A timestamp with no offset is read in the server's time zone. - An interaction date cannot be in the future. The server allows five minutes of clock drift, and one day for a date with no time. - The server writes timestamps in two forms: `2026-09-30 04:59:17` (UTC, from SQLite) and `2026-09-30T04:59:17.114Z`. Parse both. ### Paging Most list routes return the whole list. These routes page: | Route | Parameters | | ------------------------------ | ------------------------------------------------------------- | | `GET /api/search/history` | `limit` (1 to 200, default 50) and `cursor` from `nextCursor` | | `GET /api/admin/audit` | `limit` (1 to 200, default 50) and `before` from `nextBefore` | | `GET /api/ai/stats/feed` | `offset` and `limit` (1 to 200, default 50) | | `GET /api/search/interactions` | `offset` (0 to 5,000) and `limit` (1 to 50, default 20) | | `GET /api/query/contacts` | `offset` and `limit` (1 to 200, default 50) | `GET /api/query/contacts` reads a value past a bound as that bound, and a value that is not a number as the default. The other routes answer a value out of range with `400`. ### Streaming Two routes stream NDJSON, one JSON object per line: - `POST /api/search/semantic` when you send `Accept: application/x-ndjson`. Without that header it answers with one JSON object. - `POST /api/search/synthesize`, once its body passes its checks. Three routes stream Server-Sent Events. Each event is one `data: ` line and a blank line: - `POST /api/contacts/bulk` when you send `Accept: text/event-stream`. - `GET /api/ai-search/stream?batchId=`. It also sends a `: heartbeat` comment every 15 seconds. - `GET /api/dedupe/stream?scanId=`. `POST /api/mcp` also answers with an event stream, in the MCP Streamable HTTP format (see [MCP and read-only routes](#mcp-and-read-only-routes)). A stream that fails after its first byte ends without an error body. The two NDJSON routes write a last line `{"phase":"error","error":"..."}` in place of `complete`, when they can. ### Caching Responses under `/api/auth`, `/api/admin`, `/api/ai/stats` and `/api/export` send `Cache-Control: no-store, no-cache, must-revalidate`, `Pragma: no-cache` and `Expires: 0`. Uploaded files send `Cache-Control: private, max-age=0, must-revalidate`. The OAuth metadata documents send `public, max-age=300`, and a company logo sends `public, max-age=2592000` (30 days). ## Health | Endpoint | What it does | Access | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | `GET /healthz` | Liveness probe. Runs one query and answers `{ "status": "ok", "schema": { "migration", "expects" }, "vec" }`. `schema.migration` is the last migration the database applied, such as `0001_baseline`, and `schema.expects` is the last one this build holds. They are equal when an upgrade is complete. `vec` is the sqlite-vec version. Answers `503 { "status": "unavailable" }` when the database does not respond. | public | For sizes, queues, backups and the version of each search index, use `GET /api/admin/health`. ## Authentication The routes in the two tables below stay reachable with no credential, so sign-in works. Each route then checks what it needs. ### Sign in and sign up | Endpoint | What it does | Access | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | `GET /api/auth/status` | Everything the sign-in screen needs: `authRequired`, `authenticated`, `setupRequired`, `hasAccounts`, `user`, `registrationOpen`, `mailConfigured`, `magicLinkSignIn`, `localOwnerPresent`, `publicUrl`, `instanceName`, `deviceContacts`, `mcpOAuth` (whether an MCP client can sign in with OAuth), `oidc` (`{ name }` of the single sign-on provider, or `null`), and the basemap style URLs in `map`. | public | | `POST /api/auth/setup` | Create the first account: `email`, `username`, `password`, `displayName`. The account is an admin and is signed in (`201 { user }`). On a used instance it takes over the local owner and keeps its data. `409 SETUP_COMPLETE` once an account with a password exists. | public | | `POST /api/auth/login` | Sign in with `identifier` (username or email) and `password`. `remember: false` sets a cookie that ends with the browser. Answers `{ user }`. `401 INVALID_CREDENTIALS` for a wrong password and for an unknown account alike. `403 ACCOUNT_DISABLED` for a disabled account. With sign-in off it answers `{ "authRequired": false, "user": null }`. | public | | `POST /api/auth/logout` | End this session and clear the cookie. | public | | `POST /api/auth/register` | Create a member account without an invitation: `email`, `username`, `password` and `displayName` (optional). The account is signed in (`201 { user }`). `403 REGISTRATION_CLOSED` unless an admin opened registration. | public | | `POST /api/auth/accept-invitation` | Turn an invitation into an account: `token`, `email`, `username`, `password`, `displayName`. The account takes the invitation's role and is signed in. `404 INVITATION_NOT_FOUND`, or `410 INVITATION_USED`, `INVITATION_REVOKED` or `INVITATION_EXPIRED`. | public | | `POST /api/auth/invitations/check` | Whether an invitation link can still make an account, for the join page before it shows the form: `token`. `200 { ok: true }`, or the same `404` and `410` codes as accepting it. | public | | `POST /api/auth/password-reset/request` | Send a reset link to `email`. Always answers `202 {}`. It sends mail only when outgoing mail and `PUBLIC_URL` are set. The link lasts 1 hour. An account gets at most 3 links an hour, reset and sign-in links together. | public | | `POST /api/auth/password-reset/complete` | Set a new `password` with the link's `token`. Ends every session, revokes every personal token and app grant, unlinks every single sign-on, and signs you in. Following the link also makes the account's email trusted for single sign-on email matching, as `POST /api/auth/magic-link/complete` does. `404 LINK_INVALID`, `410 LINK_EXPIRED`, `410 LINK_USED` or `403 ACCOUNT_DISABLED`. | public | | `POST /api/auth/magic-link/request` | Send a sign-in link to `email`. Answers `202 {}`. `404 MAGIC_LINK_OFF` when the instance has emailed sign-in links off, or cannot send them. The link lasts 15 minutes. | public | | `POST /api/auth/magic-link/complete` | Sign in with the link's `token`. Same errors as the reset link. | public | | `POST /api/auth/passkeys/login/options` | Start a passkey sign-in. Answers `{ ceremonyId, options }`. | public | | `POST /api/auth/passkeys/login/verify` | Finish a passkey sign-in with `{ ceremonyId, response, remember }`. Sets the session cookie. | public | | `GET /api/auth/oidc/start` | Start a single sign-on with the provider an admin set up. `remember=0` makes the session end with the browser, as **Keep me signed in** off does. `returnTo` is a path on this site to open after the sign-in, and anything else is ignored. Answers 302 to the provider, and sets a cookie that binds the flow to this browser. It always redirects and never answers JSON: while sign-in is off, with no provider, when the provider cannot be reached, or past its own limit of 20 starts a minute, it answers 302 to `/?oidc=failed`. | public | | `GET /api/auth/oidc/callback` | Where the provider sends the browser back. While sign-in is off, it answers 302 to `/`. Without the flow cookie that `/start` set, it answers 302 to `/?oidc=failed`, and the flow waits for its own browser. Past its own limit of 20 callbacks a minute, it answers 302 to `/?oidc=failed` and leaves the flow unused. Signs in an account linked to the provider account, or, with email matching on, an account with the same verified email, a trusted email of its own, and no link to this provider yet. A sign-in answers 302 to its `returnTo`, or `/`. A failure answers 302 to `/?oidc=` with `no-account`, `disabled`, `must-change-password`, `expired`, `denied` (for `access_denied`) or `failed`. A link flow lands on `/settings/account?oidc=` with `linked`, `taken`, `expired`, `denied` or `failed`, and then `#password`. | public | ### Your account | Endpoint | What it does | Access | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | | `GET /api/auth/me` | Your account as `{ user, via, pendingEmail }`. `pendingEmail` is the new address an email change waits on, or `null`. | your session | | `PATCH /api/auth/me` | Change `displayName`, `username` or `email`. Fields you leave out stay. A new `email` needs `currentPassword`, or it answers `401 INVALID_CREDENTIALS`. When mail can carry links (mail set up and `PUBLIC_URL` set), the email does not change yet: a link valid for 24 hours goes to the new address, the old address gets a notice, and the answer's `pendingEmail` names the new one. A new request replaces a waiting one. `429 LINK_LIMIT` past three mailed links an hour, `502 MAIL_FAILED` when the link cannot be sent. Without mail, the email changes at once and is not trusted for single sign-on email matching until a mailed link proves it. Either way, the unused reset and sign-in links stop working when the email changes. | your session | | `DELETE /api/auth/email-change` | Stop the email change that waits on its mailed link. The link stops working, and the email stays. Answers `{ ok: true }`. | your session | | `POST /api/auth/email-change/complete` | Finish an email change with the `token` from the link mailed to the new address. The address becomes the account's and is trusted, and the unused links mailed to the old one stop working. Answers `{ email }`. It needs no session and signs nobody in. `404 LINK_INVALID`, `410 LINK_USED` or `LINK_EXPIRED`, `409` when another account took the address. | public | | `POST /api/auth/me/avatar` | Upload your account photo. The server stores a 512 px JPEG. | your session | | `DELETE /api/auth/me/avatar` | Remove your account photo. | your session | | `POST /api/auth/change-password` | Change your password with `currentPassword` and `newPassword`. Ends every other session. | your session | | `GET /api/auth/sessions` | Your live sessions, with `current: true` on this one. | your session | | `DELETE /api/auth/sessions` | Sign out everywhere else. Answers `{ "revoked": n }`. | your session | | `GET /api/auth/preferences` | Every preference with its default, as `{ preferences, stored }`. `stored` names the keys you chose. A token may call it. | your session | | `PATCH /api/auth/preferences` | Change one or more preferences. An unknown key refuses the request with `400`. A token may call it. | your session | | `DELETE /api/auth/preferences/:key` | Reset one preference to its default. `404` for an unknown key. A token may call it. | your session | | `GET /api/auth/tokens` | Your tokens and approved apps, newest first, revoked and expired ones included. Never shows a token again. | your session | | `POST /api/auth/tokens` | Create a personal token: `name` (1 to 60 characters), `expiresInDays` (1 to 3,650, optional: with none, the token never expires) and `readOnly` (optional, default `false`). The answer holds the token once. `201`. | your session | | `DELETE /api/auth/tokens/:id` | Revoke one of your tokens. Answers `{ "revoked": true }`. The row stays with `revokedAt` set. | your session | | `POST /api/auth/passkeys/register/options` | Start adding a passkey. Answers `{ ceremonyId, options }`. | your session | | `POST /api/auth/passkeys/register/verify` | Finish adding a passkey with `{ ceremonyId, response, name }`. `201 { passkey }`. | your session | | `GET /api/auth/passkeys` | Your passkeys, and whether you dismissed the passkey prompt. | your session | | `PATCH /api/auth/passkeys/:id` | Rename a passkey with `{ name }`. | your session | | `DELETE /api/auth/passkeys/:id` | Remove a passkey. | your session | | `POST /api/auth/passkey-nudge/dismiss` | Stop the prompt that suggests a passkey. | your session | | `POST /api/auth/oidc/link` | Start linking your account to the single sign-on provider. Send `currentPassword`, or it answers `401 INVALID_CREDENTIALS`. Answers `{ url }`, the provider's address to open in this browser. `429` past 20 starts a minute. When mail is set up, your email gets a notice once the link is made. | your session | | `GET /api/auth/identities` | Your linked provider sign-ins as `{ identities, matchByEmail }`. `current: true` marks an identity at the provider set up now. `matchByEmail` says whether email matching is on and your email is trusted, so a single sign-on with that verified email can link an unlinked account again. | your session | | `DELETE /api/auth/identities/:id` | Unlink a provider sign-in. Send `currentPassword`, or it answers `401 INVALID_CREDENTIALS`. Your password still works. `404` for one that is not yours. When mail is set up, your email gets a notice. | your session | The passkey routes answer `400 PASSKEY_UNSUPPORTED_ORIGIN` when the server's address is an IP address. They answer `410 CEREMONY_EXPIRED` when the ceremony is unknown or older than 5 minutes, `401 PASSKEY_VERIFICATION_FAILED` when the browser's answer does not verify, and `404 PASSKEY_NOT_FOUND` for an unknown passkey. ### Personal tokens A token is the credential for a script. It needs a session to create it, so a token cannot create another token or revoke itself. For the steps in the app, see [Create a token](mcp.md#create-a-token). ```bash curl -X POST http://localhost:3210/api/auth/tokens \ -H "Content-Type: application/json" \ -b cookies.txt \ -d '{"name":"Nightly export","expiresInDays":365,"readOnly":true}' ``` ```json { "id": "2b40fbbe-dee6-4c42-ac5a-beefe9944289", "name": "Nightly export", "token": "ctk_...", "tokenPrefix": "ctk_AbCdEfGh", "expiresAt": "2027-09-30T04:59:17.151Z", "readOnly": true } ``` Keep `token` somewhere safe. The server stores only its SHA-256 hash, and it never shows the token again. `GET /api/auth/tokens` answers `{ tokens }`. Each row has `id`, `name`, `tokenPrefix`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt`, `readOnly` and `kind`. `kind` is `personal` for a token you made and `oauth` for an app you approved, whose `tokenPrefix` is the host it signs in from. `DELETE /api/auth/tokens/:id` disconnects such an app. ### Apps that sign in with OAuth An MCP client such as Claude or ChatGPT can sign in with OAuth 2.1 instead of a token. OAuth is on when sign-in is on and `PUBLIC_URL` is an `https` address, or an `http` address on `localhost`, `127.0.0.1` or `[::1]` for a local client. While it is off, every route below answers `404`. Each answer to a client uses OAuth's own field names and errors, such as `{"error":"invalid_grant"}`. The two consent routes need a signed-in session, and the others are public. | Route | What it does | | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /.well-known/oauth-protected-resource/api/mcp` | The MCP endpoint's metadata (RFC 9728): its resource, its authorization server and its scopes. A `401` from `/api/mcp` names this URL | | `GET /.well-known/oauth-protected-resource` | The same metadata at the root | | `GET /.well-known/oauth-authorization-server` | The authorization server's metadata (RFC 8414) | | `GET /oauth/authorize` | Starts a sign-in with PKCE (S256). It sends the browser to the consent page, or back to the client with an error | | `POST /oauth/token` | A form body. Trades a code for an access token and a refresh token, or rotates a refresh token | | `POST /oauth/register` | Registers a public client (RFC 7591). A client may instead use the https URL of its metadata document as its `client_id` | | `POST /oauth/revoke` | Gives a token back (RFC 7009). The whole grant ends | | `GET /api/auth/oauth/requests/:id` | For the consent page: the app, where it sends you back, and whether it asked to write | | `POST /api/auth/oauth/requests/:id` | For the consent page: `{"decision":"allow","access":"read"}` or `"deny"`. Answers `{ redirectTo }`, the address the browser goes to next. `access` is `read` when left out, and an app that did not ask to write gets read access whatever `access` says | The scopes are `contrack:read` and `contrack:write`. An access token lasts an hour and works on `/api/mcp` only. A refresh token works once and lasts 30 days. Each refresh returns a new pair, so a grant lasts 30 days past its last refresh. A used refresh token that comes back within 60 seconds gets a new pair, for a retry. One that comes back later ends the grant. The examples below use a token in the `CONTRACK_TOKEN` variable. With sign-in off, leave out the `Authorization` header. ## Contacts A contact carries its child records: `emails`, `phones`, `addresses`, `socialLinks`, `education`, `experience`, `sources`, `tags`, `interests` and `attributes` (including extra dates stored as `name: "date: