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.jsYou 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```httpblocks below aresnake_case; the```tsblocks 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_quizzerstreak but/widget/streak/daily_quizzerreturns404, 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.
0.5 Want a test player without the chain? Use Player Zero
If you just need something to send events at while wiring up your backend,
skip the egg chain entirely: every workspace has a reserved demo player —
Player Zero, user_id "player-0" — provisioned in one idempotent call.
It's the same buddy every dashboard widget preview binds to, so events you
send as player-0 show up there immediately and never pollute real user data.
const { buddy } = await hatched.players.zero(); // create-or-get, instant
await hatched.events.ingest({
userId: buddy.userId, // "player-0"
type: 'lesson.completed',
eventId: 'evt_demo_1',
});POST /api/v1/players/zero
Authorization: Bearer hatch_test_…
# → 201 { "created": true, "buddy": { "id": "…", "user_id": "player-0", … } }
# (second call → "created": false, same buddy)New Player Zero buddies return immediately on a safe placeholder image while
Hatched queues their first brand-styled base render in the background. If
the first response still carries the placeholder image_url, widgets can
render it safely and will pick up the brand-styled image on their normal
refresh path after generation completes.
GET /api/v1/players/zero reads its status (exists / hatched) without
creating it. The rest of this page is the chain for real users.
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].id;
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": [ { "id": "…", "user_id": "user_42", … } ], "meta": { … } }
# If data is non-empty, store data[0].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 5–45s):
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 demos —
localStoragekeyed 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
| Symptom | Cause | Fix |
|---|---|---|
property userId should not exist | Sent camelCase to the raw HTTP API | The raw API is snake_case (user_id). Use snake_case, or use @hatched/sdk-js (it converts for you). |
buddy_id must be a UUID | Passed user_id (or nothing) where buddy_id was expected | Mint the session with the buddy_id from the hatch operation's result, not the user_id. |
property scopes should not exist | Sent scopes to POST /embed-tokens | That's the read-only endpoint. Use POST /widget-sessions for scoped/interactive tokens. |
409 no_published_config (NoPublishedConfigError) | No published config yet | Publish 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 cap | err.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_id → GET /buddies?user_id=… before ever calling POST /eggs. |
/widget/streak/<key> or /widget/path/<key> returns 404 although the dashboard shows the definition | The definition is on a draft config, not the published snapshot | Publish 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 hangs | Treating hatch as synchronous | It's an operation — poll GET /operations/:id every ~2s, show a loading state, mount the widget once buddy_id is available. |
Related
- Getting started — the happy path once the buddy exists.
- Auth model — session token vs embed token vs publishable key.
- Best practices — idempotency, multi-tenant ids, and more.
- Troubleshooting — diagnosing the errors above.