> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent integration

> The whole happy path on one page — install, keys, sign-in, JWT verification and webhooks — written for an agent that reads once and does not navigate.

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](https://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):

```bash theme={null}
curl -s https://api.userkit.dev/v1/me -H "Authorization: Bearer uk_sk_test_…"
```

```json Response theme={null}
{
  "organization": { "id": "…", "name": "Analytical Engine" },
  "environment": { "id": "…", "kind": "test" },
  "api_key": { "id": "…", "prefix": "uk_sk_test_…" }
}
```

## 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.

|                                     | **Proxy** — `@userkit/nextjs`                                                   | **Direct** — `@userkit/react`                                     |
| ----------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Needs                               | a server on your own origin (Next.js App Router)                                | nothing but the browser: SPA, static hosting, React Native        |
| The publishable key                 | stays on the server, so it never enters the bundle                              | ships in the bundle — that is what it is for                      |
| The contact session                 | an httpOnly cookie your own routes set: unreachable from script                 | `localStorage`, or memory when that throws                        |
| The 5-minute JWT                    | minted on your server; `getToken()` in the browser refuses, on purpose          | `getToken()` in the browser, cached and renewed 30s before expiry |
| Allowed origins                     | not the thing protecting you — the calls leave your server — but still set them | **the** control on browser calls; set them before launch          |
| Reading the session while rendering | `getSession()` in a Server Component, before a byte is sent                     | after hydration, through the guards and hooks                     |

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.

```bash theme={null}
npm install @userkit/nextjs
```

```bash .env.local theme={null}
USERKIT_PUBLISHABLE_KEY=uk_pk_test_…
USERKIT_SECRET_KEY=uk_sk_test_…
```

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

```ts app/api/userkit/[...userkit]/route.ts theme={null}
import { createUserKitHandlers } from "@userkit/nextjs/handlers";

export const { GET, POST, PUT, DELETE } = createUserKitHandlers();
```

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.

<Warning>
  **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](/en/guides/api-keys#the-allowed-origins-list); an empty list still allows
  everything, so nothing changes until you narrow it.
</Warning>

## Step 3 — the provider and the boot

```tsx app/layout.tsx theme={null}
import { UserKitProvider } from "@userkit/nextjs";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <UserKitProvider>{children}</UserKitProvider>
      </body>
    </html>
  );
}
```

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:

```ts app/api/userkit-boot/route.ts theme={null}
import { createHmac } from "node:crypto";
import { NextResponse } from "next/server";

import { currentUserId } from "@/lib/auth"; // whatever the app's auth exposes

export const dynamic = "force-dynamic";

export async function GET() {
  const externalId = await currentUserId();
  if (!externalId) {
    return NextResponse.json({ error: "not_signed_in" }, { status: 401, headers: NO_STORE });
  }
  const hash = createHmac("sha256", process.env.USERKIT_IDENTITY_SECRET!)
    .update(externalId)
    .digest("hex");
  return NextResponse.json({ external_id: externalId, hash }, { headers: NO_STORE });
}

// This body identifies one person; a CDN that kept it would hand the next
// visitor a proof of somebody else's identity.
const NO_STORE = { "Cache-Control": "no-store" };
```

```tsx app/userkit-boot.tsx theme={null}
"use client";

import { useEffect } from "react";
import { useUserKit } from "@userkit/nextjs";

export function UserKitBoot() {
  const client = useUserKit();
  useEffect(() => {
    void (async () => {
      const res = await fetch("/api/userkit-boot", { cache: "no-store" });
      if (res.status === 401) {
        await client.boot(); // a visitor; first-touch attribution lands here
      } else {
        const { external_id, hash } = await res.json();
        await client.boot({ externalId: external_id, hash });
      }
    })();
  }, [client]);
  return null;
}
```

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

```tsx app/dashboard/page.tsx theme={null}
import { getSession } from "@userkit/nextjs/server";
import { redirect } from "next/navigation";

export default async function Dashboard() {
  const session = await getSession();
  if (!session) redirect("/sign-in");

  return <p>Signed in as {session.contact.email}</p>;
}
```

`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:

```ts app/api/my-data/route.ts theme={null}
import { getSession } from "@userkit/nextjs/server";

export async function GET() {
  const session = await getSession();
  if (!session?.verified) {
    return Response.json({ error: { code: "unauthorized" } }, { status: 401 });
  }
  return Response.json({ contactId: session.contact.id });
}
```

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.

```bash theme={null}
npm install @userkit/react
```

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):

```tsx main.tsx theme={null}
import { UserKitProvider } from "@userkit/react";

createRoot(document.getElementById("root")!).render(
  <UserKitProvider publishableKey={import.meta.env.VITE_USERKIT_PUBLISHABLE_KEY}>
    <App />
  </UserKitProvider>,
);
```

`<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:

```tsx App.tsx theme={null}
import { SignedIn, SignedOut, SessionLoading, useContact } from "@userkit/react";

function App() {
  return (
    <>
      {/* the same <UserKitBoot /> as in the Next.js step above */}
      <UserKitBoot />
      <SessionLoading>
        <Spinner />
      </SessionLoading>
      <SignedOut>
        <a href="/login">Sign in</a> {/* the app's own login */}
      </SignedOut>
      <SignedIn>
        <Dashboard />
      </SignedIn>
    </>
  );
}

function Dashboard() {
  const contact = useContact(); // null until there is a session
  return <p>Signed in as {contact?.email}</p>;
}
```

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:

  ```ts theme={null}
  import { useUserKit } from "@userkit/react";

  const client = useUserKit();
  const token = await client.getToken(); // cached, renewed 30s before expiry
  ```

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:

<CodeGroup>
  ```ts Direct — in the browser theme={null}
  import { useUserKit } from "@userkit/react";

  const client = useUserKit();
  const token = await client.getToken(); // cached, renewed 30s before expiry
  await fetch("https://api.your-backend.com/things", {
    headers: { Authorization: `Bearer ${token}` },
  });
  ```

  ```ts Proxy — on your server theme={null}
  import { getToken } from "@userkit/nextjs/server";

  const token = await getToken(); // null when there is no session cookie
  await fetch("https://api.your-backend.com/things", {
    headers: { Authorization: `Bearer ${token}` },
  });
  ```
</CodeGroup>

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:

```bash theme={null}
curl -s https://api.userkit.dev/v1/jwks/uk_pk_test_…
```

<CodeGroup>
  ```ts Node — jose theme={null}
  import { createRemoteJWKSet, jwtVerify } from "jose";

  const jwks = createRemoteJWKSet(
    new URL(`https://api.userkit.dev/v1/jwks/${process.env.USERKIT_PUBLISHABLE_KEY}`),
  );

  export async function contactFromRequest(authorization?: string) {
    const token = authorization?.replace(/^Bearer /, "");
    if (!token) return null;

    const { payload } = await jwtVerify(token, jwks, {
      issuer: process.env.USERKIT_ENVIRONMENT_ID, // from /v1/me, step 0
    });
    return payload; // gate on payload.verified before trusting the identity
  }
  ```

  ```python Python — PyJWT theme={null}
  from jwt import PyJWKClient
  import jwt

  jwks = PyJWKClient(
      f"https://api.userkit.dev/v1/jwks/{PUBLISHABLE_KEY}", cache_keys=True
  )

  def contact_from_token(token: str):
      key = jwks.get_signing_key_from_jwt(token).key
      return jwt.decode(token, key, algorithms=["ES256"], issuer=ENVIRONMENT_ID)
  ```
</CodeGroup>

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:

```bash theme={null}
curl -s https://api.userkit.dev/v1/contacts \
  -H "Authorization: Bearer uk_sk_test_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 018f3b2e-user-8421-created" \
  -d '{
    "external_id": "user_8421",
    "email": "grace@example.com",
    "name": "Grace Hopper",
    "customer": { "external_id": "org_77", "name": "Acme" }
  }'
```

`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**:

```ts app/api/userkit-webhooks/route.ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const header = request.headers.get("UserKit-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));

  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  const expected = createHmac("sha256", process.env.USERKIT_WEBHOOK_SECRET!)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const valid =
    age < 300 &&
    !!parts.v1 &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(String(parts.v1)));
  if (!valid) return new Response("invalid signature", { status: 400 });

  const event = JSON.parse(rawBody);
  // event.id      → deduplicate on this before acting
  // event.type    → e.g. "contact.identified", "contact.signed_in"
  // event.sequence → larger = later fact; never compare arrival order
  // event.data    → ids, not snapshots — re-read the resource for state

  return new Response(null, { status: 202 });
}
```

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:

```bash theme={null}
# keys are real and name the test environment
curl -s https://api.userkit.dev/v1/me -H "Authorization: Bearer uk_sk_test_…"

# the environment's public config — branding, locale, what the widget draws
curl -s https://api.userkit.dev/v1/config/uk_pk_test_…

# the key set the backend will verify against (never empty once configured)
curl -s https://api.userkit.dev/v1/jwks/uk_pk_test_…

# a server-minted contact exists and reads back
curl -s https://api.userkit.dev/v1/contacts \
  -H "Authorization: Bearer uk_sk_test_…" -H "Content-Type: application/json" \
  -d '{ "external_id": "smoke_test_1",
       "customer": { "external_id": "org_smoke", "name": "Smoke" } }'
```

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](/en/guides/mcp) 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 `unknown`s 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](/en/customer-auth/federated),
[proving an address](/en/customer-auth/email-proof),
[session tokens and revocation](/en/customer-auth/session-tokens),
[webhooks in full](/en/guides/webhooks) and the
[API reference](/en/api-reference/introduction). This documentation is also
published for machines at
[docs.userkit.dev/llms.txt](https://docs.userkit.dev/llms.txt).
