HatchedDocs
Guides

First user bootstrap

The complete first-run path — from a published config to a mounted widget — in both the SDK and raw HTTP. The one flow you can't skip steps in.

A widget token is scoped to a buddy, and a buddy starts life as an egg. So before you can render anything for user_42, you have to walk a short chain:

published config  →  reuse-or-create egg  →  mark ready  →  hatch
   →  poll the hatch operation  →  read & persist buddy_id  →  widget session token  →  mount widget.js

You can't shortcut from user_id straight to a widget session token — POST /widget-sessions requires an existing buddy_id. This page is that chain, end to end, idempotency and latency included. (For everyday calls once the buddy exists, see Getting started; for the full SDK surface, SDK quickstart.)

Casing: the raw HTTP API is snake_case (user_id, buddy_id, ttl_seconds). The SDK is the only place you write camelCase — it converts on the wire. The ```http blocks below are snake_case; the ```ts blocks are SDK calls.

0. Publish a config version first

Picking a dashboard preset during onboarding publishes your first config version automatically — so most integrations never see this. You hit it only if you built a config from scratch or haven't published yet: POST /eggs then returns 409 with error.code: "no_published_config" (SDK: NoPublishedConfigError), and error.details.publish_url links straight to the dashboard publish page. Open the rules editor and hit Publish — that freezes your draft into the snapshot every new buddy gets pinned to.

Three follow-on gotchas:

  • Draft vs published. Streaks, paths, badges, coin rules, and marketplace items only reach widgets once they're in the published snapshot. If the dashboard shows an active daily_quizzer streak but /widget/streak/daily_quizzer returns 404, that definition is still on a draft config — publish again.
  • Existing buddies stay pinned. Publishing a new version does not move buddies you already created; they keep running on their old (possibly empty) snapshot until you migrate them from the dashboard. During integration testing it's usually cleanest to publish first, then create a fresh test buddy.
  • Key / environment match. hatch_test_* keys talk to staging, hatch_live_* to production — and a config published in one environment isn't visible from the other. Make sure your key and your published config are in the same place.

1. Reuse an existing buddy before creating anything

The cardinal rule: one buddy per (customer, user) — look it up before you create. React Strict Mode, focus re-fetches, hot reloads, and retry logic all love to call your bootstrap twice; without a guard you'll create a second egg, then a third, and eventually hit 409 with error.code: "active_egg_limit" (SDK: ActiveEggLimitError) — error.details.active then lists the eggs you already have, and error.details.max the cap. (POST /eggs?ensure=true reuses one of those instead of failing — see §2.)

With the SDK:

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

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

async function ensureBuddyId(userId: string): Promise<string> {
  // 1. Already stored against this app user? Use it.
  const stored = await loadStoredBuddyId(userId); // your DB column / profile field / localStorage
  if (stored) return stored;

  // 2. Hatched already has a buddy for this user? Reuse it.
  const existing = await hatched.buddies.list({ userId, status: 'active' });
  if (existing.data.length > 0) {
    const buddyId = existing.data[0].buddyId;
    await saveStoredBuddyId(userId, buddyId);
    return buddyId;
  }

  // 3. Nothing yet — create one (next section).
  return createAndHatch(userId);
}

With raw HTTP:

GET /api/v1/buddies?user_id=user_42&status=active
Authorization: Bearer hatch_test_…

# → { "data": [ { "buddy_id": "…", "user_id": "user_42", … } ], "meta": { … } }
# If data is non-empty, store data[0].buddy_id and skip egg creation.

2. Create the egg, mark it ready, hatch

Only reach this when §1 found nothing. Guard the create call so it runs at most once per user — a module-level in-flight map, a DB unique constraint on (app_user_id), or a key you generate yourself (egg:bootstrap:user_42). Don't put it anywhere a React effect, focus handler, or hot reload will re-run it.

Pass ensure: true (raw HTTP: ?ensure=true): instead of always creating a new egg, it returns this user's most recent waiting/ready egg if one already exists. That makes the call idempotent for the bootstrap path and means a retry after a crashed first attempt picks the half-finished egg back up instead of hitting the active-egg cap.

With the SDK:

async function createAndHatch(userId: string): Promise<string> {
  // ensure:true → reuse this user's existing waiting/ready egg if there is one.
  const egg = await hatched.eggs.create({ userId, ensure: true }); // POST /eggs?ensure=true
  if (egg.status === 'waiting') {
    await hatched.eggs.updateStatus(egg.eggId, 'ready');           // PATCH /eggs/:id/status
  }
  const op = await hatched.eggs.hatch(egg.eggId);                  // POST /eggs/:id/hatch → { operationId }

  // Hatch is async — image generation runs 5–45s. Don't block the UI on it.
  const result = await hatched.operations.wait(op.operationId, { intervalMs: 2_000 });
  if (result.status !== 'completed') throw new Error(`hatch ${result.status}`);

  const buddyId = result.result.buddyId;
  await saveStoredBuddyId(userId, buddyId);                        // ← persist immediately
  return buddyId;
}

With raw HTTP:

POST /api/v1/eggs?ensure=true
{ "user_id": "user_42", "metadata": {} }
# → { "egg_id": "…", "status": "waiting", "buddy_id": null,  }
# (status may already be "ready" if you're reusing an egg — skip the PATCH then.)

PATCH /api/v1/eggs/{egg_id}/status
{ "status": "ready" }

POST /api/v1/eggs/{egg_id}/hatch
# → { "operation_id": "op_…", "status": "pending" }

# Poll every ~2s until status is "completed" (typically 545s):
GET /api/v1/operations/{operation_id}
# → { "operation_id": "op_…", "status": "completed", "result": { "buddy_id": "…" },  }

GET /api/v1/eggs/{egg_id} (and GET /api/v1/eggs) echo buddy_id once the egg reaches status: "hatched" — it's null before that. Persisting the result.buddy_id from the operation here is still the right move (you have it sooner, and you avoid an extra round trip).

While the hatch operation is pending, show a loading state — a placeholder egg, a spinner, "your buddy is hatching…". Don't block the first widget render indefinitely; mount it once you have buddy_id and a session token, and let the widget show its own loading state for the artwork.

3. Mint the widget session token

POST /widget-sessions needs the buddy_id from step 2 (or §1) and scopes. Send all the scopes the widgets on that page actually use.

With the SDK:

const session = await hatched.widgetSessions.create({
  buddyId,
  userId: 'user_42',
  scopes: ['read', 'events:track', 'marketplace:browse', 'marketplace:purchase', 'items:equip'],
  ttlSeconds: 3600,
});
// → { token, sessionId, expiresAt, scopes }

With raw HTTP:

POST /api/v1/widget-sessions
{
  "user_id": "user_42",
  "buddy_id": "uuid-from-operation-result",
  "scopes": ["read", "events:track", "marketplace:browse", "marketplace:purchase", "items:equip"],
  "ttl_seconds": 3600
}
# → { "token": "wgt_…", "session_id": "…", "expires_at": "…", "scopes": [  ] }

This is not the embed-token endpoint. POST /embed-tokens is the read-only path — it rejects scopes and returns mode: read-only. Use a session token (data-session-token) when the widgets need to track events, purchase, or equip; use an embed token (data-embed-token) only for purely display mounts. See Auth model.

4. Mount the widget

<script
  src="https://cdn.hatched.live/widget.js"
  data-session-token="{{session.token}}"
  defer
></script>

<div data-hatched-mount="buddy"></div>

The token is short-lived; re-mint it on each page load (or just before it expires). The widget reads buddy state from /widget/state and renders artwork when it's ready.

Persist buddy_id — this is not optional

After the first successful hatch, store buddy_id against your app user and read it back on every subsequent load:

  • Authenticated apps — a column or profile-metadata field on your user record (users.hatched_buddy_id).
  • Anonymous demoslocalStorage keyed by your local user id.

On every widget load: stored buddy_id → use it; else GET /buddies?user_id=… → reuse the first active buddy; only then create an egg. Never create an egg on app mount, focus, hot reload, or a failed widget mount.

Common pitfalls

SymptomCauseFix
property userId should not existSent camelCase to the raw HTTP APIThe raw API is snake_case (user_id). Use snake_case, or use @hatched/sdk-js (it converts for you).
buddy_id must be a UUIDPassed user_id (or nothing) where buddy_id was expectedMint the session with the buddy_id from the hatch operation's result, not the user_id.
property scopes should not existSent scopes to POST /embed-tokensThat's the read-only endpoint. Use POST /widget-sessions for scoped/interactive tokens.
409 no_published_config (NoPublishedConfigError)No published config yetPublish a config version in the dashboard — err.details.publish_url links there. Check your key's environment matches where you published.
409 active_egg_limit (ActiveEggLimitError)Bootstrap ran multiple times (Strict Mode, focus, retries) and filled the per-user egg caperr.details.active lists the existing eggs — hatch or cancel one, or retry the create with ?ensure=true to reuse it. Better: guard egg creation and reuse via stored buddy_idGET /buddies?user_id=… before ever calling POST /eggs.
/widget/streak/<key> or /widget/path/<key> returns 404 although the dashboard shows the definitionThe definition is on a draft config, not the published snapshotPublish again so the snapshot includes it; migrate or recreate the buddy if it was pinned before the publish.
Hatch takes 20–45s and the UI hangsTreating hatch as synchronousIt's an operation — poll GET /operations/:id every ~2s, show a loading state, mount the widget once buddy_id is available.