Skip to main content
Every credential the API mints follows one shape: high-entropy random bytes behind a prefix that says what the token is, stored as a SHA-256 hash, shown in plaintext exactly once. The prefix does two jobs. It lets a leaked token be recognized — in a log, in a git push, in a repository scan — and it keeps a credential from one surface from ever opening another’s routes.

The four families

uk_st_… — staff session

A person signed into the panel. Administers the organization. Lives 30 days, revocable.

uk_sk_… — organization API key

Your backend. Reads and writes the customer plane, scoped to the key’s environment.

uk_pk_… — publishable key

Your pages. Public by design; the allowed-origins list is its boundary.

uk_ct_… — contact session

One of your users. Reads their own contact and nothing else.
All four ride the Authorization: Bearer header, except the publishable key. That one travels in the request body of the endpoints that accept it, and in the path of the two public reads addressed by it — GET /v1/jwks/{publishableKey} and GET /v1/config/{publishableKey}, which a sign-in screen and a backend fetch before anybody has signed in.
Nothing else is accepted as a credential. No query parameter — it would land in access logs and browser history. No cookie — this API is not cookie-authenticated, which is also why CORS never needs credentials.

Presenting the wrong one

A well-formed credential from another family gets told so, rather than a flat “invalid”:
401
That difference matters: one is a config mistake, the other is a security event, and they should not read the same in your logs.

Staff sessions

uk_st_…, minted by /v1/auth/signup, /v1/auth/login and /v1/auth/two-factor. Valid for 30 days. Long, because it is revocable. Signing out, resetting the password or losing the membership all kill it server-side — unlike a self-contained JWT, which stays valid until it expires no matter what happens to the account. Changing the password from inside the account ends every other session and keeps the one that made the change: the person doing it is the one who just proved the old password.
A user can see where they are signed in and sign out everywhere else:
“Everywhere else” keeps the session that asked — nobody wants to be logged out of the tab they clicked in.

Organization API keys

uk_sk_…, server-side only. Created in the panel, shown once, stored as a hash.
The environment is part of the prefix and, more importantly, part of the stored row: it is resolved from there on every request. See Environments. Revoking stamps revoked_at rather than deleting the row, so the audit trail of which key existed does not disappear with it.
A key in a browser bundle is a key in every visitor’s hands. uk_sk_ belongs on your server. For pages, use a publishable key.

Publishable keys

uk_pk_…, one per environment, seeded with it, stored in plaintext — because a publishable key identifies and authorizes almost nothing, and the panel has to be able to show it again. It sits in your page’s HTML by design. What keeps a stranger who read it from flooding your tenant with junk contacts is the allowed-origins list on its row, plus a per-key rate limit on boot.
Matching is exact on the normalized origin — no wildcards, no prefixes, because the list is short and the ambiguity of anything cleverer is where bypasses live.
An empty list allows any origin. That is the getting-started state, and it is the one thing to fix before going live.
With a non-empty list a missing Origin header is refused too: browsers always send one cross-origin, so “no Origin” means “not a browser” — exactly the caller the list exists to stop.

Contact sessions

uk_ct_…, your user’s own credential, minted by boot, by a redeemed magic link or by an email code. Valid for 30 days. A contact session reads its own contact and nothing else. That scope is the whole authorization model of uk_ct_.
It also carries a verified flag — whether the identity behind it was proven: An unverified session is usable in development and marked in the panel, but it is barred from anything another person’s data could leak through. A contact can see and end their own sessions — GET /v1/contact/sessions, DELETE /v1/contact/sessions/{id}, and DELETE /v1/contact/sessions for everywhere-else. That is part of the token’s safety case, not a nice-to-have: it lives on your domain without httpOnly.

The one non-opaque credential

Everything above is opaque — a random string whose meaning lives in our database. The exception is the session JWT: five minutes, ES256, signed per environment, minted from a uk_ct_… session at POST /v1/contact/token and verified by your backend offline against GET /v1/jwks/{publishableKey}. It exists so your API does not have to call ours on every request. It is short precisely because it cannot be revoked. See Session tokens.

One-time tokens

Not credentials you hold, but the same shape — one prefix each, so none can be mistaken for a session:

What is stored

A database dump hands out no working credentials.