HatchedDocs
Concepts

Auth model

Secret keys, publishable keys, widget session tokens, and embed tokens — which one to use, when, and why.

Hatched exposes four token types. They exist because different parts of your product have different trust boundaries, and mixing them up is the single most common reason integrations get shipped with secret-key leaks.

The four tokens

TokenPrefixWhere it livesCan do
Secret API keyhatch_live_*, hatch_test_*Server (env var)Everything. Full account access.
Publishable keyhatch_pk_*Browser (safe)Read buddies/operations, mint read-only embed tokens.
Widget session tokenJWTBrowserScoped interactive actions (track, buy, equip) for one buddy.
Embed tokenJWTBrowserRead-only widget display for one buddy.

Session token vs embed token

These two are the easiest to mix up — they're both browser JWTs scoped to one buddy, but they come from different endpoints and do different things:

Widget session tokenEmbed token
Minted byPOST /api/v1/widget-sessionshatched.widgetSessions.create(...)POST /api/v1/embed-tokenshatched.embedTokens.create(...)
Requires scopes?Yes (['read', 'events:track', ...]) — sending none is rejectedNo — the endpoint rejects a scopes field
Loader attributedata-session-tokendata-embed-token
Widget modeinteractive — can track events, purchase, equipread-only — display only
Server-side stateTracked (revocable via widgetSessions.revoke)Stateless (validity = JWT signature + exp)
Default TTL1h (max 1h)24h (max 24h)

Rule of thumb: if any widget on the page needs to do something (track an event, buy or equip an item), mint a session token. If every mount is purely display, an embed token is cheaper. Both require an existing buddy_id — see First user bootstrap.

Decision tree

Is this code running on the browser?
├── No (Node, edge, server component, route handler)
│     → Secret API key (HATCHED_API_KEY env var)

└── Yes
      ├── Do you need mutation (send event, earn coin)?
      │     → Call your own backend route with the secret key.
      │        Never put a secret key in the browser bundle.

      ├── Do you need the user to interact with widgets?
      │     → Server mints a widget session token,
      │        browser loads widget.js with data-session-token.

      ├── Do you only need to display widgets/read-only state?
      │     → Server mints an embed token (cheaper, stateless).

      └── Do you need raw API reads from a SPA/static site?
            → Publishable key in the browser + @hatched/sdk-js with { publishableKey }.

Secret API key

import { HatchedClient } from '@hatched/sdk-js';

const hatched = new HatchedClient({
  apiKey: process.env.HATCHED_API_KEY!,
});

Rules.

  • Load from an env variable. Never hard-code.
  • Rotate on any suspected leak. Rotation is instant — old key returns 401 immediately.
  • The SDK throws at construction if it detects a DOM environment. The only way to suppress is allowBrowser: true, intended exclusively for unit tests.

Publishable key

const hatched = new HatchedClient({
  publishableKey: 'hatch_pk_xxxxxxxx',
});

const buddy = await hatched.buddies.get(buddyId);     // ✅ ok
await hatched.events.send({ ... });                    // ❌ PublishableKeyScopeError

Rules.

  • Publishable keys are scoped: read-only endpoints (buddies, operations) plus embedTokens.create. Every mutation endpoint returns 403 publishable_key_scope.
  • Safe to commit to a browser bundle, include in <meta> tags, or expose as NEXT_PUBLIC_*.
  • Per-key scope is configurable in Dashboard → Developers → API keys → Create publishable key → check the endpoints you want to allow.

Widget session token

The flow for an interactive widget (buddy, marketplace, celebrate):

  1. Browser asks your backend for a session.
  2. Backend calls hatched.widgetSessions.create(...) with a secret key.
  3. Backend returns { token, expiresAt } to the browser.
  4. Browser loads widget.js with data-session-token and mounts <div data-hatched-mount="buddy">.
  5. The widget talks to Hatched directly, signed with the session token.
const session = await hatched.widgetSessions.create({
  buddyId: 'bdy_abc',
  userId: 'user_42',
  scopes: ['read', 'events:track', 'marketplace:browse', 'marketplace:purchase', 'items:equip'],
  ttlSeconds: 60 * 15,
});

Rules.

  • Short-lived (minutes, not hours). Re-mint on focus or route change.
  • Scoped to one buddyId. If you switch buddies, re-mint.
  • Scoped to the exact list of widget scopes you pass. A session minted without marketplace:purchase cannot buy items even if the widget tries.

Embed token

Read-only sibling of widget session tokens. Stateless and cheap to mint — pass one per buddy/widget render on a page.

This is the token that confuses people most often. It is not something you create once in the dashboard like an API key. It is a short-lived JWT that your backend mints on demand — typically inside a route handler — and hands to the browser for the page render.

Why it exists

The widget runs in the user's browser. It needs some token to identify which buddy to display, but you cannot put a secret API key in the browser (any visitor could read it from devtools and call mutating endpoints with your account's full authority). The embed token solves this: it is signed by Hatched, scoped to one (userId, buddyId) pair, expires automatically, and can only do read-only widget display: buddy, badges, streaks, leaderboards, and marketplace catalog/state.

How to mint one

// app/api/hatched/embed-token/route.ts  — Next.js
import { HatchedClient } from '@hatched/sdk-js';

const hatched = new HatchedClient({ apiKey: process.env.HATCHED_API_KEY! });

export async function POST(req: Request) {
  const { userId, buddyId } = await req.json();

  const embed = await hatched.embedTokens.create({
    userId,
    buddyId,
    ttlSeconds: 60 * 60, // 1h is a reasonable default
  });

  return Response.json({ token: embed.token, expiresAt: embed.expiresAt });
}

Or with raw HTTP if you do not use the SDK:

POST /embed-tokens
Authorization: Bearer hatch_live_xxxxxxxxxxxx
Content-Type: application/json

{ "user_id": "user_42", "buddy_id": "bdy_abc", "ttl_seconds": 3600 }

Response:

{ "token": "eyJhbGciOi…", "expires_at": "2026-05-05T16:00:00Z", "mode": "read-only" }

How it reaches the browser

<!-- The page fetches the token from your own backend route, then -->
<!-- inlines it into the widget loader before the script runs.    -->
<script
  src="https://cdn.hatched.live/widget.js"
  data-embed-token="eyJhbGciOi…"
  defer
></script>
<div data-hatched-mount="buddy"></div>

Lifecycle

  • Stateless: Hatched does not store embed tokens. Validity comes from the JWT signature and the exp claim — there is no revocation list.
  • TTL: minimum 5 minutes, maximum 24 hours, default 24 hours. Pick the shortest TTL that fits your render cadence.
  • Re-mint, do not cache for long: mint on each page request (or each SPA route change). The mint call is cheap.
  • Difference from a widget session token: an embed token can only display — it cannot send events, equip items, or buy from the marketplace. For interactivity, use a widget session token instead.

What lives where

LayerToken
.env / Vercel secrets / GitHub secretsSecret API key
NEXT_PUBLIC_HATCHED_PK / HTML <meta>Publishable key
Request to your /api/hatched/session endpoint— returns widget session token
data-session-token / data-embed-token script attributeWidget session token or embed token

What not to do

  • ❌ Put a secret key in a .env.production file that gets shipped to the browser via Vite/webpack DefinePlugin. Check your bundler output.
  • ❌ Use a session token to call the raw API from fetch — session tokens are only accepted by the widget runtime.
  • ❌ Reuse a single session token across many users — tokens are user-bound.
  • ❌ Assume a publishable key is "read-only enough" to skip scope review — check the scope set before publishing a new one.