Authentication¶
Two credentials reach the API, and each has its own header. An API key sent as a Bearer token authenticates as nobody — the request is treated as a guest and refused by the collection rules.
| Credential | Header | Used by |
|---|---|---|
| API key | X-API-Key: <key> |
Scripts, integrations, anything programmatic |
| Session token | Authorization: Bearer <jwt> |
The app itself, after signing in with a passkey |
The in-app API preview on every create drawer shows a ready-to-run curl with
the correct header for the request in front of you.
Signing in¶
People sign in with a passkey and nothing else; there is no password endpoint.
A passkey is bound to the server's address, so the ceremony runs on the
server's own page, GET /passkey, in the browser:
- The app makes a PKCE verifier, and opens
/passkey?mode=signin&challenge=<S256>&state=<random>. - The page calls
POST /api/passkeys/login/beginand…/finisharound the authenticator, and gets a one-time code. - The page opens
revoked://auth?code=…&state=…. - The app checks the state and redeems the code with its verifier at
POST /api/passkeys/token, which answers with the session token.
The code is useless to anything else that sees the link: it is bound to the
challenge, and spent on first presentation. Registration runs the same way
through /api/passkeys/register/* — with an email address where the operator
allows signups, or with a one-time ticket that adds a passkey to an existing
account. See Accounts.
Scripts do not sign in; they use an API key.
API keys¶
Create one under Settings → Developer → API keys, or over the API by
creating an apiKeys record with a session token. The token is minted
server-side either way and returned exactly once — in the app as the
plaintext you copy, over the API in the X-Plain-Token response header. The
server stores only its hash; there is no way to retrieve it later.
A key can carry an expiresAt date, after which it stops authenticating as
if revoked.
A key carries the permissions granted when it was created, expanded to scopes. A request outside those scopes is refused with a named error rather than a generic 403, so the response says which grant is missing.
A key never opens the vault. Creating one with a vault permission
(vault:read, vault:write) or one of their scopes (record:*, section:*)
fails with api_key_vault_scope: a key is a string that ends up pasted into
other services, and one that reads the vault hands over everything in it. A
tool that needs something from you connects and proposes a link you confirm; a
company sends a request. Keys created before this rule keep what they were
granted until they expire or are revoked — the app marks them, and they should
be replaced.
curl "https://api.example.com/api/collections/links/records" \
-H "X-API-Key: $TOKEN"
The header value is in double quotes on purpose: '$TOKEN' in single quotes
is sent as the six literal characters, since the shell expands variables only
inside double quotes.
Reading a failure¶
| Code | Meaning |
|---|---|
not_authenticated |
No usable credential arrived — wrong header, an unexpanded variable, or an empty value. |
invalid_api_key |
The header arrived, but no key matches it: mistyped, revoked, or expired. |
api_key_vault_scope |
The key being created asks for the vault, which no key may hold. |
| A named scope error | The key is valid but was not granted that permission. Create a key with the right grants; scopes are fixed at creation. |
Revoking¶
Revoke from the same settings list. The next request with that key fails — there is no grace period.
Treat a key that has left its intended channel (pasted into a chat, committed, written to a log) as compromised: revoke it and create a replacement. The plaintext cannot be rotated in place, because the server never had it.