https://api.userkit.dev.
What you are integrating
UserKit is the customer plane of a product — contacts, sessions, teams, billing, support — plus a machine API for the product’s backend. It never authenticates the product’s users: the product’s own auth does, and the product’s server vouches for the one on the page. The people who use the product are called contacts. Nothing here touches the developer’s own team accounts; those live in the panel athttps://app.userkit.dev.
Facts that must not be gotten wrong:
- Every organization has a
liveand atestenvironment. Which one a request touches is decided by the credential presented, never by a parameter. Integrate againsttest; swap the keys to go live. - Four credential prefixes, four surfaces.
uk_pk_…is the publishable key (identifies the environment, safe in page HTML).uk_sk_…is the secret API key (server only, shown once, never in a bundle or a repo).uk_ct_…is a contact session.uk_st_…is a staff session for the panel — your code never handles one. - An email sent through the API is an attribute, not proof.
email_verifiedbecomes true only when a link or code from that inbox is used. Do not build anything that assumes otherwise. - Auth endpoints never reveal whether an account exists. The magic link
and the email code answer
202unconditionally. That is a feature; do not branch on it. - Errors are one envelope:
{"error": {"code", "message"}}. Branch oncode, showmessage.
Step 0 — keys
The developer signs up at app.userkit.dev (sign-up creates the organization and both environments in one transaction). In the panel, under Settings → API keys, with the environment switch on test:- copy the publishable key,
uk_pk_test_…— it is stored in plaintext and re-showable; - create an API key,
uk_sk_test_…— shown exactly once; a lost key is replaced, never recovered.
Response
Two arrangements — pick one before Step 1
There are exactly two ways a page can hold a contact session, and the choice decides Steps 1 to 4. Both are first-class; the API was built for both.
Choose proxy when a server already exists — it buys the one thing direct
cannot, a session token no script can read. Choose direct when there is no
server to put it on; a publishable key in page HTML is the design, not a leak,
because it identifies an environment and authenticates nothing.
Steps 1 to 4 below are the proxy arrangement. The direct one is the same four
steps and is written out in full after them. From Step 5 on, the two are
identical — the JWT, the machine surface and webhooks do not know or care
which one the page chose.
Step 1 — install the SDK
The recommended arrangement for a Next.js App Router app is@userkit/nextjs:
route handlers on the app’s own origin hold the contact session in an httpOnly
cookie, and the publishable key never enters the bundle.
.env.local
NEXT_PUBLIC_: in this arrangement the key stays on the
server, where the route handlers put it on the wire.
USERKIT_SECRET_KEY is optional and worth setting. It authenticates nothing
here — every call the handlers make is still signed by the publishable key or
by the contact’s own session. What it does is prove to UserKit that these
requests come from a server, which is what lets the handlers say which
visitor each one is for.
Without it, every call leaves your deployment from one address, so UserKit’s
per-IP limits apply to your whole user base at once: five email codes an hour
become the budget for everybody, and the “new device, new location” line on a
sign-in names your server. The proof has to be
a secret — a header alone is a limit any caller resets per request — so keep
this one off NEXT_PUBLIC_ too. It is read on the server and put in one
outbound header; it never reaches a response, a cookie or the bundle.
Step 2 — mount the route handlers
app/api/userkit/[...userkit]/route.ts
uk_ct_… token
have it stripped out of the response and set as an httpOnly cookie; every
authenticated call afterwards is signed here rather than in the browser. A 401
on one of those signed calls clears the cookie, so a session revoked from another
device does not leave a credential behind that authenticates nothing.
Step 3 — the provider and the boot
app/layout.tsx
app/api/userkit-boot/route.ts
app/userkit-boot.tsx
<UserKitBoot /> once inside the provider. The message signed is the
external_id and nothing else; USERKIT_IDENTITY_SECRET is read from
GET /v1/organization/environments/{id}/identity, per environment, and never
leaves the server. A valid hash mints a verified session; a missing one
mints an unverified session that is fine for development and barred from
anything another person’s data could leak through; a wrong one is refused
with 401 invalid_identity_hash rather than downgraded. The whole rule is at
/en/customer-auth/federated, and the pages for Supabase, Clerk, Firebase and
Better Auth beside it show where currentUserId comes from in each.
Guards for client components: SignedIn, SignedOut, SessionLoading,
Verified, and the useContact() / useSession() hooks, all imported from
@userkit/nextjs.
Step 4 — protect a page
app/dashboard/page.tsx
getSession() is memoised per request — calling it in a layout and three
components costs one round trip. It answers { contact, verified, expiresAt }
or null. In the app’s own route handlers and Server Actions, the same call is
the authentication:
app/api/my-data/route.ts
verified, not just on presence: an unverified session is a valid
session over an unproven identity claim.
Steps 1 to 4, direct arrangement
Skip this section if the app took the proxy above. This is the same four steps for an app with no server of its own — a Vite SPA, a static export, React Native. Nothing after it changes.VITE_USERKIT_PUBLISHABLE_KEY, NEXT_PUBLIC_USERKIT_PUBLISHABLE_KEY, a
build-time constant — whatever the bundler already does):
main.tsx
<UserButton />, the guards and the boot are the same, imported from
@userkit/react instead — the server half of the boot is whatever endpoint the
app’s backend exposes to sign the id:
App.tsx
-
There is no server-side session read, so a protected view is a rendered
guard rather than a redirect decided before the response.
SessionLoadingexists for that gap —statusstarts atloadingprecisely so a sign-in button does not flash on every reload. -
The token is obtained in the browser.
useUserKit()hands over the same client the components use:
Step 5 — a backend in another language
A backend that is not the Next server verifies a short-lived JWT offline — no call to UserKit on the request path. Where the JWT comes from is the one place the two arrangements still differ, and it is one import:getToken() in the browser throws unsupported rather than
handing a credential to script — the cookie is on your server, so the mint is
too.
The token is minted by POST /v1/contact/token from the uk_ct_… session, is
signed with ES256 per environment, and lives 5 minutes — which is why
caching the verification key set is safe. Claims: iss (the environment id —
pin it), sub (contact id), sid (session id), external_id, email,
verified, iat/exp.
The backend verifies against the public key set, addressed by the publishable
key the backend’s config already holds:
- Verify the signature before reading anything. A JWT is base64, not encryption.
- Pin the issuer. A test-environment token must not satisfy a live check.
- Cache the key set and keep verifying when a refetch fails. The response
carries
stale-while-revalidateandstale-if-errorfor exactly this; the clients above honour it. - For sub-5-minute revocation, poll
GET /v1/revocations/{publishable_key}(cacheable,max_age_seconds: 15) and match the token’ssid— or useverifyContactTokenfrom@userkit/nextjs/verify, which does signature and revocation in one offline call. A verifier that cannot fetch the list keeps verifying; the token’s own expiry is the floor.
Step 6 — identify contacts from the server
The machine surface, with theuk_sk_… key. Identify is create-or-update:
201 with "created": true the first time, 200 with "created": false
after. Every write on this surface accepts an Idempotency-Key and answers a
retry with the same status and body — programs retry, so send one on every
write. Rate limits are per key with per-environment headroom, and every
response carries RateLimit-* headers; pace by them instead of by tripping
429.
Step 7 — webhooks
Register an endpoint in the panel (Webhooks, choosing the environment), or with the panel’s own credentials viaPOST /v1/organization/webhooks. An
endpoint is a URL (https:// only), a signing secret (re-showable in the
panel) and a list of subscribed event types — empty means all of them.
The handler’s contract: delivery is at-least-once and unordered.
Deduplicate on id, order on sequence, answer 2xx within ten seconds and
do the work afterwards. Verify the signature on the raw bytes:
app/api/userkit-webhooks/route.ts
Verify the integration
Each line proves one step, in order:verified: true, confirm the protected page renders the contact, and
confirm the contact appears in the panel under the test environment. For webhooks, the panel’s endpoint screen
has a test send (webhook.test) and a delivery log with replay.
Ask the install doctor before saying it works
The curl lines above prove the pieces answer. The install doctor is the question they cannot answer between them — is this environment actually wired up — and it reports every check with the evidence behind it and the repair when there is one:- in the panel, at Organization → home;
- as the
run_doctortool, if the assistant doing this integration has the MCP server connected. It takes no arguments: the environment is the one the API key belongs to.
unknown means the check could not tell — not that it
passed — and a check that does not apply to this environment is omitted rather
than reported as ok. A summary that folds the unknowns into “everything is
working” is the one failure this endpoint exists to prevent, because an untested
integration and a broken one look the same from here.
Going live is a key swap: create live keys, change uk_pk_test_…/uk_sk_test_…
to their live counterparts in the deployment’s environment, and — before
launch — set the publishable key’s allowed origins in the panel, which locks
browser calls to the product’s own domains (an empty list allows any origin,
which is the right default for localhost and the wrong one for production).