Skip to main content
This page is the entire integration, in order, with nothing on another page required to finish it. It is written for an AI agent doing the integration on a developer’s behalf, which also makes it the fastest read for a person. Every command runs against the hosted service at 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 at https://app.userkit.dev. Facts that must not be gotten wrong:
  • Every organization has a live and a test environment. Which one a request touches is decided by the credential presented, never by a parameter. Integrate against test; 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_verified becomes 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 202 unconditionally. That is a feature; do not branch on it.
  • Errors are one envelope: {"error": {"code", "message"}}. Branch on code, show message.

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.
Confirm the API key and learn the environment id (needed later for JWT verification):
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
Deliberately not 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
This is the whole server half. Endpoints that answer with a 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.
The origin on the wire is this deployment’s, not the browser’s. These calls leave from your server, so the handler sends its own address as Origin — a same-origin request from the page carries none to relay. Put your app’s own origin on the publishable key’s allowed-origins list; an empty list still allows everything, so nothing changes until you narrow it.

Step 3 — the provider and the boot

app/layout.tsx
The app’s own auth signs the user in. What UserKit needs is a boot that names that user, signed by the server so the page cannot forge it:
app/api/userkit-boot/route.ts
app/userkit-boot.tsx
Render <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
Gate on 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.
The key is public here, so it travels the way public configuration travels (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
Two differences from the proxy arrangement, and they are the whole difference:
  • There is no server-side session read, so a protected view is a rendered guard rather than a redirect decided before the response. SessionLoading exists for that gap — status starts at loading precisely 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:
Before this app goes to production, set the publishable key’s allowed origins in the panel. In the direct arrangement that list is the only thing standing between the key in your page and the same key in somebody else’s.

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:
In proxy mode 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:
Rules that hold this together:
  • 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-revalidate and stale-if-error for 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’s sid — or use verifyContactToken from @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 the uk_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 via POST /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
The timestamp is inside what is signed, and refusing old ones is your half of the replay defence. Failed deliveries retry twelve times over ~14 hours; five deliveries in a row spending every attempt disables the endpoint and emails the organization’s owners.

Verify the integration

Each line proves one step, in order:
Then, in a browser: sign in through the app’s own auth, confirm the boot answers 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_doctor tool, 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.
It has three statuses. 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).

Where the rest lives

Everything above is enough to ship. The deeper pages, when a specific surface matters: federated identity, proving an address, session tokens and revocation, webhooks in full and the API reference. This documentation is also published for machines at docs.userkit.dev/llms.txt.