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