# Hatched documentation — full text
> Hatched is the gamification layer for B2B products: a typed SDK (@hatched/sdk-js), embeddable widgets, and an HTTP API. Send product events, grow buddies, award coins / tokens / badges / streaks, run marketplaces, and embed it all with a few lines of HTML.
Generated from https://docs.hatched.live. Page index: https://docs.hatched.live/llms.txt
# Hatched documentation
> Drop a buddy into your product in under 10 minutes. Server-only SDK, scoped browser tokens, auto-retries, idempotency built in.
Source: https://docs.hatched.live/docs
Hatched is a gamification layer for B2B products. Events go in, buddies
grow. These docs are organised by customer task — pick the one that matches
your mode.
## Thirty-second taste
```ts
import { HatchedClient } from '@hatched/sdk-js';
const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
});
// 1. create a buddy
const egg = await hatched.eggs.create({ userId: 'user_42' });
await hatched.eggs.updateStatus(egg.eggId, 'ready');
const op = await hatched.eggs.hatch(egg.eggId);
const finished = await hatched.operations.wait(op.operationId);
// 2. teach the buddy about your product
await hatched.events.send({
eventId: 'lesson_1_user_42',
userId: 'user_42',
type: 'lesson_completed',
properties: { score: 94 },
});
// 3. mint a browser token and embed the widget
const session = await hatched.widgetSessions.create({
buddyId: finished.result.buddyId,
userId: 'user_42',
scopes: ['read', 'events:track'],
ttlSeconds: 900,
});
```
Full walkthrough: [Getting started](/docs/guides/getting-started).
## Pick your track
- **[Concepts](/docs/concepts/overview)** — buddies, skills, coins, streaks, badges, paths, marketplace, evolution, rule engine, auth model.
- **[Guides](/docs/guides/getting-started)** — getting started, SDK quickstart, widgets, events, webhooks, Next.js / Express / edge runtimes, troubleshooting.
- **[Reference](/docs/reference/http-api)** — HTTP endpoints, full SDK method surface, widget props, webhook payloads, error codes, rate limits, changelog.
- **[Billing](/docs/billing/pricing)** — plans, credits, Stripe billing portal, 402 handling, and credit top-ups.
## Building with an AI assistant?
These docs are machine-readable. Drop our [`AGENTS.md`](https://docs.hatched.live/AGENTS.md)
into your repo, point your assistant at [`/llms-full.txt`](/llms-full.txt), and
grab any page as Markdown with the buttons under its title — full walkthrough in
[Use Hatched with AI coding assistants](/docs/ai-assistants).
## Popular pages
- [Getting started (10 min)](/docs/guides/getting-started)
- [Widget integration](/docs/guides/widget-integration)
- [Handle webhooks](/docs/guides/handle-webhooks)
- [Auth model — secret vs publishable keys](/docs/concepts/auth-model)
- [Use Hatched with AI coding assistants](/docs/ai-assistants)
- [Error codes](/docs/reference/error-codes)
- [Troubleshooting](/docs/guides/troubleshooting)
## What's new
Always see the latest SDK release notes at
[Changelog](/docs/reference/changelog) — mirrored from the package itself,
produced on every merge.
---
# Use Hatched with AI coding assistants
> These docs are machine-readable. Point Cursor, Claude Code, Copilot, or any LLM at the right files and drop a ready-made instruction file into your repo.
Source: https://docs.hatched.live/docs/ai-assistants
If you build with an AI coding assistant — Cursor, Claude Code, GitHub Copilot,
Windsurf, Codex, Devin — these docs are designed to feed it cleanly. This page
covers the three things worth wiring up.
## 1. Drop an instruction file into your repo
The single highest-leverage move: give your assistant a short, opinionated
brief on how to use Hatched. We maintain one for you:
```bash
curl -fsSL https://docs.hatched.live/AGENTS.md -o AGENTS.md
```
[View it here](https://docs.hatched.live/AGENTS.md). It works as a standalone
`AGENTS.md` (read by Codex, Cursor, Copilot's coding agent, Devin, Jules, and
more), or paste its contents into `CLAUDE.md`, `.cursor/rules`, or your Copilot
instructions file. It covers:
- installing `@hatched/sdk-js` and initialising the client
- **the cardinal rule** — secret keys (`hatch_live_*`, `hatch_test_*`) are
server-only; the SDK throws in the browser. Use a widget session token or a
`hatch_pk_*` publishable key client-side.
- the **first-user bootstrap** — publish a config first, reuse-or-create a buddy
(never re-create an egg on every load), persist `buddy_id`, mint a widget
session token; the `snake_case` raw API vs camelCase SDK
- the core flows: `events.send` with a stable `eventId`, `buddies.earn` /
`spend` / `equip`, gates, webhooks
- verifying webhook signatures against the **raw body** before parsing
- catching typed `HatchedError` subclasses by `.code`
- embedding widgets with the loader script (`data-session-token`) and
`data-hatched-mount` elements
## 2. Point it at the machine-readable docs
| File | What it is | When to use it |
| --- | --- | --- |
| [`/llms.txt`](https://docs.hatched.live/llms.txt) | A short index of every page — title, one-line summary, URL. Follows the [llms.txt](https://llmstxt.org) convention. | Let the assistant pick which page it needs. |
| [`/llms-full.txt`](https://docs.hatched.live/llms-full.txt) | Every page concatenated into one plain-text document. | Give the assistant the whole docs in a single fetch. |
| `/llm/` | Any single page as raw Markdown — e.g. [`/llm/guides/getting-started`](https://docs.hatched.live/llm/guides/getting-started). | Hand it exactly one page. |
Every docs page also has **Copy as Markdown** and **Open in ChatGPT / Claude**
buttons under the title, so you can ship a page to an assistant in one click.
## 3. Know the rules it must not break
If you take nothing else from this page, make sure your assistant respects
these — they are the mistakes we see most often:
- **Secret keys never leave the server.** Not in a bundle, not in a
`NEXT_PUBLIC_*` var, not in a mobile app. The SDK enforces this by throwing in
DOM environments. Browser code gets a [widget session
token](/docs/concepts/auth-model) (`hatched.widgetSessions.create(...)`) or a
publishable key.
- **Bootstrap a buddy before minting a widget token.** You can't go from
`userId` straight to a session token — there's a publish → reuse-or-create egg
→ hatch → `buddy_id` chain, and the assistant must reuse an existing buddy
rather than create an egg on every render. See [First user bootstrap](/docs/guides/first-user-bootstrap).
- **`events.send` is idempotent on `eventId`.** Always pass a stable, meaningful
id (`lesson_42:user_7`). Omitting it — or generating a random one on retry —
double-counts. See [Sending events](/docs/guides/send-events).
- **Verify webhook signatures before parsing.** HMAC over the raw bytes, then
`JSON.parse`. See [Handling webhooks](/docs/guides/handle-webhooks).
- **Wait on operations, don't poll.** Image-producing calls (hatch, evolve,
equip) return an `operationId`; use `hatched.operations.wait(operationId)`.
- **Use the SDK, not hand-rolled HTTP.** The [SDK reference](/docs/reference/sdk-js)
is generated from the package source, so it never drifts.
---
# Overview
> The fourteen primitives that make up every Hatched gamification programme, and how they fit together.
Source: https://docs.hatched.live/docs/concepts/overview
Hatched gives a product a **buddy** — a companion that grows as its user does.
Everything else on Hatched exists to feed the buddy: events go in, effects
come out, a widget shows the result.
## The whole picture in one paragraph
A user does something in your product (`lesson_completed`, `checkout_succeeded`).
You send it as an **event**. The **rule engine** turns it into **effects** —
skills level up, coins are earned, a badge might unlock, a streak ticks, the
buddy moves closer to its next **evolution** stage. Users spend coins in the
**marketplace**. Your backend gets **webhooks** when anything interesting
happens.
## The primitives
- **[Buddy & hatch](/docs/concepts/buddy-and-hatch)** — the avatar, born from
an egg, growing through evolution stages.
- **[Skills](/docs/concepts/skills)** — numeric dimensions like Grammar or
Stamina that level up over time.
- **[Coins](/docs/concepts/coins)** — the primary currency, earned from
events, spent in the marketplace.
- **[Tokens](/docs/concepts/tokens)** — secondary currencies (gems, hearts,
stars) for more specialised economies.
- **[Streaks](/docs/concepts/streaks)** — consistency counters that tick
daily and reward habit.
- **[Badges](/docs/concepts/badges)** — one-shot achievements that mark a
specific moment.
- **[Paths](/docs/concepts/paths)** — multi-step guided journeys
(onboarding, learning modules, certification tracks) with steps and
sub-steps that complete via events or manual marks.
- **[Marketplace](/docs/concepts/marketplace)** — where users spend coins on
items that show up in the widget.
- **[Leaderboard](/docs/concepts/leaderboard)** — competitive ranking with
scoped visibility.
- **[Evolution](/docs/concepts/evolution)** — the long-horizon arc; the
buddy changes appearance at milestones.
- **[Audiences](/docs/concepts/audiences)** — segmenting one customer into
multiple user groups with separate rules.
- **[Config versions](/docs/concepts/config-versions)** — immutable
snapshots of the whole rule set, pinned per buddy.
- **[Rule engine](/docs/concepts/rule-engine)** — the deterministic pipeline
that converts events into effects.
- **[Webhooks](/docs/concepts/webhooks)** — signed HTTP callbacks that let
your backend react to anything.
## Design principles
- **Template-first over free-form.** Rule types are a fixed enum; we chose
fewer knobs over infinite configurability.
- **Publish before live.** Config changes land on a draft, then get published
as a new immutable version. Existing buddies stay on their pinned version.
- **Canonical state in Hatched.** Your product can keep a copy for UX, but
Hatched owns the truth.
- **Async by default.** Image generation, webhook delivery and other slow
paths run off a queue and expose an operation you can poll.
## Where to next
- [Getting started](/docs/guides/getting-started) — create a buddy, send an event, embed a widget in ten minutes.
- [SDK quickstart](/docs/guides/sdk-quickstart) — the `@hatched/sdk-js` surface.
- [Configure rules](/docs/guides/configure-rules) — tune the economy in the dashboard.
- [Best practices](/docs/guides/best-practices) — patterns for an integration that scales.
---
# Buddy & hatch
> The avatar at the heart of Hatched — how it's created, what hatch means, and how it grows.
Source: https://docs.hatched.live/docs/concepts/buddy-and-hatch
A **buddy** is the persistent companion a user gets when they join your
Hatched programme. Everything else — skills, coins, streaks, badges — hangs
off the buddy.
## Egg and hatch
Before a buddy exists, there is an **egg**. Creating an egg is the first
write you make to the Hatched API:
```ts
const egg = await hatched.eggs.create({
userId: 'user_42',
});
```
The egg is pinned to the current [config version](/docs/concepts/config-versions)
on the customer (set during onboarding via `apply-preset`, or via the
Dashboard) and carries any audience tags you passed. It doesn't render a
visible buddy yet — it's a placeholder with pending image generation.
Move the egg from `waiting` to `ready` once your own onboarding gate is
complete; only ready eggs can hatch.
**Hatch** is the ceremony that turns an egg into a buddy:
```ts
await hatched.eggs.updateStatus(egg.eggId, 'ready');
const op = await hatched.eggs.hatch(egg.eggId);
const buddy = await hatched.operations.wait(op.operationId);
```
Hatching is asynchronous because image generation takes 5–20 seconds. You
get an operation id back; poll it with `operations.wait` or listen for the
`buddy.hatched` [webhook](/docs/concepts/webhooks).
## One user, possibly several buddies
By default there is one buddy per (customer, external_user_id) pair. When
[audiences](/docs/concepts/audiences) are in play, a single user can have
one buddy per audience — useful when the same person plays two roles
(student and teacher, for instance).
## What a buddy carries
A buddy is not just a picture. It holds:
- a **config_version_id** — which rulebook it lives under
- a **skills** map — each skill with value and level
- **coins** and any **token** balances
- a list of awarded **badges**
- a set of **streak** counters
- an **evolution stage**, current `image_url`, and bare `base_image_url`
- equipped **marketplace items**
- a **progression** summary for XP, badges, items, and streak counters
- an **appearance** block for pending or failed item compositing
- an audience tag (if configured)
The widget reads this shape directly; your backend can mirror it via
webhooks if you need your own source of truth for UX.
## Appearance state
`buddy.appearance` separates the desired outfit from the image that is
currently safe to display:
- `ready` — `image_url` includes the rendered equipped items.
- `pending` — a new composite is being generated.
- `awaiting_credits` — the composite will retry after image credits are
available.
- `failed` — the render needs operator action or a retry.
Use `desired_equipped_item_ids` to know what the user wants equipped and
`rendered_equipped_item_ids` to know what is visible in `image_url`. If a
failed appearance has `error.code === 'needs_rerender'`, regenerate the bare
stage with `buddies.rerenderAppearance(buddyId)`, wait for `ready`, then
re-equip the items.
## Lifecycle
Buddies are long-lived. They don't expire on their own — they evolve. When
you update the rule set, existing buddies stay pinned to their old config
version; you migrate them explicitly when you're ready.
## Related
- [Evolution](/docs/concepts/evolution) — how the buddy changes over time.
- [Compositing & stages](/docs/concepts/compositing-and-stages) — the full `appearance` state machine.
- [Config versions](/docs/concepts/config-versions) — the rulebook a buddy is pinned to.
- [Getting started](/docs/guides/getting-started) — create and hatch your first buddy.
---
# Skills
> The dimensions along which a buddy grows — Pronunciation, Grammar, Stamina, whatever matters for your product.
Source: https://docs.hatched.live/docs/concepts/skills
Skills describe **who the buddy is**. Each skill has a numeric value, a level,
an icon, and a max. They let you visualise which dimensions a learner is
progressing on.
## Why skills exist
Skills are the spine of gamification. They feed
[evolution](/docs/concepts/evolution), act as conditions for
[badges](/docs/concepts/badges), and create the "look what I learned"
moment in the widget.
## How they behave
A skill has:
- **name** (e.g. "Pronunciation", "Grammar")
- **value** — numeric, typically 0–100 or 0–1000
- **level** — derived from value, one level per N points
- **icon** and **color** — visual identity in the widget
## Example
> "Pronunciation" ranges 0–100, one level every 20 points. When a
> `lesson_completed` event fires with `difficulty: "speaking"`, Pronunciation
> gains +5. When the widget renders, it shows the current level and
> progress toward the next.
## How to set them up
1. Create a skill set (e.g. "Language mastery").
2. For each skill pick a name, icon, colour, and max level.
3. Add a [skill rule](/docs/concepts/rule-engine) — which event increments
which skill, and by how much.
## Gotchas
- Renaming a skill updates the widget in real time, but historical events
keep the old label in the event log.
- More than 8 skills crowds the widget. Don't split dimensions unless the
signal is real.
## Pairing skills with decay
By default skills only go up. If you want users who hit the cap to keep
returning, pair the skill with a [decay rule](/docs/concepts/skill-decay)
that subtracts a small amount on a schedule. Decay is opt-in per
customer and configured next to skill rules in the dashboard.
## Related
- [Skill decay](/docs/concepts/skill-decay) — make skills go down on a schedule.
- [Rule engine](/docs/concepts/rule-engine) — how skill rules turn events into increments.
- [Evolution](/docs/concepts/evolution) — skills are a common evolution trigger.
- [Configure rules](/docs/guides/configure-rules) — defining skill sets and rules.
---
# Skill decay
> Subtract skill points on a schedule so users who go inactive feel the loss — a loss-aversion engagement loop for power users who already capped out.
Source: https://docs.hatched.live/docs/concepts/skill-decay
Skill decay is a **time-based** counterpart to [skill rules](/docs/concepts/skills).
Skill rules add points when an event happens; decay rules subtract points
when time passes. The product goal is the same engagement loop —
*"come back or you'll lose progress"* — adapted for users who have
already hit a cap and no longer find the next +5 motivating.
## When to turn it on
Decay earns its keep when:
- Power users have **plateaued** (large cohort sitting near the skill max).
- Your activation curve has a clear **return-or-lose** moment (language
apps, fitness streaks, certification refreshers).
- You can be confident the daily/weekly ritual is **achievable** —
punishing a user who never had a fair chance to log in burns trust.
It's a bad fit for skills users only touch once (e.g. an onboarding
score) or for cohorts where re-engagement is impossible (one-shot
certifications, finite curricula). Configure rules conservatively and
**watch your churn metrics for two weeks** after enabling.
## How it works
A decay rule says: *"every `cadence`, subtract `amount` from
`skill_key`, but never below `floor`, never if the buddy is younger
than `grace_days` days, and (optionally) only if the current level is
above `apply_only_above`."*
| Field | What it does |
| --- | --- |
| `skill_key` | Which skill loses points |
| `cadence` | `daily`, `weekly`, or `monthly` |
| `amount` | Points subtracted each cadence period |
| `floor_level` | Lower bound — decay never goes below this |
| `grace_days` | New buddies are exempt for N days after creation |
| `apply_only_above` | Optional. Only decay above this level |
| `audience` | Scope to a single audience (default: `default`) |
| `active` | Toggle without losing the configured rule |
## Lifecycle
1. **Author** the rule in **Skills → Skill Decay** (active = off).
2. **Preview** the projected curve next to each cadence option — the
dashboard shows where a fresh-cap user lands in 30 days.
3. **Activate** the rule. Even active rules are gated behind the
per-customer **Skill decay master switch** (`settings.features.decay`).
4. **Sweep** runs daily at **03:00 UTC**. Each (rule, buddy, period)
tuple is recorded in `skill_decay_applications` so a re-run inside
the same calendar period is a no-op.
5. **Webhook** `skill.decayed` fires for every buddy that lost points.
`skill.updated` also fires (same shape as a rule-engine update) so
downstream systems don't have to special-case the source.
You can also click **Run sweep now** from the dashboard for QA — it
enqueues a one-off sweep scoped to the current customer.
## Idempotency
Decay is keyed by a calendar **period**:
| Cadence | Period key example |
| --- | --- |
| `daily` | `2026-05-06` |
| `weekly` | `2026-W19` (ISO 8601) |
| `monthly` | `2026-05` |
The unique `(rule_id, buddy_id, period_key)` row in
`skill_decay_applications` makes the sweep safe to re-run inside the
same period. A buddy that already lost their daily 2 points won't lose
another 2 if you click *Run sweep now*.
## What it does **not** do
- It does not retroactively apply for periods missed during downtime.
If the worker was offline for three days, three days are skipped —
decay catches up at the next sweep, once.
- It does not touch coins, tokens, or badges. It is strictly skill-level.
- It does not take per-customer time zones into account. The sweep
uses UTC calendar boundaries (a small fidelity gap that buys a much
simpler operational footprint — DST and per-tenant cron windows are
not a concern at this scale).
## Related
- [Skills](/docs/concepts/skills) — the underlying scalar
- [Rule engine](/docs/concepts/rule-engine) — event-driven counterpart
- [Webhook payloads](/docs/reference/webhook-payloads#skilldecayed) —
the `skill.decayed` event shape
---
# Coins
> The primary currency — earned from events, spent in the marketplace, tuned with coin rules.
Source: https://docs.hatched.live/docs/concepts/coins
Coins are the buddy's currency. They're earned automatically from events you
send, according to the **coin rules** you configure. Users spend them in the
[marketplace](/docs/concepts/marketplace).
## What a coin rule is
Each coin rule binds an event type (`lesson_completed`, `daily_login`) to an
amount. Optional caps and multipliers shape the economy:
- **Daily cap** — maximum coins of this type earned per UTC day.
- **Weekly cap** — same, weekly.
- **Multiplier** — for streak holders or premium users.
- **Total limit** — one-shot; useful for "first-10-lessons" bonuses.
## Example
```
lesson_completed → +10 coins (daily cap: 50)
streak_7 → +100 coins (one-time)
checkout_complete → +500 coins (premium audience only)
```
## How to set it up
1. List the events you already fire in your product that deserve a reward.
2. Attach an amount and optional daily cap to each.
3. Add streaks or multipliers to reward long-term play.
4. Watch the Economy Health page on the dashboard.
## Gotchas
- Without daily caps, coin supply inflates fast. A user grinding 200 lessons
on day one should not break your marketplace.
- Balance coin earnings against marketplace prices. If nothing costs more
than a week's earnings, coins become invisible.
- Coins always emit `coin.earned` webhooks — subscribe if you need to mirror
the ledger into your own system.
## Related
- [Marketplace](/docs/concepts/marketplace) — where coins get spent.
- [Tokens](/docs/concepts/tokens) — secondary currencies alongside coins.
- [Configure rules](/docs/guides/configure-rules) — tuning coin rules and caps.
- [Best practices](/docs/guides/best-practices) — designing an economy that doesn't reward grinding.
---
# Tokens
> Two-tier token model — one primary spendable, one progression accumulator. Customers pick the names.
Source: https://docs.hatched.live/docs/concepts/tokens
Tokens are the currencies attached to a buddy. Hatched ships a **two-tier
model**: each customer configures exactly two token slots.
- **Primary** — the spendable currency. Marketplace purchases, gate unlocks,
and `buddies.spend` draw from this slot.
- **Progression** — earn-only. Never spent; it accumulates and gates things
like evolution readiness.
The token names are yours. Fantasy buddies use `gems` + `mana`. A fitness
app might use `reps` + `streaks`. Pick whatever fits the product.
## Why two tiers
A single wallet collapses two different motivations into one number:
"what can I buy" and "how far have I come". Splitting them makes both
feelings legible — you can spend your gems without feeling like your
overall progress regressed.
Progression is deliberately **unspendable**. Attempting
`buddies.spend(buddyId, { token: '' })` throws
`progression_not_spendable`. That keeps long-term progress monotonic while
leaving the primary slot free for economy design.
## Example
```
gems (primary) — earned from lesson_completed, spent on items + gates.
Capped at 500 per week.
mana (progression) — earned from quiz_passed, feeds "stage 3 at mana ≥ 1000"
evolution readiness. Cannot be spent.
```
## How to set it up
1. In the dashboard **Tokens** page, pick a `token_key` and `label` for
each slot. Use `snake_case` for keys (`gems`, `xp_coins`, `mana`).
2. Optionally set an `icon` and a `max_balance` (primary only).
3. Attach earn rules per slot. Same shape as the rule engine elsewhere —
event type, amount, optional cap.
4. Reference `primary.token_key` in marketplace item prices and gate costs.
If you skip this step, the [onboarding plan](/docs/concepts/overview) seeds
both slots from a theme catalog — a fantasy buddy defaults to
`gems` + `mana`, fitness to `reps` + `streaks`, and so on. See
[Token economy](/docs/concepts/token-economy) for the fallback rules.
## At runtime
```ts
const summary = await hatched.buddies.tokens(buddyId);
// {
// primary: { key: 'gems', label: 'Gems', balance: 120, lifetimeEarned: 340, lifetimeSpent: 220 },
// progression: { key: 'mana', label: 'Mana', balance: 480, lifetimeEarned: 480, lifetimeSpent: 0 },
// }
```
Spend against the primary by default — no `token` arg needed:
```ts
await hatched.buddies.spend(buddyId, { amount: 50, reason: 'gate:advanced_mode' });
```
Pass `token` only when you have a multi-primary setup (not supported today —
reserved for a future expansion).
## Surfacing it to end users
Drop the [tokens widget](/docs/reference/widgets/tokens) — `` — to show the wallet in your product:
the primary balance up top, then the progression token with a progress bar
toward its `max_balance`. It needs the `tokens` capability and a `read`
scope; the primary number stays live off the shared `/widget/state` poll.
## Gotchas
- **Progression is monotonic.** Rule engine writes go through but spend
attempts are rejected. If you want a "spend XP" mechanic, that currency
belongs in the primary slot, not progression.
- **Token keys are immutable.** Once rules and ledger rows reference a key,
renaming it in the dashboard doesn't rewrite history. Pick the key you
can live with.
- **Tokens emit ledger webhooks.** `token.earned` and `token.spent` fire per
slot; subscribe if you sync balances downstream.
- **The legacy 4-tuple is gone.** Pre-0.3 customers used
`hatch_token`/`evolution_token`/`reroll_token`/`gift_token`. Migration
024 introduced the `kind` column; existing configs default to `primary`.
## Related
- [Token economy](/docs/concepts/token-economy) — how the two tiers, gates, and pricing fit together.
- [Coins](/docs/concepts/coins) — the default primary currency.
- [Unlock gates](/docs/guides/unlock-gates) — spending the primary token to unlock features.
- [Tokens widget](/docs/reference/widgets/tokens) — showing the wallet.
---
# Token economy
> How the two-tier token model, gates, and marketplace pricing fit together.
Source: https://docs.hatched.live/docs/concepts/token-economy
This page is the mental model for the whole token surface — how the two
slots get seeded, where they're spent, and where they accumulate.
## The loop
```
┌────────┐ events.send() ┌────────────┐
│ Event ├────────────────────▶│ Rule │
└────────┘ │ engine │
└─────┬──────┘
earn rules │ readiness conditions
┌──────────────────┼──────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ primary │◀─── buddies.spend │ progression │
│ (spendable) │ marketplace │ (earn-only) │
│ │ gates.unlock │ evolution gate │
└──────────────┘ └──────────────────┘
```
Events feed both slots via the rule engine. The primary slot is where
users *consume*; the progression slot is where the buddy *grows*.
## How slots are seeded
When you apply an onboarding plan, Hatched looks at the `token_config`
bundle:
- **If the plan already has two slots** (one `primary`, one
`progression`) — it's used as-is.
- **If the plan is empty or partial** — Hatched picks a theme from the
plan description / target sector / creature style, then seeds both
slots from a catalog.
The catalog matrix:
| Theme | Primary (spendable) | Progression (earn-only) |
|-------------|---------------------|-------------------------|
| `fantasy` | `gems` | `mana` |
| `fitness` | `reps` | `streaks` |
| `corporate` | `points` | `xp` |
| `education` | `stars` | `xp` |
| `tech` | `bytes` | `commits` |
| `default` | `coins` | `xp` |
The resolved source lands in `customers.settings.applied_sources`:
```json
{
"tokens": "plan" | "fallback",
"marketplace": "plan" | "fallback" | "hybrid",
"theme": "fantasy",
"applied_at": "2026-04-22T10:30:00Z"
}
```
Check that field if you're debugging why a customer ended up with
`gems`/`mana` instead of the names in their plan.
## Where each slot gets used
**Primary** is drawn from by:
- `hatched.buddies.spend(buddyId, { amount, reason })` — direct spend.
- `hatched.gates.unlock(buddyId, gateKey)` — gate cost is always primary.
- [Marketplace](/docs/concepts/marketplace) item prices.
**Progression** is read by:
- Evolution readiness conditions (`token. >= N`).
- Any custom rule engine condition that references the balance.
- Dashboards, widgets, leaderboards.
Attempting `buddies.spend({ token: '' })` returns
`progression_not_spendable`.
## Why not one wallet
A single wallet forces an ugly trade-off: spending a coin on a hat would
also lower the "how far I've come" number. Two slots lets the product
feel consumerist (spend, trade) *and* progressive (never regress) at the
same time.
If you genuinely want a single-wallet feel, set the primary token as
your only visible balance and treat progression as a backend-only metric
that drives evolution. Nothing in the SDK forces both to surface in the
UI.
## Migrating from the legacy 4-tuple
Customers created before 0.3 had a hardcoded `hatch_token` /
`evolution_token` / `reroll_token` / `gift_token` contract. Migration 024
added the `kind` column and defaulted every existing row to `primary`.
On the next onboarding apply, the fallback seeds a progression slot if
none exists — no manual step needed.
## Related
- [Tokens](/docs/concepts/tokens) — slot contract.
- [Marketplace](/docs/concepts/marketplace) — primary-priced catalog.
- [Unlock gates](/docs/guides/unlock-gates) — primary-spent feature flags.
- [Evolution](/docs/concepts/evolution) — progression-gated stages.
---
# Streaks
> Consistent-daily-activity counters that reward habit, not volume.
Source: https://docs.hatched.live/docs/concepts/streaks
A streak is a counter that ticks once per UTC day when a given event fires.
If the user skips a day, the counter burns. At configured milestones, a
streak awards bonus coins, tokens, or badges.
## Why streaks exist
Coins reward each action; streaks reward **showing up**. A well-placed
streak is often the single biggest retention lever in a gamification
economy.
## How they work
- Bound to one event type (the "today's activity" signal).
- One tick per UTC day maximum.
- Hatched fires `streak.milestone` webhooks at `[7, 14, 30, 60, 90, 180, 365]`
by default (configurable).
- Missing a day resets to zero unless a grace token is available.
## Example
> **daily_lesson streak** — +5 coins every tick day, +50 bonus at 7 days,
> +200 + 1 premium token at 30 days. Awards a "Week Warrior" badge at 7
> days that shows off in the widget.
## How to set it up
1. Pick the event type that counts as "today's activity".
2. Set a per-day bonus (optional).
3. Add milestone bonuses for the thresholds that matter to you.
4. Copy the streak `key` from Dashboard → Streaks.
5. Pick a display mode — `count`, `row`, or `mini` (a bare inline `🔥 N` chip
for navbars and menus).
6. Mount the [streak widget](/docs/reference/widgets/streak) with
`data-streak-key="…"` (and optionally `data-display-mode="mini"`).
The streak `key` is the stable workspace-level identifier for one streak
definition, such as `daily_lesson`. It is not generated by the widget and it
does not come from the user's buddy state. Treat it like app configuration:
hardcode it in a shared constant, store it in an environment variable such as
`NEXT_PUBLIC_HATCHED_STREAK_KEY`, or pass different keys to different streak
mounts when you intentionally show multiple counters.
## Gotchas
- Streaks tick in UTC. Multi-timezone products may see users "burn" during
their night. If this matters, model your own tick event instead of using a
daily_login heuristic.
- No grace days are built in — if you need them, grant a freeze token the
user can burn.
## Related
- [Badges](/docs/concepts/badges) — milestone streaks usually award one.
- [Leaderboard](/docs/concepts/leaderboard) — rank by streaks completed.
- [Streak widget](/docs/reference/widgets/streak) — rendering a streak with `data-streak-key`.
- [Configure rules](/docs/guides/configure-rules) — picking the event and milestones.
---
# Badges
> One-shot rewards that mark a specific achievement — the "you did it" moment.
Source: https://docs.hatched.live/docs/concepts/badges
Where [skills](/docs/concepts/skills) draw a curve and
[coins](/docs/concepts/coins) drip over time, a badge **freezes a moment**.
"7-day streak". "First 10 lessons". "Helped 5 classmates".
## What badges can key off
- **Milestone events** — "complete your 100th lesson"
- **Streaks** — "7-day streak"
- **Skill levels** — "reach Pronunciation level 5"
- **Collections** — "earn all sports-themed items"
- **Evolution stages** — "reach stage 3"
- **Coin thresholds** — "earn 1,000 coins"
- **Custom** — any combination via the rule engine
## Auto vs manual award
Badges can be **auto-awarded** (the rule engine evaluates the condition and
awards immediately) or **manual** (the dashboard operator or an external
workflow awards them after review).
Manual badges emit `badge.ready` webhooks — subscribe a moderation queue if
you want humans in the loop.
## Example
> **Week Warrior** — auto-awarded on 7-day streak, plays a shine animation
> in the widget, fires `badge.awarded`. Also grants +50 coins as a side
> effect.
## How to set it up
1. Name, icon, and description.
2. Pick a condition type (milestone, streak, skill, coin, custom).
3. Decide auto vs. manual award.
4. Optionally attach a coin or token reward.
## Gotchas
- Custom conditions read the event payload — renaming properties silently
breaks the rule. Version your event schemas.
- Manual badges fire `badge.ready`, not `badge.awarded`. Make sure your
workflow calls `POST /badges/:id/award` when the human says yes.
## Related
- [Streaks](/docs/concepts/streaks) — the most common badge trigger.
- [Skills](/docs/concepts/skills) and [Coins](/docs/concepts/coins) — other things badges can key off.
- [Configure rules](/docs/guides/configure-rules) — defining badge conditions.
---
# Paths
> Multi-step guided journeys — onboarding, learning modules, activation checklists, certification tracks.
Source: https://docs.hatched.live/docs/concepts/paths
A path is an ordered journey made of **steps** and **sub-steps**. Each step
unlocks once the previous one is complete; sub-steps inside a step finish
either automatically (rule-engine event) or manually (widget CTA / API).
Operators define one path per audience as the active default; users see it
through the [path widget](/docs/reference/widgets/path).
## Why paths exist
Coins, streaks, and badges reward isolated moments. Paths reward
**progress through a defined arc** — first-value onboarding, a five-lesson
unit, a seller activation checklist, a 30-day training plan, a certification
track. Instead of asking users to infer "what's next," the widget shows the
next concrete action and rewards completion in context.
## How they work
A path has three layers:
- **Definition** — label, audience, display mode, accent color, icon.
- **Steps** — ordered milestones. Each step has a label, optional
description, optional `unlock_condition` override, optional
`completion_condition`, and reward (coins + badge).
- **Sub-steps** — the actual work inside a step. Each sub-step finishes
via a `completion_condition` (auto) or `allow_manual_complete=true`
(manual CTA / public API). Optional `content_url` deep-links to the
customer's app.
Default unlock semantics: step `n+1` unlocks when step `n` is fully
completed. Override per step with `unlock_condition` (e.g. "unlocks when
the user earns the `novice` badge") if you need non-linear gating.
Default sub-step semantics: `allow_manual_complete=true` with no
`completion_condition`. The widget shows a "Mark complete" CTA (if the
session token has `events:track` scope), and a `content_url` opens in a
new tab. Add an automation later if you want the rule engine to mark the
sub-step done off an event.
Step-level completion is optional. Add a step `completion_condition`
when one event should finish the whole milestone, such as
`event_count(module_finished) >= 1`. When it matches, Hatched marks every
unfinished sub-step in that step as complete in the same event tick,
applies each sub-step reward once, then applies the step reward. Leave
the step condition blank when each sub-step should complete from its own
event or manual CTA.
## Display modes
- **Straight column** (`display_mode: 'straight'`) — a readable vertical
path with a continuous accent connector. Best for long ordered
journeys where clarity matters.
- **Zigzag quest** (`display_mode: 'zigzag'`) — alternating nodes and
labels, Duolingo-style. Best when the path should feel more playful.
- **Compact stepper** (`display_mode: 'stepper'`) — horizontal scrolling
chips with the active step expanded inline. Best for short checklists,
onboarding flows, or any place vertical real estate is tight.
The runtime renders the same data either way — toggle freely; users see
the change on next mount.
## Path icons
The definition `icon` is optional decoration next to the path label.
Allowed values are `path`, `flame`, `heart`, `bolt`, `star`, and `leaf`.
Use `path` for no icon. Existing unsupported icon values are normalized
to `path` by the path icon migration.
## Single active path per audience
Activating a path atomically deactivates every other path in the same
audience. There is one live path per audience at any time. Drafts stay
inactive until you flip the toggle from the dashboard.
## Example
> **buyer_onboarding path (audience: `seller`)** — three steps:
> 1. *Set up your storefront* — sub-step "Add your first product" (manual,
> deep-links to `/products/new`).
> 2. *Stock your shop* — sub-step "Add five products" (auto, completes
> when `event_count(product_added) ≥ 5`).
> 3. *Go live* — step completion condition
> `event_count(storefront_submitted) ≥ 1`, then awards
> `verified_seller` badge.
## How to set it up
1. Dashboard → Paths → **New path**.
2. Pick the audience (multi-audience customers only).
3. Add steps in order; expand each one to add sub-steps.
4. Default sub-step is manual — leave it that way unless you want
automation. If one event should complete the whole step, add a step
completion rule instead.
5. Live preview shows fresh / in-progress / completed states.
6. **Set active** when you're ready. Activating swaps in this path and
deactivates whatever was previously live for this audience.
7. Embed the [path widget](/docs/reference/widgets/path) on the customer
surface — the runtime resolves the active path automatically.
## Gotchas
- Audience is **immutable** after a path is created. Pick carefully on
creation; if you need a different audience, clone the path.
- Manual completion needs a session token with `events:track` scope.
Embed-token mounts (read-only) won't show the "Mark complete" CTA.
- Step completion rules cascade unfinished sub-steps and their rewards.
Use them for true milestone events; use per-sub-step conditions when
each task should award independently.
- Add at least one sub-step when using a step completion rule. The rule
persists progress by marking unfinished sub-steps complete; an empty
step has no completion record to update.
- A sub-step must keep either a `completion_condition` *or*
`allow_manual_complete=true`. The API rejects sub-steps that drop both.
- Path definition icons are curated keys, not arbitrary emoji or image
URLs. Use one of `path`, `flame`, `heart`, `bolt`, `star`, or `leaf`.
- Per-buddy completion state is bound to the path definition. Deleting a
path removes its completion records — there is no recovery.
## Related
- [Badges](/docs/concepts/badges) — step rewards are usually a badge.
- [Streaks](/docs/concepts/streaks) — habit loops that run alongside a path.
- [Send events](/docs/guides/send-events) — events drive automatic sub-step completion.
- [Path widget](/docs/reference/widgets/path) — rendering the active path.
---
# Marketplace
> Where users spend coins to dress up the buddy — items, costumes, backgrounds, boosters.
Source: https://docs.hatched.live/docs/concepts/marketplace
The marketplace is the **consumption side** of the coin economy. Without it,
coins become an empty metric. It's also where users feel "this buddy is mine".
## Anatomy of an item
Each item carries:
- **name**, **description**, **image**
- **category** (`background`, `body`, `feet`, `hand`, `neck`, `face`, `head`, `accessory`)
- **price** — in coins, tokens, or mixed
- **rarity** (common, uncommon, rare, epic, legendary)
- **visibility rule** — "stage 2+", "holds badge X", "audience = premium"
- **equip slot** — the category also drives compositing order and conflicts
## Example
> **"Cowboy Hat"** — 50 coins, rare, unlocked for Stage 2+ buddies. Once
> equipped, the widget renders the buddy wearing it.
## Equip lifecycle
Equipping is an appearance update, not just a metadata flip. Hatched validates
ownership, enforces [category bounds](/docs/concepts/compositing-and-stages),
sets the desired `equipped_items`, and then renders a new image against the
buddy's `base_image_url`.
The response can be instant when a cached composite exists. Otherwise it returns
an operation id and the buddy reports `appearance.status: 'pending'` until the
render lands. If the image provider is out of credits the status becomes
`awaiting_credits` and Hatched retries after credits are available. If the
base image must be regenerated, the status is `failed` with
`error.code: 'needs_rerender'`; call `buddies.rerenderAppearance(...)`, wait
for `ready`, then re-equip.
## How to set it up
1. Create a marketplace (pricing mode: coin / free / mixed).
2. For each item, set an image, price, and unlock type.
3. Add visibility rules (Stage 2+, holds badge X, etc.).
4. Mount the [marketplace widget](/docs/reference/widgets/marketplace) on
the page where users shop.
## Gotchas
- Oversized images slow the marketplace widget — cap thumbnails at 512×512.
- Tune prices against the coin economy. An unsold item is usually invisible,
not expensive.
- A buddy can equip at most 4 items. Non-`accessory` categories are exclusive,
so two `head` items conflict.
- Equipped items persist across evolution stages. When a stage changes, Hatched
renders the same desired item set against the new `base_image_url` and exposes
any delayed composite through `buddy.appearance`.
## Related
- [Coins](/docs/concepts/coins) — what items are priced in.
- [Compositing & stages](/docs/concepts/compositing-and-stages) — equip slots, layer order, and the `appearance` state machine.
- [Customize buddy](/docs/guides/customize-buddy) — walking an equip flow.
- [Marketplace widget](/docs/reference/widgets/marketplace) — the shopping surface.
---
# Compositing & stages
> How equipped items layer onto the buddy, and how evolution preserves them atomically.
Source: https://docs.hatched.live/docs/concepts/compositing-and-stages
When a user equips a hat on a stage-1 egg and then evolves to stage 2,
the hat does not disappear. This page is how that invariant is
maintained end-to-end.
## The 8 canonical categories
Every marketplace item belongs to one of eight slots:
| Category | `layer_order` | Multi-equip? |
| ------------ | ------------- | ------------ |
| `background` | 10 | no |
| `body` | 20 | no |
| `feet` | 30 | no |
| `hand` | 40 | no |
| `neck` | 50 | no |
| `face` | 60 | no |
| `head` | 70 | no |
| `accessory` | 80 | yes |
`layer_order` is the compositing z-order (back → front). `background`
paints first, `accessory` paints last. `accessory` is the only slot that
accepts multiple equipped items — everything else rejects the second
item in the same category with `category_conflict`.
### Equip bounds
- **Max 4 equipped items.** The fifth rejects with `too_many_items`.
- **Non-accessory categories are exclusive.** Equipping a second `head`
while one is already equipped rejects with `category_conflict`.
- Items sort deterministically by `(layer_order, item_id)` before
reaching the image pipeline, so two equipped items always composite
in the same order.
These checks happen at the API boundary, surfaced in the SDK as
`TooManyItemsError` and `CategoryConflictError`.
## Stage-aware item assets
Items can ship a stage-specific override via `stage_image_urls`:
```json
{
"image_url": "https://cdn.hatched.live/items/wizard_hat/base.png",
"stage_image_urls": {
"3": "https://cdn.hatched.live/items/wizard_hat/stage3.png",
"5": "https://cdn.hatched.live/items/wizard_hat/stage5.png"
}
}
```
The compositing pipeline reads `stage_image_urls[currentStage]` and
falls back to `image_url` when there's no override. Designers only have
to ship overrides for the stages where the base asset would look wrong.
## The appearance state machine
Anything that changes the buddy's image — hatch, equip / unequip, evolve — runs
through the image pipeline asynchronously. The buddy carries an `appearance`
block so you always know whether what you're showing is the final render. This
is the single source of truth for "is the visual ready"; the buddy's economy
state (coins, skills, stage) is already committed regardless.
| `appearance.status` | Meaning | What to show / do |
| --- | --- | --- |
| `ready` | `image_url` is the final composite; `rendered_equipped_items` matches `equipped_items`. | Show `image_url`. Nothing to do. |
| `pending` | A job is generating or compositing. `appearance.operation_id` points at it. | Show `image_url` (the last good render) and an optional "updating…" hint. `operations.wait(operationId)` to know when it's done. |
| `awaiting_credits` | The job is blocked on insufficient image credits. | Show the last good `image_url`. Surface a top-up prompt; the job resumes once credits land. See [Credits](/docs/billing/credits). |
| `failed` | The job failed. Check `appearance.error.code`. | Show the last good `image_url`. If `error.code === 'needs_rerender'` (typically a migrated buddy with no usable bare-stage image), call `buddies.rerenderAppearance(buddyId)` — or the widget `POST /widget/appearance/rerender` with an `items:equip` session — wait for `ready`, then re-equip. For other error codes, retry the originating action. |
Two fields make recovery deterministic: `base_image_url` is the trustworthy
bare-stage image, and `equipped_items` is the desired item set. `image_url` and
`rendered_equipped_items` are "what's currently on screen". A rerender
regenerates the bare stage from scratch, after which you re-apply
`equipped_items`.
## Atomic evolve × equip
The invariant that unlocks the whole feature: **the stage transition is
committed atomically, while the item composite is tracked as appearance
state.**
What happens when a user with an equipped hat evolves:
1. Client calls `hatched.buddies.evolve(buddyId)` and receives an
`operation_id`.
2. The evolve worker re-checks readiness, then generates the next-stage
bare image and stores it as `base_image_url`.
3. If `equipped_items` is non-empty, the same job attempts to composite
the desired items over that bare image.
4. If compositing succeeds, `buddy.image_url` becomes the rendered image,
`rendered_equipped_items` matches `equipped_items`, and
`appearance.status` is `ready`.
5. If compositing is delayed or fails, the stage still advances. The buddy
keeps the new bare stage image, `appearance.status` becomes
`awaiting_credits` or `failed`, and `appearance.operation_id` points at
the job that owns recovery.
6. Operation transitions to `completed`, and `buddy.evolved` fires on
webhooks. Read `buddy.appearance` to decide whether the visual composite
is also done.
```ts
const op = await hatched.buddies.evolve(buddyId);
const result = await hatched.operations.wait(op.operationId);
// result.buddy.evolutionStage has advanced.
// result.buddy.appearance?.status tells you whether item compositing is ready.
```
The split matters for recovery. `base_image_url` is the trustworthy bare
stage. `image_url` is the currently displayable render. `equipped_items`
is the desired item set, while `rendered_equipped_items` is what actually
made it into the current image. If a migrated buddy reports
`appearance.status === 'failed'` with `error.code === 'needs_rerender'`,
call `buddies.rerenderAppearance(buddyId)` or the widget
`POST /widget/appearance/rerender` endpoint, wait until `ready`, then
re-equip the desired items.
## Demo path parity
The demo widget (publishable-key `widget_sessions.demo`) runs through
the same atomic pipeline via a mock image provider. Stage + equipped
items still composite; evolution history rows are still written with
`source: 'demo'`. That's why the marketing demo and production builds
show identical behavior for this flow.
## Related
- [Marketplace](/docs/concepts/marketplace) — where items live.
- [Evolution](/docs/concepts/evolution) — stage triggers.
- [Customize buddy](/docs/guides/customize-buddy) — walking through an
equip + evolve flow end to end.
---
# Leaderboard
> Rank-based competition, scoped by time window and audience.
Source: https://docs.hatched.live/docs/concepts/leaderboard
A leaderboard ranks buddies by a scoring function over a window. It's
optional — not every product benefits from ranking, and ranking can be
stressful. When it fits, though, it's a strong pull.
## What defines a leaderboard
- **Metric** — coins earned, skills-leveled, streaks completed, a custom sum.
- **Window** — all-time, monthly, weekly, custom cohort.
- **Scope** — global (across all buddies of the customer), per-audience, or
per-group (classroom, team, etc.).
- **Visibility** — public to all, visible only to the buddy's own rank ±3
entries, or admin-only.
## Example
> **Weekly coin leaderboard, scoped to the `students` audience.** Resets
> every Monday. Each student sees their own rank plus the five nearest
> neighbours; admins see the full list.
## How it computes
Leaderboards are snapshotted periodically (default every 10 minutes) and
cached. Reads from the widget or API hit the snapshot, not the live
aggregate — this keeps performance stable regardless of scale.
A refresh fires `leaderboard.snapshot.ready` if you want to react on your
backend.
## Gotchas
- Leaderboards can demotivate the bottom ranks. Consider scoping visibility
to "within ±N of me" rather than the whole list.
- Per-group leaderboards require you to pass a `groupId` on events; without
it, the event doesn't count toward group ranking.
- Weekly/monthly windows reset in UTC. Cross-timezone products may see a
sudden "reset" mid-day for some users.
## Related
- [Audiences](/docs/concepts/audiences) — scope a leaderboard to one user group.
- [Streaks](/docs/concepts/streaks) — a common leaderboard metric.
- [Leaderboard widget](/docs/reference/widgets/leaderboard) — rendering it in your product.
---
# Evolution
> Stages the buddy grows through over time — the Tamagotchi arc.
Source: https://docs.hatched.live/docs/concepts/evolution
Evolution stages (typically 3–6) trigger when conditions are met: total XP,
specific skill levels, badges earned, coin thresholds. Each stage gets a
new look.
## Why evolution exists
Evolution is the **long-horizon motivator**. Coins reward today. Streaks
reward this week. Evolution is the story that spans weeks or months:
"egg → baby → teen → master".
The image pipeline regenerates the buddy's bare art at each stage. If the
buddy has marketplace items equipped, Hatched then renders those items over
the new `base_image_url` and reports that composite through
`buddy.appearance`.
## Example
> **Stage 2 unlocks at total XP ≥ 500 + "Streak 7" badge.** When met,
> `evolution.ready` fires. If auto-evolve is off, your backend calls
> `buddies.evolve(buddyId)` and `operations.wait` returns the new buddy
> stage in 5–20s. Check `buddy.appearance.status` to confirm whether any
> equipped items have finished rendering on the new stage.
## Runtime loop
Event ingestion reports readiness, but the stage transition is a separate
operation unless the customer's config has `auto_evolve` enabled:
```ts
const effects = await hatched.events.send({
eventId,
userId,
type: 'lesson_completed',
});
if (effects.evolutionReady) {
const op = await hatched.buddies.evolve(buddyId);
await hatched.operations.wait(op.operationId);
}
```
With `auto_evolve: true`, Hatched starts that operation when readiness is
detected and still emits the `evolution.ready` webhook for observability.
## Appearance after evolve
Evolution commits the stage transition first. Item compositing is attempted in
the same operation, but a credit shortage or provider failure does not roll the
stage back. Instead, the buddy response exposes:
- `base_image_url` — the trustworthy bare image for the new stage.
- `image_url` — the current display image.
- `appearance.status` — `ready`, `pending`, `awaiting_credits`, or `failed`.
- `rendered_equipped_item_ids` — the item layers currently visible in
`image_url`.
Widgets poll `/widget/state` and show the latest safe visual while a composite
is pending. For `failed` appearances with `error.code === 'needs_rerender'`,
call `buddies.rerenderAppearance(buddyId)` before re-equipping items.
## Modes
- **Preset** — a fixed set of 5 sprite stages per preset.
- **Generative** — AI-generated art per buddy at each stage. Slower, more
unique.
- **Hybrid** — preset base with a generative overlay for personalised
details.
## How to set it up
1. Pick an evolution model (preset / generative / hybrid).
2. Set conditions per stage (XP, skill level, badge, coin).
3. Pick a creature style (cute, sci-fi, fantasy, minimal).
4. Decide auto vs. manual evolve.
## Gotchas
- `evolution.ready` fires even if auto-evolve is off. In that mode, call
`buddies.evolve(buddyId)` from your backend when you want to advance the
stage.
- Generative mode takes 5–20s per stage; treat it as an async operation and
use `operations.wait` or the widget's built-in loading state.
- Equipped marketplace items must work on all stages. Test early stage
equipment against late stage art, and monitor `buddy.appearance` for delayed
composites.
## Related
- [Compositing & stages](/docs/concepts/compositing-and-stages) — how items survive a stage change, and the `appearance` state machine.
- [Buddy & hatch](/docs/concepts/buddy-and-hatch) — where a buddy starts.
- [Skills](/docs/concepts/skills) — a common evolution trigger.
- [Customize buddy](/docs/guides/customize-buddy) — equip + evolve end to end.
---
# Audiences
> Segment one customer into multiple user groups — each with its own rules, capabilities, and leaderboards.
Source: https://docs.hatched.live/docs/concepts/audiences
An audience is a label (e.g. `student`, `teacher`) that propagates through
every buddy, event, rule, and chart. You declare them on customer settings
with a short brief the rule engine and LLM can read.
## Why audiences exist
Real products have multiple roles. A teacher doesn't care about the
lesson-completion streak; a student doesn't care about class-level
leaderboards. Audiences keep these experiences cleanly separate **without
forking your customer** into two accounts.
## Example
> Declare `student` and `teacher` audiences. A rule scoped to
> `audience: "student"` only fires for student buddies, even though both
> student and teacher buddies live under the same API key.
## How to set it up
1. Add audiences under Settings → Audiences, with a one-paragraph brief for
each.
2. Scope rules, streaks, items with the `audience` field.
3. Ingest events with the `audience` key — Hatched auto-creates per-audience
buddies when needed.
4. Filter every analytics card by audience from the top-of-page dropdown.
## Gotchas
- Removing an audience is blocked while any buddy still references it. The
dashboard shows you the blocking buddies.
- A single `externalUserId` can have **one buddy per audience** — handy for
users who play both roles.
- Audience briefs are read by the LLM that generates copy and images. Short,
direct briefs produce better results than verbose ones.
## Related
- [Config versions](/docs/concepts/config-versions) — audience-scoped rules live in the same versioned snapshot.
- [Leaderboard](/docs/concepts/leaderboard) — scope rankings per audience.
- [Configure rules](/docs/guides/configure-rules) — adding per-audience overrides.
---
# Config versions
> Immutable snapshots of every rule, skill, coin payout, and item. Buddies pin to a snapshot so their world never shifts unexpectedly.
Source: https://docs.hatched.live/docs/concepts/config-versions
Drafts are your working copy. Publishing freezes them. Each buddy carries a
`config_version_id` that the [rule engine](/docs/concepts/rule-engine) loads
on every event. Migration is explicit — you decide when a buddy moves to a
newer version.
## Why versions exist
Without versioning, tweaking a coin payout silently changes every live
buddy's behaviour. With it, you can always explain exactly why a buddy did
what it did.
## Example
> Bump `lesson_completed` from +10 → +25 coins in the draft. Publish. New
> eggs hatch pinned to v12. Existing buddies stay on v11 until you migrate
> them.
## How to work with drafts
1. Make changes freely — they land on the draft.
2. Review the diff under Publish before flipping it live.
3. Migrate buddies in bulk or individually when you're ready.
## Lifecycle
- **Draft** — your working copy, mutable.
- **Published** — immutable; new buddies pin to it.
- **Archived** — old snapshot still valid for pinned buddies, no longer
available for new pins.
## Gotchas
- Migration swaps the **rulebook**, never the state. Coins, badges, streak
counters all carry over when a buddy moves to a new version.
- Archiving a version doesn't migrate its buddies; they keep running under
the old snapshot until you explicitly move them.
- Preset changes from Hatched itself land as a new version for your
customer — they never silently modify your draft.
## Related
- [Rule engine](/docs/concepts/rule-engine) — loads the pinned version on every event.
- [Audiences](/docs/concepts/audiences) — per-audience rule overrides live in the version.
- [Configure rules](/docs/guides/configure-rules) — editing drafts and publishing.
---
# Rule engine
> The deterministic two-phase pipeline that converts events into effects.
Source: https://docs.hatched.live/docs/concepts/rule-engine
The rule engine is the heart of Hatched. Every event you send goes through
the same pipeline; every effect the buddy accumulates is the output of that
pipeline.
## The two-phase contract
1. **Compute phase** — read-only. Given the current buddy state and the
incoming event, the engine computes what *would* change: coin
increments, skill increments, badges newly eligible, token deltas,
streak ticks.
2. **Apply phase** — transactional. Opens a single database transaction,
takes a `pessimistic_write` lock on the buddy row, writes every computed
effect, commits atomically. If any step throws, everything rolls back.
3. **Post-transaction** — non-atomic side effects: progression counters,
evolution readiness check, webhook emission.
## Why this split
Two properties fall out of the contract:
- **Idempotency** — the same `event_id` produces exactly one effect even if
retried.
- **Race-freedom** — concurrent events for the same buddy serialise on the
row lock, so two overlapping lessons don't both award the same badge.
## Two ingestion paths — by design
- `POST /events` — standard ingestion. The rule engine owns the decision.
- `POST /coins`, `POST /skills`, `POST /badges/:id/award`, `POST /tokens` —
administrative override. Every write still funnels through the same
transactional services, so ledger invariants are preserved.
This is intentional. Don't try to collapse them into a single endpoint.
## Unknown events
Events not declared on the customer's [config version](/docs/concepts/config-versions):
- Type in `EVENT_COUNTER_MAP` → increments the canonical column.
- Type not in the map → atomically upserts into
`progression_metrics.custom_counters` via a `jsonb_set` update.
Downstream handlers still evaluate.
- Type neither in the map nor declared → same as above, plus a warning log.
**Never dropped.**
## Observability
Every event carries a `requestId` that follows it through the rule engine,
into effect ledger entries, and out into webhooks. Keep the `requestId`
from API responses and webhook payloads when contacting support; Hatched
uses that value to trace a specific event through retries, ledgers, and
webhook delivery without asking you to expose secret keys or raw database
state.
## Related
- [Config versions](/docs/concepts/config-versions) — the immutable rulebook the engine loads per buddy.
- [Coins](/docs/concepts/coins), [Skills](/docs/concepts/skills), [Badges](/docs/concepts/badges) — the effects the engine produces.
- [Send events](/docs/guides/send-events) — what to send, with stable `eventId`s.
---
# Webhooks
> Signed HTTP callbacks when something happens in the buddy's world — HMAC-signed, 3 retries, 5-minute replay window.
Source: https://docs.hatched.live/docs/concepts/webhooks
Point Hatched at one of your endpoints and it will `POST` JSON whenever a
subscribed event fires — coin earned, badge awarded, streak milestone,
buddy evolved, etc. The signature is HMAC-SHA256 over `${timestamp}.${body}`
using the secret we show **once** at creation.
## Why webhooks
[Widgets](/docs/reference/widgets/buddy) cover the presentation layer;
webhooks cover the business layer. Unlock a course when a badge fires,
notify Slack on streak milestones, sync coins into your own ledger.
## Example
> Subscribe to `badge.awarded`. When "7 Day Streak" fires, your backend
> grants the user a premium feature for 24h.
## How to set it up
1. Add an endpoint under Settings → Webhooks and pick event types.
2. **Store the secret** — it is shown only at creation time. Rotating the
secret requires re-subscribing.
3. On receipt, **verify HMAC** + timestamp, reject replays older than 5
minutes.
4. Return 2xx quickly; non-2xx triggers retries at +5s, +30s, +5min.
See [Handle webhooks](/docs/guides/handle-webhooks) for a complete
verification example in Node.
## Gotchas
- Sign over the **raw body bytes**, not a re-serialized JSON. JSON parsers
reorder keys, which breaks the signature.
- Delivery log keeps every attempt — use it when something looks off.
- Webhooks are not ordered. If you need strict ordering, write to a queue
on your side and sequence from there.
## Related
- [Handle webhooks](/docs/guides/handle-webhooks) — signature verification, replay window, retries.
- [Webhook payloads](/docs/reference/webhook-payloads) — the shape of every event type.
- [Rule engine](/docs/concepts/rule-engine) — what produces the events you subscribe to.
---
# Auth model
> Secret keys, publishable keys, widget session tokens, and embed tokens — which one to use, when, and why.
Source: https://docs.hatched.live/docs/concepts/auth-model
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
| Token | Prefix | Where it lives | Can do |
| ------------------------ | ------------------------------ | ---------------- | ------------------------------------------------------------- |
| **Secret API key** | `hatch_live_*`, `hatch_test_*` | Server (env var) | Everything. Full account access. |
| **Publishable key** | `hatch_pk_*` | Browser (safe) | Read buddies/operations, mint read-only embed tokens. |
| **Widget session token** | JWT | Browser | Scoped interactive actions (track, buy, equip) for one buddy. |
| **Embed token** | JWT | Browser | Read-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 token | Embed token |
| --- | --- | --- |
| Minted by | `POST /api/v1/widget-sessions` — `hatched.widgetSessions.create(...)` | `POST /api/v1/embed-tokens` — `hatched.embedTokens.create(...)` |
| Requires `scopes`? | **Yes** (`['read', 'events:track', ...]`) — sending none is rejected | **No** — the endpoint rejects a `scopes` field |
| Loader attribute | `data-session-token` | `data-embed-token` |
| Widget mode | `interactive` — can track events, purchase, equip | `read-only` — display only |
| Server-side state | Tracked (revocable via `widgetSessions.revoke`) | Stateless (validity = JWT signature + `exp`) |
| Default TTL | 1h (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](/docs/guides/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
```ts
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
```ts
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 `` 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 `
`.
5. The widget talks to Hatched directly, signed with the session token.
```ts
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
```ts
// 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:
```http
POST /embed-tokens
Authorization: Bearer hatch_live_xxxxxxxxxxxx
Content-Type: application/json
{ "user_id": "user_42", "buddy_id": "bdy_abc", "ttl_seconds": 3600 }
```
Response:
```json
{ "token": "eyJhbGciOi…", "expires_at": "2026-05-05T16:00:00Z", "mode": "read-only" }
```
### How it reaches the browser
```html
```
### 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
| Layer | Token |
| ---------------------------------------------------------- | ----------------------------------- |
| `.env` / Vercel secrets / GitHub secrets | Secret API key |
| `NEXT_PUBLIC_HATCHED_PK` / HTML `` | Publishable key |
| Request to your `/api/hatched/session` endpoint | — returns widget session token |
| `data-session-token` / `data-embed-token` script attribute | Widget 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.
## Related
- [Widget integration](/docs/guides/widget-integration)
- [Browser usage with publishable key](/docs/guides/browser-usage)
- [Error: publishable_key_scope](/docs/reference/error-codes#publishable-key-scope)
---
# Getting started
> Ten minutes from zero to a buddy in your product — create an egg, send your first event, embed a widget.
Source: https://docs.hatched.live/docs/guides/getting-started
This guide walks through the full integration path. If you only have ten
minutes, this is the one to read.
> Wiring this into a real app? Read [First user bootstrap](/docs/guides/first-user-bootstrap)
> alongside it — same flow, with the parts you can't skip spelled out: publish
> your config first, **reuse an existing buddy instead of creating a new egg on
> every load**, persist `buddy_id`, the `snake_case` raw API, and hatch latency.
> Skipping those is the #1 cause of broken first-run integrations.
## 1. Sign up and grab an API key
1. Create an account at the [Hatched dashboard](https://hatched.live).
2. Pick a dashboard preset — `language-learning`, `fitness`, `productivity`,
or `custom` — it seeds your customer config with sensible defaults.
3. **Publish your config.** Picking a preset in step 2 publishes your first
config version automatically, so `eggs.create` works straight away. (If you
built a config from scratch, open the rules editor and hit Publish —
`eggs.create` returns `409 no_published_config` until one is published. Later
edits also sit on a draft until you publish them.) New buddies pin to the
snapshot you publish.
4. Go to **Developers → API keys** and create a **secret key** (prefix
`hatch_live_` in production, `hatch_test_` for sandbox).
Secret keys are server-only. Never ship one to a browser bundle.
## 2. Install the SDK
```bash
pnpm add @hatched/sdk-js
# or
npm install @hatched/sdk-js
```
```ts
import { HatchedClient } from '@hatched/sdk-js';
const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
});
```
> The SDK throws on construction if it detects a browser runtime. For
> browser integrations, mint a widget session token server-side
> (step 5) or use a [publishable key](/docs/concepts/auth-model).
## 3. Create an egg and hatch it
A buddy is born from an egg. **Do this once per user** — before creating an egg,
check whether the user already has a buddy (`hatched.buddies.list({ userId })`)
or whether you've stored one. Creating an egg on every page load fills up the
per-user egg limit; the [bootstrap guide](/docs/guides/first-user-bootstrap)
has the full reuse pattern. `ensure: true` makes the create call reuse this
user's existing `waiting`/`ready` egg if there is one.
```ts
const egg = await hatched.eggs.create({ userId: 'user_42', ensure: true });
if (egg.status === 'waiting') {
await hatched.eggs.updateStatus(egg.eggId, 'ready');
}
const hatchOp = await hatched.eggs.hatch(egg.eggId);
const finished = await hatched.operations.wait(hatchOp.operationId);
const buddyId = finished.result.buddyId;
console.log('Buddy ready:', buddyId);
// Persist buddyId against your app user — you need it for the widget session below
// and on every future page load. (See "Persist buddy_id" in the bootstrap guide.)
// Once the egg is hatched, GET /eggs/:id also returns its buddy_id.
```
Image generation runs asynchronously; `operations.wait` polls the hatch
operation until the buddy's art is ready (typically 5–45 seconds). Show a
loading state in your UI rather than blocking on it.
## 4. Send your first event
```ts
await hatched.events.send({
eventId: 'lesson_lsn_1_user_42',
userId: 'user_42',
type: 'lesson_completed',
properties: { lessonId: 'lesson_1', durationMs: 5 * 60 * 1000 },
});
```
The [rule engine](/docs/concepts/rule-engine) evaluates the event against
the buddy's pinned config and applies coin, skill, badge, streak, and
evolution effects in a single transaction. `eventId` provides idempotency —
re-sending the same id returns the cached effect.
When an event satisfies the next evolution condition, the SDK response includes
`effects.evolutionReady === true`. If your config does not enable auto-evolve,
start the stage transition from your backend:
```ts
const effects = await hatched.events.send({
eventId: 'lesson_lsn_2_user_42',
userId: 'user_42',
type: 'lesson_completed',
});
if (effects.evolutionReady) {
const evolveOp = await hatched.buddies.evolve('bdy_abc');
await hatched.operations.wait(evolveOp.operationId);
}
```
## 5. Embed the buddy widget
On any page your user visits, mint a **widget session token** on your server,
using the `buddyId` you stored in step 3:
```ts
const session = await hatched.widgetSessions.create({
buddyId, // from the hatch result / your stored value — NOT the userId
userId: 'user_42',
scopes: ['read', 'events:track', 'marketplace:browse'],
ttlSeconds: 60 * 15,
});
```
This is the *interactive* token (`data-session-token`). For a purely read-only
display mount, use `embedTokens.create(...)` instead (`data-embed-token`, no
scopes) — see [Auth model](/docs/concepts/auth-model#session-token-vs-embed-token).
Pass the token to the client and render the widget:
```html
```
That's it. The widget mounts in a Shadow DOM, pulls buddy state, and
reflects new events in real time.
## Next steps
- [Handle webhooks](/docs/guides/handle-webhooks) — react on your backend
when a buddy earns a badge or hits a streak milestone.
- [Configure rules](/docs/guides/configure-rules) — tune the coin economy
and badge conditions.
- [Reference](/docs/reference/http-api) — the full API spec.
- [Auth model](/docs/concepts/auth-model) — secret vs publishable keys.
---
# 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.
Source: https://docs.hatched.live/docs/guides/first-user-bootstrap
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:
```text
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](/docs/guides/getting-started); for the
full SDK surface, [SDK quickstart](/docs/guides/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:**
```ts
import { HatchedClient } from '@hatched/sdk-js';
const hatched = new HatchedClient({ apiKey: process.env.HATCHED_API_KEY! });
async function ensureBuddyId(userId: string): Promise {
// 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:**
```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:**
```ts
async function createAndHatch(userId: string): Promise {
// 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:**
```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:**
```ts
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:**
```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](/docs/concepts/auth-model#session-token-vs-embed-token).
## 4. Mount the widget
```html
```
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** — `localStorage` 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
| 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/` or `/widget/path/` 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](/docs/guides/getting-started) — the happy path once the buddy exists.
- [Auth model](/docs/concepts/auth-model) — session token vs embed token vs publishable key.
- [Best practices](/docs/guides/best-practices) — idempotency, multi-tenant ids, and more.
- [Troubleshooting](/docs/guides/troubleshooting) — diagnosing the errors above.
---
# SDK quickstart
> Install @hatched/sdk-js, authenticate, and make your first calls from Node or TypeScript.
Source: https://docs.hatched.live/docs/guides/sdk-quickstart
`@hatched/sdk-js` is the official TypeScript SDK for the Hatched API. It
ships as dual ESM + CJS, runs on Node 18+, Cloudflare Workers, Vercel
Edge, Deno, and Bun. Full package on
[npmjs.com/package/@hatched/sdk-js](https://npmjs.com/package/@hatched/sdk-js).
## Install
```bash
pnpm add @hatched/sdk-js
```
## Initialise a client
```ts
import { HatchedClient } from '@hatched/sdk-js';
const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
// optional overrides:
baseUrl: 'https://api.staging.hatched.live/api/v1',
timeoutMs: 15_000,
maxRetries: 3,
fetch: globalThis.fetch,
});
```
`hatch_test_*` secret keys default to the staging API if you omit `baseUrl`;
`hatch_live_*` keys default to production.
The SDK parses the [canonical error envelope](/docs/reference/error-codes)
and throws typed `HatchedError` subclasses with `requestId`, `code`, and
`statusCode` fields.
> Secret keys (`hatch_live_*`, `hatch_test_*`) are **server-only**. The
> SDK throws if instantiated in a DOM environment. See
> [Auth model](/docs/concepts/auth-model) for browser options.
## Core resources
```ts
// Eggs & buddies
const egg = await hatched.eggs.create({ userId });
await hatched.eggs.updateStatus(egg.eggId, 'ready');
await hatched.eggs.hatch(egg.eggId);
await hatched.buddies.list({ userId });
// Economy
await hatched.buddies.earn(buddyId, { amount: 50, reason: 'lesson_reward' });
await hatched.buddies.spend(buddyId, { amount: 20, reason: 'item_purchase' });
const equip = await hatched.buddies.equip(buddyId, { equip: [itemId] });
if (equip.operationId) await hatched.operations.wait(equip.operationId);
const buddy = await hatched.buddies.get(buddyId);
console.log(buddy.appearance?.status);
// Recovery only: use when buddy.appearance?.error?.code === 'needs_rerender'
// await hatched.buddies.rerenderAppearance(buddyId);
// Events
await hatched.events.send({ eventId, userId, type, properties });
// Operations (async image jobs)
const op = await hatched.eggs.hatch(egg.eggId);
const finished = await hatched.operations.wait(op.operationId);
// Widget sessions
await hatched.widgetSessions.create({ buddyId, userId, scopes, ttlSeconds });
await hatched.widgetSessions.revoke(sessionId);
```
## Error handling
```ts
import { HatchedError, RateLimitError, ValidationError } from '@hatched/sdk-js';
try {
await hatched.events.send({ ... });
} catch (err) {
if (err instanceof RateLimitError) {
await sleep(err.retryAfter * 1000);
} else if (err instanceof ValidationError) {
console.error('bad payload:', err.details);
} else if (err instanceof HatchedError) {
console.error(err.code, err.requestId, err.message);
} else {
throw err;
}
}
```
The SDK exposes a typed class per known error code. See
[Error codes](/docs/reference/error-codes) for the full catalogue.
## Retries and idempotency
- `events.send` is idempotent on `eventId` — passing the same id twice
returns the cached effect without re-applying rules.
- The client retries `GET`s and `idempotent: true` `POST`s on network
failures, `408`, `429` (with `Retry-After` honoured), and 5xx responses.
Exponential backoff + jitter.
- 4xx responses (other than 408/429) surface immediately.
## Cancellation
Every resource method accepts an optional `AbortSignal` as its last
argument. The SDK combines it with the built-in timeout via
`AbortSignal.any`, so either source can cancel the request.
```ts
const controller = new AbortController();
setTimeout(() => controller.abort(), 500);
await hatched.buddies.get(buddyId, controller.signal);
```
## Rate limits + request ids
```ts
await hatched.events.send({ ... });
console.log(hatched.getRateLimitInfo());
// { limit: 1000, remaining: 986, reset: 1735689600, retryAfter: undefined }
console.log(hatched.getLastRequestId());
// 'req_abc_123'
```
Include the request id in any support ticket and we can look up the full
trace.
---
# Widget integration
> Drop Hatched widgets into any page with one loader script and stable data-hatched-mount attributes.
Source: https://docs.hatched.live/docs/guides/widget-integration
Widgets are the presentation layer: small Shadow DOM UI pieces that render
buddy state directly in your product without leaking CSS in either direction.
## The short version
```html
```
The loader reads the token from its own script tag, discovers known
`data-hatched-mount` elements,
downloads only the bundles present on the page, and keeps them in sync through
one shared widget state poller.
The browser global is `window.__HATCHED_WIDGET__`. If a SPA creates mount
elements after the loader has already run, call
`window.__HATCHED_WIDGET__?.init({ token })` after those elements exist. The
required identity field is `token`; user and buddy identity come from the JWT.
Advanced endpoint and theme overrides are documented in
[Runtime configuration](#runtime-configuration).
## From onboarding to production
Onboarding scans the operator's site, extracts a brand brief plus visual identity
evidence (palette, typography, motifs), and seeds three widget defaults on the
customer: `widget_theme_config`, `widget_custom_css`, and `widget_size`. The
Dashboard Widget Studio preview and the install snippets both read those same
settings, so the widget a teammate approves in the dashboard is the widget that
ships in production.
> **The shortest path:** open Widget Studio → pick a preset (or click **AI from
> theme** so the loader rebuilds the theme from your scanned brand evidence) →
> dial in personality, size, and CSS hooks → **Save**. Your live page picks up
> the new theme on next load or focus — see [Live theme sync](#live-theme-sync)
> below.
Use the dashboard-generated snippet as the source of truth when possible. It
includes the current personality axes, theme variables, custom CSS hook
overrides, size, and the correct mount id for each widget. Manual edits should
use the public `data-*` attributes, `--hw-*` variables, and `.hw-*` class hooks
below so future widget releases can keep the same customization contract.
## Live theme sync
The loader fetches `/widget/theme` on init (using the same widget token you
already pass) and on browser focus / tab visibility change. The response carries
the customer's current `widget_theme_config` (preset, personality, vars),
`widget_custom_css`, and `widget_size`. If anything differs from the inline
`data-*` attributes, the loader hot-swaps the widget — surface, geometry,
motion, reward voice, iconography, typography, palette and density all update
without a page reload.
What this means in practice:
- **You do not have to re-paste the snippet** every time a teammate changes a
color or switches a personality dial in Widget Studio.
- The inline `data-surface` / `data-geometry` / `data-motion-profile` /
`data-reward-voice` / `data-iconography` / `data-typography` /
`data-theme-vars` / `data-custom-css` attributes still ship as **fast-paint
fallbacks** so SSR/no-JS or first-render scenarios get the right widget
immediately, but the API answer is authoritative.
- Theme refresh respects a 5s minimum interval and a 15s `Cache-Control` on the
endpoint so it stays cheap even on heavily-trafficked partner pages.
## Token choice
Use a **widget session token** for interactive widgets. Mint it on your server
with a secret API key:
```ts
import { HatchedClient } from '@hatched/sdk-js';
const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
});
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,
});
```
Use a **read-only embed token** when the page only displays state. The
embed token is a short-lived JWT minted by your backend per render, then
handed to the browser. It is *not* an API key and is not created once in
the dashboard — every page render mints a fresh one:
```ts
// On your server (route handler, server component, etc.)
const embed = await hatched.embedTokens.create({
buddyId: 'bdy_abc',
userId: 'user_42',
ttlSeconds: 60 * 60,
});
// Send embed.token down to the browser.
```
Then inline that token into the loader script attribute:
```html
```
See [Auth model → Embed token](/docs/concepts/auth-model#embed-token) for
the full lifecycle, raw-HTTP form, and how it differs from a widget session
token.
### Token matrix
| Widget / action | Token to use |
| ------------------------------ | --------------------------------------------- |
| Buddy display | Embed token or widget session token |
| Badges display | Embed token or widget session token |
| Leaderboard display | Embed token or widget session token |
| Streak display | Embed token or widget session token |
| Guided path display | Embed token or widget session token |
| Browser `track()` | Widget session token with `events:track` |
| Manual path sub-step completion | Widget session token with `events:track` |
| Marketplace browse | Embed/session token; add `marketplace:browse` for session review |
| Marketplace purchase | Widget session token with `marketplace:purchase` |
| Equip / unequip items | Widget session token with `items:equip` |
### Allowed browser origins
Widget runtime requests are allowed per customer. When onboarding is seeded
from a website URL, Hatched automatically adds that URL's origin to
`settings.widget_allowed_origins`. You can add local, staging, and production
app origins later in Dashboard → Settings → General → Widget allowed origins.
The allowlist is read from customer settings on each widget API request; it is
not baked into embed or session tokens.
Use origins only, not full paths:
```txt
https://app.example.com
http://localhost:4002
```
### Staging vs production
The loader picks its default API base from the URL it was loaded from:
| Loader URL | Default API base |
| ------------------------------------------- | --------------------------------------------- |
| `https://cdn.hatched.live/widget.js` | `https://api.hatched.live/api/v1` |
| `https://cdn.hatched.live/staging/widget.js` | `https://api.staging.hatched.live/api/v1` |
So a snippet that points at the staging CDN automatically talks to the
staging API — no `data-api-base-url` override needed. If you _do_ pass
`data-api-base-url`, the value must be one of the canonical Hatched API
origins (`api.hatched.live`, `api.staging.hatched.live`); arbitrary
values are rejected to prevent token exfiltration via host-page XSS.
## Runtime configuration
The script tag is the preferred public configuration surface. The same values
can be passed to `window.__HATCHED_WIDGET__?.init(...)` when a SPA mints or
refreshes a token after hydration.
```ts
window.__HATCHED_WIDGET__?.init({
token,
apiBaseUrl: 'https://api.staging.hatched.live/api/v1',
cdnBaseUrl: 'https://cdn.hatched.live/staging/widget/v1/',
themeVars: { '--hw-accent': '#3F8F5F' },
customCss: '.hw-container { box-shadow: none; }',
personality: {
surface: 'paper',
geometry: 'rounded',
motion_profile: 'standard',
reward_voice: 'joyful',
iconography: 'geometric',
typography_pair: 'sans-serif-modern',
},
size: 'medium',
lang: 'en',
});
```
| Init key | Equivalent script attribute | Notes |
| -------------- | --------------------------- | ----- |
| `token` | `data-session-token` or `data-embed-token` | Required unless already present on the script tag |
| `apiBaseUrl` | `data-api-base-url` | Must be a canonical Hatched API origin |
| `cdnBaseUrl` | Derived from script `src` | Advanced staging / self-hosted bundle override |
| `themeVars` | `data-theme-vars` | JSON object of `--hw-*` values |
| `customCss` | `data-custom-css` / `data-custom-css-id` | Inline CSS or CSS script element |
| `personality` | `data-surface`, `data-geometry`, etc. | Widget Studio writes these for you |
| `size` | `data-size` | `small`, `medium`, or `large` |
| `lang` | `data-lang` | Locale hint |
Per-widget instance identity belongs on the mount element. For example,
streak widgets read `data-streak-key` and path widgets read
`data-path-key` from the `
`, not from `init()`.
## Available mounts
| Mount attribute | Purpose |
| --------------------------------------- | -------------------------------------------------- |
| `data-hatched-mount="buddy"` | Animated companion, coins, stage, equipped items |
| `data-hatched-mount="badges"` | Earned and locked badge shelf |
| `data-hatched-mount="streak"` | One or more streak counters; add a Dashboard streak `key` via `data-streak-key` |
| `data-hatched-mount="path"` | Guided journey for the audience's active path; add `data-path-key` to pin a specific path |
| `data-hatched-mount="tokens"` | Wallet card — spendable balance plus progression-token balances with progress toward each gate |
| `data-hatched-mount="marketplace"` | Browse, buy, and equip items |
| `data-hatched-mount="leaderboard"` | Community rank surface |
Legacy ids such as `id="buddy-widget"` still work for older installs, but new
snippets should use `data-hatched-mount`.
## Personality dimensions
Hatched widgets compose six personality axes that decide how the widget feels —
not just how it's coloured. The Widget Studio presets pick one value per axis;
the loader forwards each as a `data-*` attribute that drives Shadow-DOM CSS
attribute selectors. You can override any single axis without abandoning the
preset.
| Axis | Attribute | Values | Drives |
| ---------------- | --------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Surface | `data-surface` | `paper` `glass` `metal` `crt` `parchment` `vellum` | Shell background texture (grain, frost, scanlines) |
| Geometry | `data-geometry` | `rounded` `cut` `pill` `sharp` `organic` | Shell, card, button, badge clip + bar radii |
| Motion profile | `data-motion-profile` | `calm` `standard` `expressive` `theatrical` | Idle bounce, hover lift, animation amplitude |
| Reward voice | `data-reward-voice` | `quiet` `crisp` `joyful` `epic` | Coin pulse, level-up burst, badge glow intensity |
| Iconography | `data-iconography` | `geometric` `hand-drawn` `pixel` `embossed` `flat-mono` | Icon stroke, wobble, pixelation, depth |
| Typography pair | `data-typography` | `sans-serif-modern` `serif-classic` `mono-tech` `rounded-playful` `display-condensed` | Body + display font stacks |
Pick the combination that matches your brand. A fintech onboarding probably
wants `vellum + sharp + calm + crisp + geometric + sans-serif-modern`. A
gaming studio probably wants `crt + sharp + theatrical + epic + pixel +
mono-tech`. A wellness app probably wants `parchment + pill + calm + quiet +
hand-drawn + serif-classic`.
The Widget Studio's **AI from theme** action and the onboarding scout both
populate these axes from your scanned brand evidence, so the preset that
ships in production matches the one a teammate approved in the dashboard.
## Styling and CSS hooks
Widgets render inside Shadow DOM, so your product CSS cannot accidentally break
them. Customization is explicit: pass design tokens on the loader script and use
stable `.hw-*` hooks for deeper polish.
```html
```
### Loader styling attributes
| Attribute | Values | Purpose |
| --------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------- |
| `data-surface` | `paper` `glass` `metal` `crt` `parchment` `vellum` | Shell background texture |
| `data-geometry` | `rounded` `cut` `pill` `sharp` `organic` | Corner radii across shell, cards, buttons |
| `data-motion-profile` | `calm` `standard` `expressive` `theatrical` | Idle bounce and hover amplitude |
| `data-reward-voice` | `quiet` `crisp` `joyful` `epic` | Celebration intensity |
| `data-iconography` | `geometric` `hand-drawn` `pixel` `embossed` `flat-mono` | Icon styling |
| `data-typography` | `sans-serif-modern` `serif-classic` `mono-tech` `rounded-playful` `display-condensed` | Body + display font pair |
| `data-size` | `small` `medium` `large` | Widget density and panel height |
| `data-theme-vars` | JSON object of `--hw-*` values | CSS variables injected into each shadow root |
| `data-custom-css` | CSS string | Inline custom CSS for short overrides |
| `data-custom-css-id` | element id | Reads CSS from a `
```
The Shadow DOM picks these up via CSS variables.
## Evolution stages
Each evolution stage gets a different image. You control the **count**
(3–6), the **conditions** (XP, skill level, badges, coins), and whether
evolution is automatic or user-triggered.
For a complete design conversation with the art generator, see
[Evolution](/docs/concepts/evolution).
## Equip slots
Marketplace items snap into one of a fixed set of equip slots: hat, outfit,
accessory, background, companion. Each item declares which slots it uses.
When designing items, stick to the slot's silhouette so the art layering
stays clean across evolution stages.
---
# Configure rules
> Tune the coin economy, skill progression, badge conditions, and streak milestones in the dashboard.
Source: https://docs.hatched.live/docs/guides/configure-rules
Rules live on your **config version**. Changes land on a draft first, then
publish as a new immutable version. Existing buddies stay pinned to their
current version until you migrate them.
## Where rules live
| Surface | What it controls |
| --- | --- |
| **Skills → Set** | Skill names, icons, max levels, skill rules |
| **Skills → Decay** | Time-based skill loss ([concept](/docs/concepts/skill-decay)) |
| **Economy → Coin rules** | Event → coin amount mappings, daily caps |
| **Economy → Tokens** | Secondary currencies and their earn rules |
| **Engagement → Streaks** | Streak definitions and milestone rewards |
| **Engagement → Badges** | Badge conditions, auto-vs-manual award |
| **Evolution** | Stage conditions, creature style, art mode |
| **Marketplace** | Items, pricing, visibility rules |
Each surface writes to the same draft. The diff between draft and
published is visible under **Publish** before you flip it live.
## Publishing
1. Review the diff — coin rules changed, badges added, streaks modified.
2. Hit **Publish** — a new immutable version is created and becomes the
default for new eggs.
3. Existing buddies are *not* migrated. They stay on their pinned version.
4. Migrate buddies in bulk from **Buddies → Migration** when you're ready.
## Migrating existing buddies
Migration is a first-class operation:
- It swaps the rulebook, never the state.
- Coin balances, badge lists, streak counters all carry over.
- If a rule that awarded a badge on their current state no longer exists in
the new version, the badge stays — historical awards are never revoked.
- You can migrate a single buddy, an audience, or all buddies at once.
## Tuning tips
- **Watch Economy Health.** The dashboard shows coin inflow vs. outflow per
day. When inflow outruns outflow for too long, marketplace items become
invisible.
- **Cap the top of the earn curve.** Daily caps prevent grinding; multiplier
caps prevent streak-compounding breakage.
- **Start with auto-awarded badges.** Manual badges need a moderation
workflow — get auto working before adding the human loop.
- **Publish small, publish often.** Each version is cheap; big-bang
publishes are harder to reason about.
---
# Handle webhooks
> Verify the HMAC signature, respect the replay window, and respond before Hatched retries.
Source: https://docs.hatched.live/docs/guides/handle-webhooks
Webhooks are how your backend reacts to buddy events. Hatched signs every
request so you can trust the payload came from us and hasn't been tampered
with.
## Subscribe
1. Dashboard → Settings → Webhooks → **Add endpoint**.
2. Pick the event types you care about
([catalogue](/docs/reference/webhook-payloads)).
3. Copy the signing secret **once** — we don't show it again.
Programmatically:
```ts
await hatched.webhooks.create({
url: 'https://your-app.com/api/webhooks/hatched',
events: ['buddy.leveled_up', 'badge.awarded', 'streak.milestone'],
});
```
## Verify with the SDK helper
`@hatched/sdk-js` ships a static helper for signature verification:
```ts
import { WebhooksResource } from '@hatched/sdk-js';
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('hatched-signature') ?? '';
const valid = WebhooksResource.verifySignature(
rawBody,
signature,
process.env.HATCHED_WEBHOOK_SECRET!,
);
if (!valid) return new Response('invalid signature', { status: 400 });
const event = JSON.parse(rawBody);
await handle(event);
return new Response(null, { status: 202 });
}
```
The header format is `t=,v1=`. The helper
verifies the HMAC, rejects timestamps older than `toleranceSeconds`
(default 300), and uses `timingSafeEqual` under the hood.
> Sign over **raw body bytes**. A JSON `parse`→`stringify` round-trip
> reorders keys and breaks the signature. Read the body as `Buffer` or
> `string` **before** any framework middleware parses it as JSON.
## Manual verification (without the SDK)
```ts
import crypto from 'node:crypto';
export function verifyHatchedSignature(header: string, rawBody: Buffer, secret: string) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const ts = parts.t;
const sig = parts.v1;
if (!ts || !sig) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody.toString('utf8')}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex'),
);
}
```
## Respond quickly
- Return a 2xx within 10 seconds, or Hatched retries the delivery.
- Retry schedule: **+5s, +30s, +5min**. After the third failure the
delivery is marked `failed` in the delivery log but the state in Hatched
is already correct.
- If your handler is expensive, ack fast and push to a queue.
## Idempotency
Every webhook carries a unique `deliveryId`. Dedupe against it before
side-effects:
```ts
if (await alreadyHandled(event.deliveryId)) return ack();
await recordHandled(event.deliveryId);
await doTheWork(event);
```
## Replay from the delivery log
Dashboard → Developers → Webhook deliveries shows every attempt with
payload, headers, and response. Replay failed deliveries once your
endpoint is healthy:
```ts
await hatched.webhooks.replay(deliveryId);
```
## Framework examples
- [Next.js route handler](/docs/guides/nextjs-integration) — App Router,
raw body, signature verify.
- [Express middleware](/docs/guides/express-integration) — `express.raw`
+ signature verify before JSON parsing.
- [Edge runtimes](/docs/guides/edge-runtimes) — Workers/Vercel Edge notes.
---
# Unlock gates
> Spend primary tokens to unlock features — the non-cosmetic half of the token economy.
Source: https://docs.hatched.live/docs/guides/unlock-gates
Unlock gates are how tokens get a meaning **beyond dressing up the
buddy**. A gate is a named flag stored against a buddy; the user
"unlocks" it by spending primary tokens. Whether it guards a premium
feature, a higher difficulty, or a surprise reward is up to you.
The primitive is deliberately generic: Hatched stores the unlock, deducts
the tokens, and guarantees idempotency. The client decides what the
unlock *means*.
## Create a gate
Gates are authored in the dashboard under **Settings → Gates**.
Each gate has:
- `gate_key` — stable identifier (e.g. `advanced_mode`, `custom_skin_2`).
Snake_case recommended.
- `token_key` — which primary token pays for it. Must match the
customer's primary slot.
- `cost` — positive integer.
- `metadata` (optional) — arbitrary JSON returned to the client on
lookup. Put display strings and feature flags here.
Gates live at the customer level, not per-buddy — every buddy can unlock
the same gate once.
## Unlock at runtime
```ts
import { HatchedClient, InsufficientBalanceError } from '@hatched/sdk-js';
const hatched = new HatchedClient({ apiKey: process.env.HATCHED_SECRET_KEY! });
try {
const result = await hatched.gates.unlock(buddyId, 'advanced_mode');
if (result.alreadyUnlocked) {
// Idempotent — no tokens spent, existing unlock returned.
console.log('Already unlocked at', result.unlock.unlockedAt);
} else {
// First unlock — tokens just got deducted.
console.log('Unlocked for', result.gate.cost, result.gate.tokenKey);
}
} catch (err) {
if (err instanceof InsufficientBalanceError) {
// User needs more tokens — show a nudge.
} else {
throw err;
}
}
```
The call is **idempotent**: repeat calls return `{ alreadyUnlocked: true }`
without touching the ledger. That means you can retry safely on network
failures, and you can call `unlock()` optimistically from a UI without
double-spending.
## List a buddy's unlocks
```ts
const unlocks = await hatched.gates.unlocks(buddyId);
// [
// { gateKey: 'advanced_mode', unlockedAt: '2026-04-20T10:00:00Z', metadata: { ... } },
// ]
```
Typical usage: fetch once on app load, cache in the client, and treat it
as the source of truth for which features to render.
## List available gates
```ts
const gates = await hatched.gates.list();
// [
// { gateKey: 'advanced_mode', tokenKey: 'gems', cost: 50, metadata: { label: 'Advanced mode' } },
// ]
```
Use this to render a "shop" of feature unlocks alongside the marketplace.
## Publishable-key access
`gates.unlock` is scope-gated. A publishable key needs the
`write:unlocks` scope granted explicitly — it is not part of the default
scopes. That keeps browser-embedded clients from draining tokens
without intent. `gates.unlocks` and `gates.list` are read-only and
allowed under the default `read:buddies` scope.
## Gotchas
- **Primary slot only.** Gates can't spend progression tokens, by design
(progression is monotonic). The dashboard refuses a gate pointing at
the progression key.
- **No undo.** There's no "refund" endpoint for an accidental unlock.
Rename the gate key if you want to effectively reset (old unlocks
remain attached to the dead key but the client treats them as stale).
- **`alreadyUnlocked: true` is normal.** A client calling `unlock()`
inside a `useEffect` on mount is a supported pattern — the second call
is free.
## Related
- [Tokens](/docs/concepts/tokens) — the two-tier model that backs gate
costs.
- [Token economy](/docs/concepts/token-economy) — how the primary slot
fits into spending.
- [Marketplace](/docs/concepts/marketplace) — the other primary-spent
surface.
---
# Next.js integration
> Wire Hatched into a Next.js App Router app — server components, route handlers, widgets, and webhooks.
Source: https://docs.hatched.live/docs/guides/nextjs-integration
Next.js is the most common host for Hatched integrations. The SDK is
server-only, so every call happens in a server component, a route
handler, or middleware — never in a `"use client"` component.
## 1. Install and configure
```bash
pnpm add @hatched/sdk-js
```
```bash
# .env.local
HATCHED_API_KEY=hatch_test_...
HATCHED_WEBHOOK_SECRET=whsec_...
```
## 2. Shared client
```ts
// lib/hatched.ts
import { HatchedClient } from '@hatched/sdk-js';
export const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
});
```
Importing this module from a client component will fail at build time —
good, that's the point. Keep it under `lib/` or `server/` and let the
bundler prevent misuse.
## 3. Server component reads
```tsx
// app/buddy/page.tsx
import { hatched } from '@/lib/hatched';
export default async function BuddyPage({ params }: { params: { userId: string } }) {
const buddies = await hatched.buddies.list({ userId: params.userId });
return ;
}
```
## 4. Route handlers for writes
Mutations (events, coin earn/spend, widget session mint) go through route
handlers. They run on the server with access to `HATCHED_API_KEY`.
```ts
// app/api/hatched/events/route.ts
import { hatched } from '@/lib/hatched';
import { ValidationError } from '@hatched/sdk-js';
export async function POST(req: Request) {
const { userId, lessonId, score } = await req.json();
try {
const effects = await hatched.events.send({
eventId: `lesson_${lessonId}_${userId}`,
userId,
type: 'lesson_completed',
properties: { lessonId, score },
});
return Response.json(effects);
} catch (err) {
if (err instanceof ValidationError) {
return Response.json({ error: err.details }, { status: 422 });
}
throw err;
}
}
```
## 5. Widget session mint endpoint
Your browser widget calls this to get a short-lived token. Never expose
your secret API key directly.
```ts
// app/api/hatched/session/route.ts
import { hatched } from '@/lib/hatched';
import { getServerSession } from '@/lib/auth';
export async function POST() {
const user = await getServerSession();
if (!user) return new Response('unauthorized', { status: 401 });
const session = await hatched.widgetSessions.create({
buddyId: user.buddyId,
userId: user.id,
scopes: ['read', 'events:track', 'marketplace:browse'],
ttlSeconds: 60 * 15,
});
return Response.json({ token: session.token, expiresAt: session.expiresAt });
}
```
```tsx
// components/buddy-widget.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
export function BuddyWidget() {
const [token, setToken] = useState(null);
const mountRef = useRef(null);
useEffect(() => {
fetch('/api/hatched/session', { method: 'POST' })
.then((r) => r.json())
.then(({ token }) => setToken(token));
}, []);
useEffect(() => {
if (token) (window as any).__HATCHED_WIDGET__?.init({ token });
}, [token]);
if (!token) return null;
return (
<>
>
);
}
```
## 6. Webhook handler
Raw body is critical for signature verification. In the App Router,
`req.text()` preserves the raw bytes.
```ts
// app/api/hatched/webhooks/route.ts
import { WebhooksResource } from '@hatched/sdk-js';
export const runtime = 'nodejs'; // `verifySignature` uses node:crypto
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get('hatched-signature') ?? '';
const valid = WebhooksResource.verifySignature(
raw,
signature,
process.env.HATCHED_WEBHOOK_SECRET!,
);
if (!valid) return new Response('invalid signature', { status: 400 });
const event = JSON.parse(raw);
// enqueue for background processing
await handle(event);
return new Response(null, { status: 202 });
}
```
## 7. Middleware gotcha
Next.js Middleware runs in the Edge runtime. `@hatched/sdk-js` works in
Edge **only** with `publishableKey` (read endpoints). For secret-key
writes, move the logic into a `runtime = 'nodejs'` route handler.
## Project layout recap
```
app/
api/
hatched/
events/route.ts POST — ingest an event
session/route.ts POST — mint widget session token
webhooks/route.ts POST — receive webhook (runtime=nodejs)
buddy/page.tsx server component using hatched.buddies.*
components/
buddy-widget.tsx "use client" — mounts data-hatched-mount="buddy"
lib/
hatched.ts shared HatchedClient instance
```
---
# Express integration
> Use @hatched/sdk-js from an Express app — handlers, raw-body webhook middleware, and error mapping.
Source: https://docs.hatched.live/docs/guides/express-integration
Express is straightforward. The SDK is server-only, so everything works
out of the box as long as you keep your API key in an env variable and
preserve raw bodies for webhooks.
## Install
```bash
pnpm add @hatched/sdk-js express
```
## Shared client
```ts
// src/hatched.ts
import { HatchedClient } from '@hatched/sdk-js';
export const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
});
```
## Route handler example
```ts
// src/routes/events.ts
import { Router } from 'express';
import { ValidationError } from '@hatched/sdk-js';
import { hatched } from '../hatched';
export const events = Router();
events.post('/lesson-completed', async (req, res, next) => {
try {
const { userId, lessonId, score } = req.body;
const effects = await hatched.events.send({
eventId: `lesson_${lessonId}_${userId}`,
userId,
type: 'lesson_completed',
properties: { lessonId, score },
});
res.json(effects);
} catch (err) {
if (err instanceof ValidationError) {
return res.status(422).json({ error: err.details });
}
next(err);
}
});
```
## Webhook endpoint — raw body first
The default `express.json()` middleware parses and throws away the raw
body, which breaks signature verification. Mount a raw-body parser on
**just** the webhook path:
```ts
// src/index.ts
import express from 'express';
import { WebhooksResource } from '@hatched/sdk-js';
const app = express();
app.post(
'/webhooks/hatched',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('hatched-signature') ?? '';
const valid = WebhooksResource.verifySignature(
req.body, // Buffer, from express.raw
signature,
process.env.HATCHED_WEBHOOK_SECRET!,
);
if (!valid) return res.status(400).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// enqueue, etc.
res.status(202).end();
},
);
// JSON parser for everything else
app.use(express.json());
// ... your other routes
```
## Centralised error mapping
```ts
import { HatchedError, RateLimitError } from '@hatched/sdk-js';
app.use((err, _req, res, _next) => {
if (err instanceof RateLimitError) {
res.set('Retry-After', String(err.retryAfter));
return res.status(429).json({ error: 'rate_limited' });
}
if (err instanceof HatchedError) {
return res.status(err.statusCode).json({
error: { code: err.code, message: err.message, requestId: err.requestId },
});
}
console.error(err);
res.status(500).json({ error: 'internal_error' });
});
```
## Graceful shutdown
If you're running long polls (`operations.wait`) during shutdown, pass an
`AbortSignal` so SIGTERM can cancel them cleanly:
```ts
const controller = new AbortController();
process.on('SIGTERM', () => controller.abort());
await hatched.operations.wait(op.operationId, { signal: controller.signal });
```
---
# Edge runtimes
> Run @hatched/sdk-js on Cloudflare Workers, Vercel Edge, Deno, and Bun — fetch overrides, AbortSignal, and the crypto caveat.
Source: https://docs.hatched.live/docs/guides/edge-runtimes
The SDK is written against native web standards — `fetch`, `Response`,
`AbortSignal`, `crypto.randomUUID` — so it runs unmodified on every modern
edge runtime.
## Cloudflare Workers
```ts
import { HatchedClient } from '@hatched/sdk-js';
export interface Env {
HATCHED_API_KEY: string;
}
export default {
async fetch(req: Request, env: Env): Promise {
const hatched = new HatchedClient({ apiKey: env.HATCHED_API_KEY });
const health = await hatched.health();
return Response.json(health);
},
};
```
The server-only guard passes because Workers don't have a `window` or
`document` global.
### Webhooks on Workers
`WebhooksResource.verifySignature` uses `node:crypto`, which **does not**
run on Workers. For Workers, verify manually with Web Crypto:
```ts
async function verify(body: string, header: string, secret: string): Promise {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const ts = parts.t;
const sig = parts.v1;
if (!ts || !sig) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const expected = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${ts}.${body}`),
);
const expectedHex = Array.from(new Uint8Array(expected))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return timingSafeEqualHex(expectedHex, sig);
}
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
```
## Vercel Edge
```ts
// app/api/hatched/health/route.ts
export const runtime = 'edge';
import { HatchedClient } from '@hatched/sdk-js';
export async function GET() {
const hatched = new HatchedClient({ apiKey: process.env.HATCHED_API_KEY! });
return Response.json(await hatched.health());
}
```
For webhook verification with `node:crypto`, switch to
`runtime = 'nodejs'`. Everything else (reads, event sends, widget session
mint) works on Edge.
## Deno
```ts
import { HatchedClient } from 'npm:@hatched/sdk-js';
const hatched = new HatchedClient({ apiKey: Deno.env.get('HATCHED_API_KEY')! });
console.log(await hatched.health());
```
## Bun
```ts
import { HatchedClient } from '@hatched/sdk-js';
const hatched = new HatchedClient({ apiKey: Bun.env.HATCHED_API_KEY! });
```
## Custom fetch override
Pass your own `fetch` — useful for:
- Preflighting every request through a telemetry hop
- Forcing a specific outbound pool on Workers (`fetch(input, { cf: {...} })`)
- Injecting retries via a shared HTTP client
```ts
const hatched = new HatchedClient({
apiKey: env.HATCHED_API_KEY,
fetch: async (input, init) => {
const start = Date.now();
const res = await fetch(input, init);
metrics.record('hatched.http', Date.now() - start);
return res;
},
});
```
## Cancellation
`AbortSignal.any` is used internally to combine your signal with the SDK
timeout. If your runtime doesn't have `AbortSignal.any` (very old
environments), the SDK falls back to a manual combinator — no action
needed on your side.
---
# Browser usage (publishable keys)
> Use @hatched/sdk-js in the browser with a publishable key — read buddies, mint read-only embed tokens, no server round-trip.
Source: https://docs.hatched.live/docs/guides/browser-usage
For pages where you only need to **read** buddy state or **mint read-only
embed tokens**, a publishable key lets you talk to Hatched directly from the
browser — no server endpoint of your own, no secret-key leak risk.
## Decide: is this the right tool?
| You want to... | Use |
| -------------------------------------- | ------------------------------------------ |
| Read buddy state on a static site | Publishable key with `read:buddies` |
| Show a read-only buddy widget | Mint an embed token with a publishable key |
| Let a user buy/equip/track in widgets | Widget session minted by your backend |
| Send events (`lesson_completed`, etc.) | Secret key on your server |
| Spend coins, equip items via API | Secret key on your server |
| Manage webhook configs | Secret key on your server |
If you need mutations, keep your secret key on the server. See
[Auth model](/docs/concepts/auth-model).
## Create a publishable key
Dashboard → Developers → API keys → **Create publishable key**.
- Pick a label.
- Confirm the scopes (default: `read:operations` + `write:embed-tokens`).
- Copy the `hatch_pk_*` value. Unlike secret keys, it's safe to put in
client-side config (`NEXT_PUBLIC_*`, `` tags, etc.).
## Initialise the client in the browser
```ts
import { HatchedClient } from '@hatched/sdk-js';
const hatched = new HatchedClient({
publishableKey: process.env.NEXT_PUBLIC_HATCHED_PK!,
// Set this for staging publishable keys.
baseUrl: process.env.NEXT_PUBLIC_HATCHED_API_BASE_URL,
});
```
The server-only runtime guard is disabled for publishable-key clients,
so this works in a React client component, a Vite SPA, a static
landing page, anywhere.
## Read buddy state
```tsx
'use client';
import { useEffect, useState } from 'react';
import { HatchedClient, type Buddy } from '@hatched/sdk-js';
const hatched = new HatchedClient({
publishableKey: process.env.NEXT_PUBLIC_HATCHED_PK!,
});
export function BuddyBadge({ buddyId }: { buddyId: string }) {
const [buddy, setBuddy] = useState(null);
useEffect(() => {
hatched.buddies.get(buddyId).then(setBuddy).catch(console.error);
}, [buddyId]);
if (!buddy) return null;
return (
);
}
```
## Mint a read-only embed token in the browser
Publishable keys can mint their own embed tokens — no server endpoint
needed:
```ts
const embed = await hatched.embedTokens.create({
buddyId: 'bdy_abc',
userId: currentUserId,
ttlSeconds: 900,
});
// Render before calling init.
window.__HATCHED_WIDGET__?.init({ token: embed.token });
```
## What fails and what you'll see
Attempting a mutation from a publishable-key client fails **before** the
network call with `PublishableKeyScopeError` — no latency cost:
```ts
import { PublishableKeyScopeError } from '@hatched/sdk-js';
try {
await hatched.events.send({ ... }); // server-only
} catch (err) {
if (err instanceof PublishableKeyScopeError) {
console.error('move this call to your backend with HATCHED_API_KEY');
}
}
```
If you somehow bypass the SDK and hit the API directly, you'll get
`403 publishable_key_scope` with the same semantic.
## Rotation
Publishable keys rotate like secret keys — Dashboard → Developers → API
keys → revoke. Revocation is instant; browser sessions error on their
next call and you deploy a new `NEXT_PUBLIC_HATCHED_PK`.
## Try it
Head to the [Playground](/playground) — paste a publishable key, hit
"Get buddy", see the response inline.
---
# Best practices
> Patterns for a Hatched integration that scales — designing the economy, sending events safely, handling webhooks reliably, and staying multi-tenant clean.
Source: https://docs.hatched.live/docs/guides/best-practices
The other guides show you *how* to call each piece. This one is the set of
decisions that keep an integration healthy once real users are on it.
## Design the economy so it rewards engagement, not grinding
Coins, tokens, and skills are knobs you tune in the dashboard — the failure mode
is making the most-repeated action the most rewarding one, which trains users to
spam it.
- **Reward outcomes, not raw volume.** `lesson_completed` with a passing score
beats `button_clicked`. If an event is cheap to trigger, give it a small
reward or none.
- **Cap the repeatable stuff.** Use per-event-type daily caps on coin rules so
the tenth repetition of the same action doesn't pay like the first. See
[Configure rules](/docs/guides/configure-rules).
- **Price the marketplace against earn rate.** A user earning ~50 coins/day
should be a few days away from the cheapest desirable item, not minutes and
not months. Re-check pricing whenever you change coin rules — both live on the
same [config version](/docs/concepts/config-versions).
- **Keep skills few.** More than ~8 skills crowds the widget and dilutes each
one's meaning. Pick the dimensions a user would actually recognise.
- **Use streaks for habit, badges for milestones.** A streak says "you showed up
again"; a badge says "you did the thing". Don't award a badge for something
that happens daily — that's a streak.
## Send events safely
`POST /events` is the hot path. Two rules:
- **Always pass a stable, meaningful `eventId`.** It's the idempotency key —
resending the same `eventId` is a guaranteed no-op. Derive it from your own
domain (`lesson_42:user_7`, `order_8891`), never from `Date.now()` or a fresh
UUID on retry, or you'll double-count on every network blip.
- **Don't block your product on Hatched.** Send events from a queue / background
job, not inline in the request that the user is waiting on. A slow or failed
`events.send` should never degrade your own UX. The SDK already retries
transient failures with backoff; if it ultimately throws, log it and move on —
the `eventId` makes a later replay safe.
Unknown `type` values are fine — Hatched stores them as custom counters and
never drops them — so you can ship new event types before configuring rules for
them. See [Send events](/docs/guides/send-events).
## One identity space per audience
`userId` is *your* identifier, opaque to Hatched. If you run multiple
[audiences](/docs/concepts/audiences) (kids vs. adults, free vs. paid), make
sure a given `userId` means the same person everywhere — don't recycle ids
across audiences, and don't let two of your tenants collide in one Hatched
customer unless you actually want a shared economy. Each buddy is pinned to one
audience for its lifetime; pick the audience at egg creation.
## Pick the right credential for where the code runs
- **Server code** → secret key (`hatch_live_*` / `hatch_test_*`) in an env var.
The SDK throws if you instantiate it in a browser; don't work around that.
- **Browser code that needs to track events or browse the marketplace** → mint a
short-lived, scoped **widget session token** on your server
(`hatched.widgetSessions.create({ buddyId, userId, scopes, ttlSeconds })`) and
hand the token to the client.
- **Browser code that only reads buddy state** → a publishable key
(`hatch_pk_*`) is enough.
Never ship a secret key in a bundle, a `NEXT_PUBLIC_*` var, or a mobile app.
Full decision tree: [Auth model](/docs/concepts/auth-model).
## Handle webhooks like a payment provider would
Webhooks are at-least-once, so treat them the way you'd treat Stripe events:
1. **Verify the HMAC signature against the raw body before parsing JSON.** See
[Handle webhooks](/docs/guides/handle-webhooks).
2. **Dedupe on `deliveryId`.** Persist processed ids; a repeat delivery is a
no-op.
3. **Return `2xx` fast.** Do the heavy work asynchronously — a slow handler gets
retried and looks like a failure.
4. **Make handlers idempotent.** Combined with dedup, replays (manual or
automatic) are safe.
Payload shapes: [Webhook payloads](/docs/reference/webhook-payloads).
## Wait on operations, don't poll
Image-producing calls — hatch, evolve, equip — return an `operationId`. Use
`hatched.operations.wait(operationId)` (it long-polls efficiently) instead of
calling `operations.get` in a `setInterval`. The stage transition and ledger
writes are already committed atomically by the time the operation completes; the
operation is only telling you whether the *visual* is also done. Check
`buddy.appearance.status` for that — see [Compositing & stages](/docs/concepts/compositing-and-stages).
## Log the request id
Every API response and webhook payload carries a `requestId` (`X-Request-Id`).
Log it next to your own correlation id. When something goes wrong, that single
value lets us trace the request end to end — it's the fastest path to a fix.
## Related
- [Configure rules](/docs/guides/configure-rules) — where you tune the economy.
- [Send events](/docs/guides/send-events) — the event contract in detail.
- [Handle webhooks](/docs/guides/handle-webhooks) — verification and retries.
- [Troubleshooting](/docs/guides/troubleshooting) — when something's already broken.
---
# Troubleshooting
> Reproduce common failures and the exact fix for each — 401, 429, validation errors, image errors, and widget mount issues.
Source: https://docs.hatched.live/docs/guides/troubleshooting
If something looks broken, start here. Each section has the **signal** (what
you'd see in your logs or UI), **why** it happens, and the **fix**.
> **First-run / widget-bootstrap problems** — `property userId should not exist`,
> `buddy_id must be a UUID`, `property scopes should not exist`, `Customer must
> have a published config version`, `User already has N active egg(s)`,
> `/widget/streak/` 404 while the dashboard shows it, hatch hanging for
> 20–45s, `widget_sessions_token_hash_key` collision — all of those have a
> cause-and-fix row in the [First user bootstrap pitfalls table](/docs/guides/first-user-bootstrap#common-pitfalls).
## 401 Unauthorized
**Signal.** `UnauthorizedError: Unauthorized` from the SDK, or raw
`{ "error": { "code": "unauthorized" } }` from `curl`.
**Why.** The API didn't accept your key. Usually one of:
- The key was rotated but the env variable wasn't updated.
- A production key is used against the test base URL (or vice versa).
- The key has been revoked from Dashboard → Developers → API keys.
- The `Authorization` header was dropped by an edge proxy.
**Fix.**
```ts
// Log what the SDK is actually sending (SAFELY — no logging of the key itself)
console.log('[hatched] key prefix:', process.env.HATCHED_API_KEY?.slice(0, 11));
// Should print: "hatch_live_" or "hatch_test_"
```
Rotate via Dashboard → Developers → API keys and redeploy.
## 429 Too Many Requests
**Signal.** `RateLimitError: Rate limit exceeded. Retry after 60s`. Header
`Retry-After` on the raw response.
**Why.** Your customer is over the per-minute quota for the endpoint. Most
commonly: tight loops calling `events.send` without batching, or
`buddies.list` paginating without a cursor.
**Fix.**
- Let the SDK's built-in retry handle spikes (`maxRetries: 3` by default).
- For bulk imports, use `hatched.events.sendBatch([...])` and chunk by 100.
- If you're consistently near the ceiling, Dashboard → Plan → upgrade.
```ts
try {
await hatched.events.send({ ... });
} catch (err) {
if (err instanceof RateLimitError) {
// Last-resort manual backoff
await new Promise((r) => setTimeout(r, err.retryAfter * 1000));
}
}
```
## 422 validation_failed
**Signal.** `ValidationError: Validation failed` with a `details` payload
listing field-level issues.
**Why.** A field is missing, the wrong type, or violates a business rule
(e.g. event `type` not registered, `eventId` collision).
**Fix.** Log `err.details`:
```ts
catch (err) {
if (err instanceof ValidationError) {
console.error('fields:', JSON.stringify(err.details, null, 2));
}
}
```
Typical shape:
```json
{
"fields": [{ "path": "properties.score", "message": "must be a number" }]
}
```
## 502 upstream_image_error
**Signal.** `UpstreamImageError: Image generation failed`.
**Why.** The art provider behind hatch/evolve is currently throwing (usually
a model-host incident). The buddy state in Hatched is fine — only the art
job failed.
**Fix.** Re-call the operation:
```ts
const op = await hatched.eggs.hatch(egg.eggId);
try {
await hatched.operations.wait(op.operationId);
} catch (err) {
if (err instanceof UpstreamImageError) {
// Safe to retry — the egg is still ready, no ledger writes
const retry = await hatched.eggs.hatch(egg.eggId);
await hatched.operations.wait(retry.operationId);
}
}
```
`hatched.eggs.hatch` is idempotent — the second call returns the same
operation id if the first one is still in flight.
## 403 publishable_key_scope
**Signal.** `PublishableKeyScopeError: Publishable key is not authorised
for this operation`.
**Why.** You used a `hatch_pk_*` browser-safe key to call a mutation
endpoint (e.g. `events.send`, `buddies.earn`). Publishable keys are
read-only + embed-token mint.
**Fix.** Move the call server-side with a secret `hatch_live_*` key. See
[Auth model](/docs/concepts/auth-model).
## 403 Origin not allowed for widget access
**Signal.** Widget API requests fail with:
```json
{
"error": {
"code": "forbidden",
"message": "Origin \"http://localhost:4002\" is not allowed for widget access"
}
}
```
**Why.** The widget token is valid, but this browser origin is not in the
customer's widget origin allowlist. The origin decision is read from customer
settings at request time; it is not stored inside the embed or session token.
**Fix.** Add the browser origin in Dashboard → Settings → General → Widget
allowed origins. Onboarding automatically seeds the origin from the pasted
website URL, but local development and staging app origins may need explicit
entries such as `http://localhost:4002`.
If the origin is already listed and the response is still 403, check that:
- The token was minted for the same Hatched environment you edited
(`api.staging.hatched.live` vs `api.hatched.live`).
- The token belongs to the same customer/workspace whose settings you saved.
- The value is an origin only (`https://app.example.com`), not a path
(`https://app.example.com/app`).
## SDK throws "server-only" on construction
**Signal.** `Error: Hatched SDK is server-only when initialised with a
secret key`.
**Why.** You instantiated `HatchedClient({ apiKey })` in a browser bundle
(a `"use client"` component, a static HTML page, etc.).
**Fix.** One of:
- Move the call to an API route / route handler / edge function.
- Mint a [widget session token](/docs/guides/widget-integration)
server-side and pass that to the browser.
- Use a [publishable key](/docs/concepts/auth-model) for browser reads.
## Widget won't mount
**Signal.** The `
` stays empty. No
network requests in DevTools.
**Why.** Checklist:
1. The `
```
## Event was ingested but no effects fired
**Signal.** `effects.coins === undefined` or empty; nothing moved.
**Why.** No coin rule, badge rule, or skill rule currently matches the
`type` you sent.
**Fix.** Dashboard → Developers → Event log → click the event →
**Evaluation trace** shows which rules were considered and why none fired.
Typical mismatches:
- Event `type` doesn't match any rule (typo: `lesson_complete` vs
`lesson_completed`).
- Rule is on draft, not published.
- `audience` filter excludes this user.
- Buddy is on an older config version that doesn't contain the new rule —
migrate via Dashboard → Buddies → Migration.
## Appearance update stuck or needs rerender
**Signal.** Marketplace equip/unequip is disabled, the widget shows an
appearance banner, or the SDK returns a conflict with `code: 'needs_rerender'`.
The buddy response has `appearance.status` as `pending`, `awaiting_credits`, or
`failed`.
**Why.** Outfit changes and evolution render a new image composite over
`base_image_url`. That render may still be queued, waiting for image credits, or
blocked because an older buddy image was migrated from a contaminated composite
and needs a clean bare stage.
**Fix.**
- For `pending`, wait for `/widget/state` or `operations.wait(...)` to report
completion.
- For `awaiting_credits`, add credits or wait for the scheduled retry.
- For `failed` with `error.code === 'needs_rerender'`, call
`hatched.buddies.rerenderAppearance(buddyId)` or
`POST /widget/appearance/rerender`, wait for `ready`, then re-equip the
desired items.
## Support
Include these four things in every support ticket:
- **Request id** from `hatched.getLastRequestId()` or the `X-Request-Id`
response header.
- SDK version (`@hatched/sdk-js` in your lockfile).
- Minimal reproduction — the five lines of code, not the whole file.
- What you expected vs what happened.
---
# HTTP API
> Lean API contract and state machines — V1 scope, endpoints, authentication, and the business processes behind each operation.
Source: https://docs.hatched.live/docs/reference/http-api
> Most integrations should use [`@hatched/sdk-js`](/docs/reference/sdk-js)
> rather than calling these endpoints directly — it handles auth, retries,
> idempotency, error parsing, and edge runtimes for you. Reach for raw HTTP
> only when there's no SDK for your language.
## Quick reference
| | |
| --- | --- |
| **Base URL (production)** | `https://api.hatched.live/api/v1` |
| **Base URL (staging)** | `https://api.staging.hatched.live/api/v1` |
| **Auth** | `Authorization: Bearer ` — secret key (`hatch_live_*` / `hatch_test_*`, server-only) or publishable key (`hatch_pk_*`, limited reads). Widgets pass a short-lived session token instead. See [Auth model](/docs/concepts/auth-model). |
| **Casing** | The raw HTTP API uses **`snake_case`** for every request and response field (`user_id`, `buddy_id`, `ttl_seconds`). Sending camelCase is rejected (`property userId should not exist`). [`@hatched/sdk-js`](/docs/reference/sdk-js) is the only place you write camelCase — it converts to `snake_case` on the wire and back on responses. SDK code samples (` ```ts `) use camelCase; raw HTTP samples (` ```http ` / curl) use `snake_case`. (The error envelope below is the one camelCase exception — `requestId`, not `request_id`.) |
| **First-run flow** | Minting a widget token requires an existing `buddy_id` — you can't go from `user_id` straight to a session token. The full path (published config → reuse-or-create egg → ready → hatch → poll operation → persist `buddy_id` → `POST /widget-sessions`) is in [First user bootstrap](/docs/guides/first-user-bootstrap). |
| **Errors** | Always the canonical envelope `{ "error": { "code", "message", "details?", "requestId" } }`. Codes are stable — branch on `code`, not `message`. See [Error codes](/docs/reference/error-codes). |
| **Request correlation** | Every request echoes an `X-Request-Id` header; it also appears in the error envelope, your logs, and outgoing webhook payloads. Include it in support requests. |
| **Idempotency** | `POST /events` dedupes on the `event_id` you supply — resending is a no-op. Other writes are not automatically idempotent; don't blind-retry them, and guard `POST /eggs` against React Strict Mode / focus re-runs (see the bootstrap guide). |
| **Async work** | Image-producing calls (hatch, evolve, equip) return an `operationId`. Poll `GET /operations/{id}` or use `operations.wait(id)` in the SDK. Don't tight-loop. |
| **Pagination** | List endpoints return either `{ data, nextCursor }` (cursor-based — pass `cursor`) or `{ data, meta: { page, limit, total } }` (page-based — pass `page`). The field present on the response tells you which. |
| **Rate limits** | Per-key quotas; `429` responses carry `Retry-After` and `X-RateLimit-*` headers. The SDK retries with backoff by default. See [Rate limits](/docs/reference/rate-limits). |
| **Billing** | `402` with `code: 'credit_insufficient'`, `event_quota_exceeded`, or `plan_feature_locked` when you hit a billing limit. See [Handling 402](/docs/billing/handling-402). |
The rest of this page is the V1 product contract — scope, state machines, and
the business processes behind each operation. The full machine-generated
endpoint list is in [Endpoints](#endpoints) below.
---
**Goal:** Position Hatched as a narrow-scope product that does one thing exceptionally well, rather than an "enterprise does-everything" platform.
This document clarifies three things:
1. Which business processes V1 definitively supports
2. Which state machines make the system deterministic
3. How the API contracts stay lean and easy to integrate
---
## 1. Product Philosophy
V1 targets for Hatched:
- Not a "platform" where the customer designs their own game
- A service that reliably produces buddy progression from the customer's existing product events
That's why V1 follows these principles:
- **Template-first**: limited rule types instead of a free-form rule language
- **Publish-before-live**: progression config changes are edited in draft, then published
- **Async-by-default**: visual-producing jobs are tracked via operations
- **Read vs write separation**: easy for widgets to read, tighter controls on mutations
- **Canonical state lives in Hatched**: customers may keep a local copy, but Hatched is the source of truth
---
## 2. V1 Scope
### 2.1 Definitely in V1
- Preset plan selection and customization
- Skill set definition
- Coin rules
- Badge rules
- Evolution readiness and evolution trigger
- Item marketplace
- Buddy widget
- Marketplace widget
- Event ingestion
- Webhook delivery
- Multi-buddy support
### 2.2 Not in V1
- Full no-code workflow builder
- Unlimited rule engine with if/else trees
- Cross-customer shared economy
- Buddy-to-buddy social graph
- Full user-level CRM
- Real-time multiplayer / competition engine
- Arbitrary CSS/JS execution on the customer side
- Custom approval flows tailored per customer need
### 2.3 Deliberately limited in V1
- Rule types are picked from fixed enums
- Widget theme is configurable but not an infinite design surface
- Evolution capped at 5 stages
- Token types start with a limited set of system tokens
- Marketplace visibility and requirement logic ships with predefined operators
---
## 3. User-Friendly Business Processes
### 3.1 Customer onboarding
Goal: ship an integration that's live in 10 minutes.
Flow:
1. Customer creates an account
2. Picks a preset plan
3. Lightly edits skill, badge, coin and evolution fields
4. Publishes the draft
5. Receives an API key
6. Sends the first `POST /events` request
7. Generates a widget token and embeds it
UI principles:
- First screen exposes at most 3 major decisions: preset, style, widget theme
- "Advanced" fields are collapsed by default in every editor
- No "empty page" feeling; the starter config from the preset is always visible
### 3.2 Progression config change
Goal: make changes without disturbing existing users' balance.
Flow:
1. Customer opens the draft config
2. Edits skill/badge/coin/evolution fields
3. System shows an impact summary:
- affects new buddies
- does not affect existing buddies
- migration can be run separately on demand
4. Customer publishes
5. New `config_version` becomes active
UI principles:
- "Save" and "Publish" separation must be obvious
- Publish modal uses impact language, not technical jargon
- "This change does not retroactively affect existing buddies" must be clearly shown
### 3.3 Event-driven reward generation
Goal: the customer only sends the event; Hatched computes the reward.
Flow:
1. Customer sends the event via `POST /events`
2. Hatched deduplicates the event
3. Rule engine computes effects
4. Writes coin/token/badge/streak/evolution_ready effects
5. Emits a webhook if needed
UI principles:
- Dashboard shows "recent events" and "generated effects" side by side
- Debug screen gives a clear answer to "why didn't this event produce a reward?"
### 3.4 Hatch / equip / evolve
Goal: make visual-producing flows deterministic and understandable.
Flow:
1. Customer or widget initiates an action
2. API returns an `operation_id`
3. Worker finishes the job
4. Result arrives via webhook or polling
UI principles:
- "Processing..." is a first-class state
- Error messages are action-specific, not generic "Image generation failed"
- The user must not retry the same action in a panic
### 3.5 Widget access
Goal: easy to integrate, but controlled on the write side.
Modes:
- **Read-only embed**: buddy widget, leaderboard
- **Interactive session**: marketplace purchase, equip item
UI principles:
- Embed snippet must be short
- Token generation should feel like a "copy-paste snippet" experience
- A read-only embed should require the minimum possible backend integration
---
## 4. Canonical Domain Concepts
### 4.1 Customer
The B2B tenant that uses Hatched.
Owns:
- plan
- settings
- active_config_version_id
### 4.2 ConfigVersion
Immutable snapshot of progression logic.
Contains:
- skill set
- coin rules
- badge definitions
- evolution rules
- token rules
- marketplace requirements
### 4.3 Egg
The pending object before a buddy is born.
Rules:
- bound to a specific user
- created with a config_version
- transitions to a closed state once hatched
### 4.4 Buddy
A user-owned progression unit.
Rules:
- a user can own multiple buddies
- a buddy is pinned to a single config_version
- its version does not change unless migration happens
### 4.5 EventIngestion
The recorded external domain event from a customer.
Rules:
- `customer_id + event_id` is unique
- the same event does not produce a reward twice
### 4.6 EconomyLedger
Immutable ledger of coin/token mutations.
Rules:
- each row is a credit or a debit
- balance is a computed/projection field
- mutation endpoints write to the ledger
### 4.7 Operation
Record for tracking an asynchronous job.
Types:
- hatch
- equip_item
- evolve
---
## 5. State Machines
### 5.1 ConfigVersion State Machine
States:
- `draft`
- `published`
- `archived`
Transitions:
- `draft -> published`
- `published -> archived`
Rules:
- only one `published` version may be active
- publish creates a new version; it never mutates an existing one
### 5.2 Egg State Machine
States:
- `waiting`
- `ready`
- `hatching`
- `hatched`
- `cancelled`
Transitions:
- `waiting -> ready`
- `ready -> hatching`
- `hatching -> hatched`
- `waiting -> cancelled`
- `ready -> cancelled`
Rules:
- `hatch` may only be initiated from `ready`
- `hatching` persists until the operation completes
### 5.3 Buddy State Machine
The top-level buddy state is kept simple:
- `active`
- `archived`
A buddy's real variables are:
- `evolution_stage`
- `skills`
- `coins`
- `tokens`
- `equipped_items`
Rules:
- an attribute-based model is preferred over a progression state machine
- this keeps the UI simpler
### 5.4 Operation State Machine
States:
- `pending`
- `processing`
- `completed`
- `failed`
- `cancelled`
Transitions:
- `pending -> processing`
- `processing -> completed`
- `processing -> failed`
- `pending -> cancelled`
Rules:
- the client never treats the result as final until it sees `completed/failed`
### 5.5 WidgetSession State Machine
States:
- `issued`
- `active`
- `expired`
- `revoked`
Rules:
- read-only tokens may be longer-lived
- interactive tokens must be short-lived
- interactive tokens operate on scopes
---
## 6. Lean API Contract
### 6.1 Public integration endpoints
#### `POST /api/v1/eggs`
Purpose:
- create a pending egg record for a user
Query params:
- `ensure=true` — return this user's most recent `waiting`/`ready` egg if one
already exists instead of creating a new one (idempotent first-run bootstrap;
also dodges the per-user active-egg cap on retries).
Request:
```json
{
"user_id": "user_123",
"metadata": {
"age": 12,
"level": "beginner"
}
}
```
Response:
```json
{
"egg_id": "egg_abc",
"status": "waiting",
"visual_variant": 7,
"config_version_id": "cfg_v12",
"user_id": "user_123",
"buddy_id": null,
"metadata": { "age": 12, "level": "beginner" },
"created_at": "2026-04-08T10:30:00Z"
}
```
`buddy_id` is `null` until the egg reaches `status: "hatched"`, after which it
carries the hatched buddy's id (same on `GET /api/v1/eggs/{egg_id}` and
`GET /api/v1/eggs`).
Errors:
- `409 no_published_config` — the customer has no published config version yet;
`details.publish_url` links to the dashboard publish page.
- `409 active_egg_limit` — the per-user active-egg cap is reached; `details.active`
lists the existing eggs (`egg_id`, `status`, `created_at`) and `details.max` the
cap. Hatch/cancel one, or retry with `?ensure=true`.
#### `PATCH /api/v1/eggs/{egg_id}/status`
Purpose:
- mark the egg `ready` or cancel it
Allowed statuses:
- `ready`
- `cancelled`
#### `POST /api/v1/eggs/{egg_id}/hatch`
Purpose:
- kick off an async hatch operation
Response:
```json
{
"accepted": true,
"operation_id": "op_123",
"status": "pending"
}
```
#### `POST /api/v1/events`
Purpose:
- ingest a customer domain event
Request:
```json
{
"event_id": "evt_789",
"user_id": "user_123",
"type": "lesson_completed",
"occurred_at": "2026-04-08T10:30:00Z",
"properties": {
"lesson_id": "lesson_456",
"score": 87
}
}
```
Response:
```json
{
"accepted": true,
"event_id": "evt_789",
"effects": {
"coins": 10,
"badges_awarded": [],
"tokens": [],
"evolution_ready": false
}
}
```
#### `GET /api/v1/buddies/{buddy_id}`
Purpose:
- return canonical buddy state
#### `POST /api/v1/buddies/{buddy_id}/evolve`
Purpose:
- start an async evolution operation
#### `PATCH /api/v1/buddies/{buddy_id}/equipped-items`
Purpose:
- start an equip/unequip operation
#### `GET /api/v1/operations/{operation_id}`
Purpose:
- read the status of a hatch/equip/evolve
### 6.2 Manual override endpoints
These endpoints ship in V1 but are not marketed as the "primary flow":
- `PATCH /buddies/{id}/skills`
- `POST /buddies/{id}/coins`
- `POST /buddies/{id}/coins/spend`
- `POST /buddies/{id}/badges`
- `POST /buddies/{id}/tokens`
Use cases:
- admin correction
- migration
- special campaign
- support operation
### 6.3 Widget endpoints
#### `POST /api/v1/widget-sessions`
Purpose:
- mint an interactive widget session token
Request:
```json
{
"buddy_id": "bdy_abc123",
"scopes": ["marketplace:purchase", "items:equip"]
}
```
Response:
```json
{
"token": "wgt_sess_xxx",
"expires_at": "2026-04-08T11:00:00Z"
}
```
#### `POST /api/v1/embed-tokens`
Purpose:
- mint a signed token for a read-only widget
---
## 7. UX Guardrails for the Dashboard
### 7.1 Rule templates instead of a rule builder
The V1 panel must offer:
- Pick a trigger
- Pick a reward
- Pick a limit
- Preview impact
The V1 panel must NOT offer:
- nested if/else
- custom expression language
- event transformation DSL
- arbitrary JSON editor as the primary UX
### 7.2 Publish UX
Every progression editor follows this shape:
- Draft badge
- Unsaved changes indicator
- Publish CTA
- Impact summary
- "Publish a new version" instead of a rollback model
### 7.3 Support UX
Support/operator screens must surface:
- recent events
- effects generated from an event
- operation status
- the user's buddy list
- recent ledger activity
These screens exist for operational confidence, not enterprise complexity.
---
## 8. Business Process Decisions
### 8.1 What we keep centralized
Hatched owns the truth for:
- progression truth
- config version truth
- buddy ownership truth
- operation truth
- ledger truth
### 8.2 What we leave to the customer
The customer owns:
- event production
- user identity mapping
- when a product action emits an event
- maintaining the buddy ID list inside their own product UI
- which widget appears on which screen
### 8.3 What we deliberately don't build
- We do not move the customer's whole business logic into Hatched
- We are not a full BPM/workflow product
- We are not a CRM or engagement automation hub
- We do not build an infinitely configurable admin panel
---
## 9. Recommended V1 Motto
**"Customer events in, progression out."**
That's the strongest, simplest positioning for the product:
- The customer emits events
- Hatched computes progression
- The widget renders the result
Everything else is secondary.
## Endpoints
{/* ENDPOINTS_START */}
{/* AUTO-GENERATED from NestJS controllers by apps/docs/scripts/generate-api-reference.ts. */}
| Method | Path | Summary | Source |
|---|---|---|---|
| `GET` | `/api/v1/admin/audit` | Search admin audit log | [`apps/api/src/admin/admin-audit.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-audit.controller.ts) |
| `POST` | `/api/v1/admin/auth/login` | Admin login | [`apps/api/src/admin/admin-auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-auth.controller.ts) |
| `POST` | `/api/v1/admin/auth/logout` | Stateless logout (client should drop token) | [`apps/api/src/admin/admin-auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-auth.controller.ts) |
| `GET` | `/api/v1/admin/auth/me` | Current admin profile | [`apps/api/src/admin/admin-auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-auth.controller.ts) |
| `GET` | `/api/v1/admin/customers` | List customers (search/filter/paginate) | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `DELETE` | `/api/v1/admin/customers/:id` | Soft-delete a customer (sets deleted_at) | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `GET` | `/api/v1/admin/customers/:id` | Customer detail (counts, balances) | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `PATCH` | `/api/v1/admin/customers/:id` | Update customer profile (name/email/settings) | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `GET` | `/api/v1/admin/customers/:id/api-keys` | List customer API keys | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/api-keys/:keyId/revoke` | Revoke a customer API key | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/api-keys/publishable` | Issue a publishable key for the customer | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `GET` | `/api/v1/admin/customers/:id/billing` | Billing snapshot (DB + reconciled view) | [`apps/api/src/admin/admin-billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-billing.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/billing/cancel-subscription` | Cancel the customer Stripe subscription | [`apps/api/src/admin/admin-billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-billing.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/billing/refund` | Refund the most recent charge and (optionally) claw back credits | [`apps/api/src/admin/admin-billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-billing.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/billing/sync` | Reconcile DB columns from Stripe | [`apps/api/src/admin/admin-billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-billing.controller.ts) |
| `GET` | `/api/v1/admin/customers/:id/credits` | Credit balance + recent transactions | [`apps/api/src/admin/admin-credits.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-credits.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/credits/adjust` | Apply a signed manual credit adjustment | [`apps/api/src/admin/admin-credits.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-credits.controller.ts) |
| `PATCH` | `/api/v1/admin/customers/:id/event-quota` | Update monthly event quota counters | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `PATCH` | `/api/v1/admin/customers/:id/features` | Override per-customer feature flags | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/impersonate` | Issue a short-lived read-only dashboard JWT for the customer | [`apps/api/src/admin/admin-impersonate.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-impersonate.controller.ts) |
| `GET` | `/api/v1/admin/customers/:id/onboarding` | Onboarding submission | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `PATCH` | `/api/v1/admin/customers/:id/plan` | Override plan (no Stripe touch) | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `POST` | `/api/v1/admin/customers/:id/restore` | Restore a soft-deleted customer | [`apps/api/src/admin/admin-customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-customers.controller.ts) |
| `GET` | `/api/v1/admin/operations` | List operations with filters | [`apps/api/src/admin/admin-operations.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-operations.controller.ts) |
| `GET` | `/api/v1/admin/operations/:id` | Operation detail (request/result/error payloads) | [`apps/api/src/admin/admin-operations.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-operations.controller.ts) |
| `GET` | `/api/v1/admin/operations/queue-link` | Short-lived Bull-Board admin URL with token | [`apps/api/src/admin/admin-operations.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-operations.controller.ts) |
| `GET` | `/api/v1/admin/users` | List admin users (super only) | [`apps/api/src/admin/admin-users.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-users.controller.ts) |
| `POST` | `/api/v1/admin/users` | Create a new admin user (super only) | [`apps/api/src/admin/admin-users.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-users.controller.ts) |
| `DELETE` | `/api/v1/admin/users/:id` | Deactivate an admin user | [`apps/api/src/admin/admin-users.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-users.controller.ts) |
| `PATCH` | `/api/v1/admin/users/:id` | Update an admin user | [`apps/api/src/admin/admin-users.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/admin/admin-users.controller.ts) |
| `GET` | `/api/v1/analytics` | Get leaderboard | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/activity-summary` | Get activity summary | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/audiences` | Get audience breakdown | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/custom-events` | Get custom event trends | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/economy-health` | Get economy health | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/economy-summary` | Get economy summary | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/engagement` | Get engagement metrics | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/event-counts` | Per-event counts | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/evolution` | Get evolution timeline | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/marketplace-funnel` | Get marketplace funnel | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/overview` | Get analytics overview | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/popular-badges` | Get popular badges | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/popular-items` | Get popular items | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/retention` | Get retention metrics | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/roi-metrics` | Get ROI metrics | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/streaks` | Get streak health | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/tokens` | Get per-token economy | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/analytics/webhooks` | Get webhook delivery health | [`apps/api/src/analytics/analytics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/analytics/analytics.controller.ts) |
| `GET` | `/api/v1/auth/api-keys` | List all active API keys for the current customer | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `POST` | `/api/v1/auth/api-keys` | Create a new API key | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `DELETE` | `/api/v1/auth/api-keys/:id` | Revoke an API key by its ID | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `POST` | `/api/v1/auth/api-keys/rotate` | Rotate API keys by revoking all existing keys and creating a new one | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `POST` | `/api/v1/auth/login` | Authenticate and obtain a JWT token | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `GET` | `/api/v1/auth/me` | Get the currently authenticated customer profile | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `POST` | `/api/v1/auth/publishable-keys` | Issue a browser-safe publishable key (hatch_pk_*) with a scoped set of permissions. | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `POST` | `/api/v1/auth/register` | Register a new customer account | [`apps/api/src/auth/auth.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/auth/auth.controller.ts) |
| `GET` | `/api/v1/badge-definitions` | List all badge definitions | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `POST` | `/api/v1/badge-definitions` | Create a badge definition | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `DELETE` | `/api/v1/badge-definitions/:id` | Delete a badge definition | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `GET` | `/api/v1/badge-definitions/:id` | Get a badge definition | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `PUT` | `/api/v1/badge-definitions/:id` | Update a badge definition | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `POST` | `/api/v1/badge-definitions/:id/regenerate-icon` | Queue an AI regeneration for this badge icon | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `POST` | `/api/v1/badge-definitions/generate-icon` | Generate a badge icon with AI | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `POST` | `/api/v1/badge-definitions/upload-icon` | | [`apps/api/src/badge-definitions/badge-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/badge-definitions/badge-definitions.controller.ts) |
| `POST` | `/api/v1/billing/checkout` | Create checkout session | [`apps/api/src/billing/billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/billing/billing.controller.ts) |
| `POST` | `/api/v1/billing/override-plan` | Override customer plan | [`apps/api/src/billing/billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/billing/billing.controller.ts) |
| `POST` | `/api/v1/billing/portal` | Create billing portal session | [`apps/api/src/billing/billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/billing/billing.controller.ts) |
| `GET` | `/api/v1/billing/status` | Get billing status | [`apps/api/src/billing/billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/billing/billing.controller.ts) |
| `POST` | `/api/v1/billing/webhook` | Handle Stripe webhook | [`apps/api/src/billing/billing.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/billing/billing.controller.ts) |
| `GET` | `/api/v1/buddies` | List buddies with pagination and optional filters | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/appearance/rerender` | Regenerate the buddy stage base image | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/badges` | List all badges awarded to a buddy | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/badges` | Award a badge to a buddy | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/coins` | Earn coins for a buddy (supports idempotency) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/coins/spend` | Spend coins for a buddy (supports idempotency) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `PATCH` | `/api/v1/buddies/:buddy_id/equipped-items` | Equip or unequip items on a buddy | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/evolution` | Check evolution readiness and progress for a buddy | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/evolutions` | Stage transition timeline for a buddy | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/evolve` | Trigger asynchronous buddy evolution | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/gates/:gate_key/unlock` | Spend tokens to unlock a gate for a buddy | [`apps/api/src/gates/gates.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/gates/gates.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/progression` | Get buddy progression metrics (legacy endpoint) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/progression-metrics` | Get buddy progression metrics (lessons, quizzes, streaks, etc.) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/purchase-item` | Purchase a marketplace item using coins (supports idempotency) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/purchased-items` | List all purchased items for a buddy | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/tokens` | Typed token balances (primary + progression) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/tokens` | Earn or spend tokens for a buddy (supports idempotency) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `POST` | `/api/v1/buddies/:buddy_id/unlock-item` | Unlock an item without spending coins | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/:buddy_id/unlocks` | List gates this buddy has unlocked | [`apps/api/src/gates/gates.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/gates/gates.controller.ts) |
| `GET` | `/api/v1/buddies/:id` | Get buddy details with full canonical state | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `PATCH` | `/api/v1/buddies/:id` | Update a buddy name | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `PATCH` | `/api/v1/buddies/:id/archive` | Archive a buddy (one-way transition from active to archived) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `PATCH` | `/api/v1/buddies/:id/skills` | Update buddy skill levels (increase, decrease, or set) | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/buddies/users/:user_id/summary` | Get a user summary including buddy count, coins, and badges | [`apps/api/src/buddies/buddies.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/buddies.controller.ts) |
| `GET` | `/api/v1/coin-rules` | List all coin rules | [`apps/api/src/coin-rules/coin-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/coin-rules/coin-rules.controller.ts) |
| `POST` | `/api/v1/coin-rules` | Create a coin rule | [`apps/api/src/coin-rules/coin-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/coin-rules/coin-rules.controller.ts) |
| `DELETE` | `/api/v1/coin-rules/:id` | Delete a coin rule | [`apps/api/src/coin-rules/coin-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/coin-rules/coin-rules.controller.ts) |
| `PUT` | `/api/v1/coin-rules/:id` | Update a coin rule | [`apps/api/src/coin-rules/coin-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/coin-rules/coin-rules.controller.ts) |
| `GET` | `/api/v1/config-versions` | List config versions | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `POST` | `/api/v1/config-versions` | Create config version | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `GET` | `/api/v1/config-versions/:id` | Get config version | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `PATCH` | `/api/v1/config-versions/:id` | Update config version | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `POST` | `/api/v1/config-versions/:id/clone` | Clone config version | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `GET` | `/api/v1/config-versions/:id/impact` | Preview config impact | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `POST` | `/api/v1/config-versions/:id/migrate-buddies` | Migrate buddies | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `POST` | `/api/v1/config-versions/:id/publish` | Publish config version | [`apps/api/src/config-versions/config-versions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/config-versions/config-versions.controller.ts) |
| `GET` | `/api/v1/credits/balance` | Get credit balance | [`apps/api/src/credits/credits.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/credits/credits.controller.ts) |
| `GET` | `/api/v1/credits/ledger` | List recent AI usage ledger entries | [`apps/api/src/credits/credits.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/credits/credits.controller.ts) |
| `GET` | `/api/v1/customers/me` | | [`apps/api/src/customers/customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/customers/customers.controller.ts) |
| `PATCH` | `/api/v1/customers/me` | | [`apps/api/src/customers/customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/customers/customers.controller.ts) |
| `POST` | `/api/v1/customers/me/apply-preset` | Apply preset | [`apps/api/src/presets/presets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/presets/presets.controller.ts) |
| `POST` | `/api/v1/customers/me/assets/regenerate` | Bulk regenerate AI assets | [`apps/api/src/customers/customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/customers/customers.controller.ts) |
| `PATCH` | `/api/v1/customers/me/audiences` | Replace the customer audience list | [`apps/api/src/customers/customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/customers/customers.controller.ts) |
| `PATCH` | `/api/v1/customers/me/settings` | | [`apps/api/src/customers/customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/customers/customers.controller.ts) |
| `DELETE` | `/api/v1/customers/me/users/:user_id/data` | | [`apps/api/src/customers/customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/customers/customers.controller.ts) |
| `GET` | `/api/v1/customers/me/users/:user_id/summary` | | [`apps/api/src/buddies/customers-summary.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/buddies/customers-summary.controller.ts) |
| `POST` | `/api/v1/customers/me/widget-theme/suggest` | Suggest widget theme customization | [`apps/api/src/customers/customers.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/customers/customers.controller.ts) |
| `POST` | `/api/v1/demo/buddy/:id/evolve` | Evolve the demo visitor buddy | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `POST` | `/api/v1/demo/event` | Ingest an event for the current demo visitor | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `POST` | `/api/v1/demo/hatch` | Hatch a buddy for the current demo visitor (instant) | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `GET` | `/api/v1/demo/operations/:id` | Read the status of an async demo operation | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `POST` | `/api/v1/demo/reset` | Archive the current demo visitor buddy and reset state | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `POST` | `/api/v1/demo/session` | Start or resume a per-visitor demo session | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `GET` | `/api/v1/demo/state` | Read the demo visitor state (buddy + readiness + metrics) | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `GET` | `/api/v1/demo/widget-token` | Mint embed token for the public demo tenant (primary Fern) | [`apps/api/src/widget-sessions/demo.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/demo.controller.ts) |
| `GET` | `/api/v1/economy/buddies/:buddyId/ledger` | Get coin ledger for a buddy | [`apps/api/src/economy/economy.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/economy/economy.controller.ts) |
| `GET` | `/api/v1/eggs` | List eggs with optional user and status filters | [`apps/api/src/eggs/eggs.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/eggs/eggs.controller.ts) |
| `POST` | `/api/v1/eggs` | Create a new egg for a user | [`apps/api/src/eggs/eggs.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/eggs/eggs.controller.ts) |
| `GET` | `/api/v1/eggs/:id` | Get an egg by its ID | [`apps/api/src/eggs/eggs.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/eggs/eggs.controller.ts) |
| `POST` | `/api/v1/eggs/:id/hatch` | Start the asynchronous hatch process for an egg | [`apps/api/src/eggs/eggs.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/eggs/eggs.controller.ts) |
| `PATCH` | `/api/v1/eggs/:id/status` | Update an egg status to ready or cancelled | [`apps/api/src/eggs/eggs.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/eggs/eggs.controller.ts) |
| `POST` | `/api/v1/embed-tokens` | Create embed token | [`apps/api/src/widget-sessions/widget-sessions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-sessions.controller.ts) |
| `GET` | `/api/v1/event-types` | List event types | [`apps/api/src/event-types/event-types.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/event-types/event-types.controller.ts) |
| `POST` | `/api/v1/event-types` | Register an event type | [`apps/api/src/event-types/event-types.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/event-types/event-types.controller.ts) |
| `DELETE` | `/api/v1/event-types/:id` | Delete an event type | [`apps/api/src/event-types/event-types.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/event-types/event-types.controller.ts) |
| `GET` | `/api/v1/event-types/:id` | Get an event type | [`apps/api/src/event-types/event-types.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/event-types/event-types.controller.ts) |
| `PUT` | `/api/v1/event-types/:id` | Update or rename an event type | [`apps/api/src/event-types/event-types.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/event-types/event-types.controller.ts) |
| `GET` | `/api/v1/events` | List events | [`apps/api/src/events/events.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/events/events.controller.ts) |
| `POST` | `/api/v1/events` | Ingest event | [`apps/api/src/events/events.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/events/events.controller.ts) |
| `GET` | `/api/v1/events/:id` | Get event | [`apps/api/src/events/events.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/events/events.controller.ts) |
| `GET` | `/api/v1/events/active-users` | List most-active users in a recent window | [`apps/api/src/events/events.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/events/events.controller.ts) |
| `POST` | `/api/v1/events/batch` | Ingest event batch | [`apps/api/src/events/events.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/events/events.controller.ts) |
| `GET` | `/api/v1/events/types` | List distinct event types | [`apps/api/src/events/events.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/events/events.controller.ts) |
| `GET` | `/api/v1/gates` | List token gates for this customer | [`apps/api/src/gates/gates.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/gates/gates.controller.ts) |
| `DELETE` | `/api/v1/gates/:gate_key` | Delete a token gate | [`apps/api/src/gates/gates.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/gates/gates.controller.ts) |
| `PUT` | `/api/v1/gates/:gate_key` | Create or update a token gate | [`apps/api/src/gates/gates.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/gates/gates.controller.ts) |
| `GET` | `/api/v1/health` | Health check | [`apps/api/src/health/health.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/health/health.controller.ts) |
| `GET` | `/api/v1/health/live` | Liveness check | [`apps/api/src/health/health.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/health/health.controller.ts) |
| `GET` | `/api/v1/health/ready` | Readiness check | [`apps/api/src/health/health.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/health/health.controller.ts) |
| `GET` | `/api/v1/image-usage` | Get image usage | [`apps/api/src/images/image-cost.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/images/image-cost.controller.ts) |
| `GET` | `/api/v1/image-usage/report` | Get image usage report | [`apps/api/src/images/image-cost.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/images/image-cost.controller.ts) |
| `GET` | `/api/v1/marketplaces` | List marketplaces | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `POST` | `/api/v1/marketplaces` | Create marketplace | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `GET` | `/api/v1/marketplaces/:id` | Get marketplace | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `PUT` | `/api/v1/marketplaces/:id` | Update marketplace | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `GET` | `/api/v1/marketplaces/:id/items` | List items | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `POST` | `/api/v1/marketplaces/:id/items` | Create item | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `DELETE` | `/api/v1/marketplaces/:id/items/:item_id` | Delete item | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `GET` | `/api/v1/marketplaces/:id/items/:item_id` | Get item | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `PUT` | `/api/v1/marketplaces/:id/items/:item_id` | Update item | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `POST` | `/api/v1/marketplaces/:id/items/:item_id/regenerate-image` | Queue an AI regeneration for this item image | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `POST` | `/api/v1/marketplaces/:id/items/:item_id/upload-image` | Upload item image | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `POST` | `/api/v1/marketplaces/:id/items/import` | Import items | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `POST` | `/api/v1/marketplaces/:id/items/reorder` | Reorder items | [`apps/api/src/marketplace/marketplace.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/marketplace/marketplace.controller.ts) |
| `GET` | `/api/v1/metrics` | Get Prometheus metrics | [`apps/api/src/health/metrics.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/health/metrics.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions` | Create or resume the current onboarding session | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `PUT` | `/api/v1/onboarding/sessions/:id/answers` | Patch structured onboarding answers | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/:id/apply` | Apply the generated plan to the customer (writes gamification config) | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/:id/generate-guide` | Generate a personalized integration guide | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/:id/generate-plan` | Generate a gamification plan from the conversation | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/:id/message` | Send a user message and stream the assistant reply via server-sent events | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/:id/regenerate-plan` | Regenerate the plan with a variant seed | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `GET` | `/api/v1/onboarding/sessions/current` | Fetch the current onboarding session | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `GET` | `/api/v1/onboarding/sessions/preparing-status` | Aggregate asset-generation status for the current customer | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/reset` | Reset the current onboarding session | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/seed-from-repo` | Seed onboarding from a repo-analysis brief produced by a local AI agent | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/seed-from-url` | Seed onboarding from a landing-page URL | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `POST` | `/api/v1/onboarding/sessions/waitlist` | Join the waitlist for an upcoming onboarding channel | [`apps/api/src/onboarding/onboarding.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/onboarding/onboarding.controller.ts) |
| `GET` | `/api/v1/operations` | List operations with optional type and status filters | [`apps/api/src/operations/operations.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/operations/operations.controller.ts) |
| `GET` | `/api/v1/operations/:id` | Get an operation by its ID | [`apps/api/src/operations/operations.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/operations/operations.controller.ts) |
| `POST` | `/api/v1/operations/:id/cancel` | Cancel a pending or processing operation | [`apps/api/src/operations/operations.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/operations/operations.controller.ts) |
| `GET` | `/api/v1/path-definitions` | List path definitions | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `POST` | `/api/v1/path-definitions` | Create a path definition | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `DELETE` | `/api/v1/path-definitions/:id` | Delete a path definition | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `GET` | `/api/v1/path-definitions/:id` | Get a path definition (with steps + sub-steps) | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `PUT` | `/api/v1/path-definitions/:id` | Update a path definition | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `POST` | `/api/v1/path-definitions/:id/activate` | Activate a path (atomic single-active per audience) | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `POST` | `/api/v1/path-definitions/:id/deactivate` | Deactivate a path | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `GET` | `/api/v1/path-definitions/:id/steps` | List steps in a path | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `POST` | `/api/v1/path-definitions/:id/steps` | Create a step in a path | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `DELETE` | `/api/v1/path-definitions/:id/steps/:stepId` | Delete a step | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `PUT` | `/api/v1/path-definitions/:id/steps/:stepId` | Update a step | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `GET` | `/api/v1/path-definitions/:id/steps/:stepId/sub-steps` | List sub-steps within a step | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `POST` | `/api/v1/path-definitions/:id/steps/:stepId/sub-steps` | Create a sub-step | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `DELETE` | `/api/v1/path-definitions/:id/steps/:stepId/sub-steps/:subStepId` | Delete a sub-step | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `PUT` | `/api/v1/path-definitions/:id/steps/:stepId/sub-steps/:subStepId` | Update a sub-step | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `PUT` | `/api/v1/path-definitions/:id/steps/:stepId/sub-steps/reorder` | Reorder sub-steps within a step | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `PUT` | `/api/v1/path-definitions/:id/steps/reorder` | Reorder steps in a path | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `GET` | `/api/v1/path-definitions/buddies/:buddyId/paths/:pathKey` | Get path runtime payload for a buddy | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `POST` | `/api/v1/path-definitions/buddies/:buddyId/paths/:pathKey/sub-steps/:subKey/complete` | Manually mark a sub-step complete (admin) | [`apps/api/src/paths/paths.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/paths/paths.controller.ts) |
| `GET` | `/api/v1/presets` | List presets | [`apps/api/src/presets/presets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/presets/presets.controller.ts) |
| `GET` | `/api/v1/presets/:key` | Get preset | [`apps/api/src/presets/presets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/presets/presets.controller.ts) |
| `GET` | `/api/v1/skill-decay-rules` | List skill decay rules | [`apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts) |
| `POST` | `/api/v1/skill-decay-rules` | Create a skill decay rule | [`apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts) |
| `DELETE` | `/api/v1/skill-decay-rules/:id` | Delete a skill decay rule | [`apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts) |
| `PUT` | `/api/v1/skill-decay-rules/:id` | Update a skill decay rule | [`apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts) |
| `GET` | `/api/v1/skill-decay-rules/:id/history` | Recent decay applications for a rule | [`apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts) |
| `GET` | `/api/v1/skill-decay-rules/:id/preview` | Preview the cumulative effect of a decay rule | [`apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts) |
| `POST` | `/api/v1/skill-decay-rules/run-now` | Trigger a decay sweep immediately for this customer | [`apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-decay-rules/skill-decay-rules.controller.ts) |
| `GET` | `/api/v1/skill-rules` | List all skill rules | [`apps/api/src/skill-rules/skill-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-rules/skill-rules.controller.ts) |
| `POST` | `/api/v1/skill-rules` | Create a skill rule | [`apps/api/src/skill-rules/skill-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-rules/skill-rules.controller.ts) |
| `DELETE` | `/api/v1/skill-rules/:id` | Delete a skill rule | [`apps/api/src/skill-rules/skill-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-rules/skill-rules.controller.ts) |
| `PUT` | `/api/v1/skill-rules/:id` | Update a skill rule | [`apps/api/src/skill-rules/skill-rules.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-rules/skill-rules.controller.ts) |
| `GET` | `/api/v1/skill-sets` | List all skill sets | [`apps/api/src/skill-sets/skill-sets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-sets/skill-sets.controller.ts) |
| `POST` | `/api/v1/skill-sets` | Create a skill set | [`apps/api/src/skill-sets/skill-sets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-sets/skill-sets.controller.ts) |
| `DELETE` | `/api/v1/skill-sets/:id` | Delete a skill set | [`apps/api/src/skill-sets/skill-sets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-sets/skill-sets.controller.ts) |
| `GET` | `/api/v1/skill-sets/:id` | Get a skill set | [`apps/api/src/skill-sets/skill-sets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-sets/skill-sets.controller.ts) |
| `PUT` | `/api/v1/skill-sets/:id` | Update a skill set | [`apps/api/src/skill-sets/skill-sets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-sets/skill-sets.controller.ts) |
| `POST` | `/api/v1/skill-sets/generate-icon` | Generate a skill icon with AI | [`apps/api/src/skill-sets/skill-sets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/skill-sets/skill-sets.controller.ts) |
| `GET` | `/api/v1/stage-assets` | List per-customer stage assets (preset mode buddy art) plus the default library URLs resolved for the customer's creature_style. | [`apps/api/src/stage-assets/stage-assets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/stage-assets/stage-assets.controller.ts) |
| `DELETE` | `/api/v1/stage-assets/:stage` | Remove the preset asset for a stage | [`apps/api/src/stage-assets/stage-assets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/stage-assets/stage-assets.controller.ts) |
| `PUT` | `/api/v1/stage-assets/:stage` | Commit an uploaded object as the preset asset for a stage | [`apps/api/src/stage-assets/stage-assets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/stage-assets/stage-assets.controller.ts) |
| `POST` | `/api/v1/stage-assets/:stage/regenerate` | Queue AI generation for a customer preset stage asset | [`apps/api/src/stage-assets/stage-assets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/stage-assets/stage-assets.controller.ts) |
| `POST` | `/api/v1/stage-assets/upload-url` | Issue a presigned PUT URL for a client-side stage asset upload | [`apps/api/src/stage-assets/stage-assets.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/stage-assets/stage-assets.controller.ts) |
| `GET` | `/api/v1/streak-definitions` | List all streak definitions | [`apps/api/src/streaks/streak-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/streaks/streak-definitions.controller.ts) |
| `POST` | `/api/v1/streak-definitions` | Create a streak definition | [`apps/api/src/streaks/streak-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/streaks/streak-definitions.controller.ts) |
| `DELETE` | `/api/v1/streak-definitions/:id` | Delete a streak definition | [`apps/api/src/streaks/streak-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/streaks/streak-definitions.controller.ts) |
| `GET` | `/api/v1/streak-definitions/:id` | Get a streak definition | [`apps/api/src/streaks/streak-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/streaks/streak-definitions.controller.ts) |
| `PUT` | `/api/v1/streak-definitions/:id` | Update a streak definition | [`apps/api/src/streaks/streak-definitions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/streaks/streak-definitions.controller.ts) |
| `GET` | `/api/v1/token-config` | List token configurations | [`apps/api/src/token-config/token-config.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/token-config/token-config.controller.ts) |
| `POST` | `/api/v1/token-config` | Upsert token configuration | [`apps/api/src/token-config/token-config.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/token-config/token-config.controller.ts) |
| `GET` | `/api/v1/webhook-configs` | List webhook configs | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `POST` | `/api/v1/webhook-configs` | Create webhook config | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `DELETE` | `/api/v1/webhook-configs/:id` | Delete webhook config | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `GET` | `/api/v1/webhook-configs/:id` | Get webhook config | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `PUT` | `/api/v1/webhook-configs/:id` | Update webhook config | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `GET` | `/api/v1/webhook-configs/:id/deliveries` | List webhook deliveries | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `POST` | `/api/v1/webhook-configs/:id/deliveries/:deliveryId/redeliver` | Redeliver webhook | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `POST` | `/api/v1/webhook-configs/:id/rotate-secret` | Rotate webhook secret | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `POST` | `/api/v1/webhook-configs/:id/test` | Send test webhook | [`apps/api/src/webhooks/webhooks.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/webhooks/webhooks.controller.ts) |
| `POST` | `/api/v1/widget-sessions` | Create session token | [`apps/api/src/widget-sessions/widget-sessions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-sessions.controller.ts) |
| `DELETE` | `/api/v1/widget-sessions/:id` | Revoke widget session | [`apps/api/src/widget-sessions/widget-sessions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-sessions.controller.ts) |
| `GET` | `/api/v1/widget-sessions/preview` | Create automatic dashboard preview tokens | [`apps/api/src/widget-sessions/widget-sessions.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-sessions.controller.ts) |
| `POST` | `/api/v1/widget/appearance/rerender` | Rerender stage base | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/badges` | Get widget badge catalog | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/buddy` | Get widget buddy | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `POST` | `/api/v1/widget/equip` | Equip or unequip items | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/evolutions` | Get widget evolution timeline | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/marketplace` | Get widget marketplace | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `POST` | `/api/v1/widget/marketplace/items/:id/track-view` | Track marketplace item impression | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/operations/:id` | Get widget operation | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/path` | Get the active path for the buddy’s audience | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/path/:key` | Get a specific path for a buddy | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `POST` | `/api/v1/widget/path/:key/sub-steps/:subKey/complete` | Manually mark a sub-step complete | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `POST` | `/api/v1/widget/purchase` | Purchase item | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/state` | Get aggregate widget state | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/streak/:key` | Get widget streak | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/theme` | Live widget theme | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `GET` | `/api/v1/widget/tokens` | Get the buddy’s token wallet | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
| `POST` | `/api/v1/widget/track` | Track event from browser | [`apps/api/src/widget-sessions/widget-api.controller.ts`](https://github.com/hatched-live/hatched/blob/main/apps/api/src/widget-sessions/widget-api.controller.ts) |
{/* ENDPOINTS_END */}
---
# SDK (JavaScript / TypeScript)
> Complete method reference for @hatched/sdk-js — HatchedClient, resources, error classes.
Source: https://docs.hatched.live/docs/reference/sdk-js
{/* AUTO-GENERATED from packages/sdk-js by apps/docs/scripts/generate-sdk-reference.ts.
Edit JSDoc in the SDK source; do not edit this file directly. */}
Package: [`@hatched/sdk-js`](https://npmjs.com/package/@hatched/sdk-js)
```bash
pnpm add @hatched/sdk-js
```
## HatchedClient
Official Hatched SDK client for JavaScript/TypeScript.
Server-side (secret key):
```ts
const hatched = new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
});
const egg = await hatched.eggs.create({ userId: 'user_123' });
await hatched.eggs.updateStatus(egg.eggId, 'ready');
const op = await hatched.eggs.hatch(egg.eggId);
```
Browser (publishable key, scoped):
```ts
const hatched = new HatchedClient({
publishableKey: 'hatch_pk_xxxxxxxx',
});
const buddy = await hatched.buddies.get('bdy_abc');
```
### `HatchedClient.health()`
```ts
health()
```
Health check; returns API status metadata.
### `HatchedClient.getRateLimitInfo()`
```ts
getRateLimitInfo()
```
Latest `X-RateLimit-*` snapshot from the most recent response.
### `HatchedClient.getLastRequestId()`
```ts
getLastRequestId(): string | null
```
Request id of the most recent response (for support correlation).
## EggsResource
### `EggsResource.create()`
```ts
create(params: CreateEggParams, signal?: AbortSignal): Promise
```
Creates a new pending egg bound to an external user.
New eggs start in `waiting`; call `updateStatus(eggId, 'ready')`
before `hatch()`. Pass `ensure: true` during a first-run bootstrap to
reuse the user's existing `waiting`/`ready` egg instead of creating one.
@example
```ts
const egg = await hatched.eggs.create({ userId: 'user_42' });
await hatched.eggs.updateStatus(egg.eggId, 'ready');
```
### `EggsResource.get()`
```ts
get(eggId: string, signal?: AbortSignal): Promise
```
Fetches the canonical state of a single egg.
### `EggsResource.list()`
```ts
list(params: ListEggsParams = {}): Promise
```
Lists eggs with optional filters.
### `EggsResource.updateStatus()`
```ts
updateStatus(eggId: string, status: 'ready' | 'cancelled', signal?: AbortSignal): Promise
```
Transitions an egg to `ready` or `cancelled`. The API only permits
`ready` and `cancelled` terminal statuses via this endpoint.
### `EggsResource.hatch()`
```ts
hatch(eggId: string, signal?: AbortSignal): Promise
```
Kicks off an async hatch operation. Poll the returned operationId via
`operations.wait()` to resolve when the buddy art is ready. The egg
must already be in `ready` status.
## BuddiesResource
### `BuddiesResource.get()`
```ts
get(buddyId: string, signal?: AbortSignal): Promise
```
Fetches a buddy by id.
### `BuddiesResource.list()`
```ts
list(params: BuddyListParams = {}): Promise
```
Lists buddies with optional filters.
### `BuddiesResource.updateName()`
```ts
updateName(buddyId: string, name: string, signal?: AbortSignal): Promise
```
### `BuddiesResource.archive()`
```ts
archive(buddyId: string, signal?: AbortSignal): Promise
```
### `BuddiesResource.updateSkills()`
```ts
updateSkills(buddyId: string, updates: SkillUpdate[], signal?: AbortSignal)
```
### `BuddiesResource.earn()`
```ts
earn(buddyId: string, params: EarnCoinsParams, idempotencyKey?: string, signal?: AbortSignal)
```
Adds coins to a buddy's ledger for a given reason.
Alias: `BuddiesResource.earnCoins`.
### `BuddiesResource.spend()`
```ts
spend(buddyId: string, params: SpendCoinsParams, idempotencyKey?: string, signal?: AbortSignal)
```
Debits coins from a buddy's ledger. Fails with
`InsufficientBalanceError` if the buddy doesn't have enough.
### `BuddiesResource.awardBadge()`
```ts
awardBadge(buddyId: string, badgeKey: string, reason?: string, signal?: AbortSignal)
```
### `BuddiesResource.getBadges()`
```ts
getBadges(buddyId: string, signal?: AbortSignal)
```
### `BuddiesResource.equip()`
```ts
equip(buddyId: string, params: EquipItemsParams, signal?: AbortSignal): Promise
```
Equips or unequips items on a buddy.
### `BuddiesResource.rerenderAppearance()`
```ts
rerenderAppearance(buddyId: string, signal?: AbortSignal): Promise
```
Regenerate the buddy's bare stage base image. Use after a hard generation
failure or when `appearance.status === 'failed'` with `code: 'needs_rerender'`.
Equipped items are removed from the rendered set; re-equip after the
appearance returns to `ready`.
### `BuddiesResource.purchaseItem()`
```ts
purchaseItem(buddyId: string, itemId: string, idempotencyKey?: string, signal?: AbortSignal)
```
### `BuddiesResource.getPurchasedItems()`
```ts
getPurchasedItems(buddyId: string, signal?: AbortSignal)
```
### `BuddiesResource.getEvolution()`
```ts
getEvolution(buddyId: string, signal?: AbortSignal)
```
### `BuddiesResource.evolve()`
```ts
evolve(buddyId: string, signal?: AbortSignal)
```
Starts the async operation that advances a ready buddy to its next
evolution stage. Use after `events.send()` returns
`effects.evolutionReady === true` when auto-evolve is disabled.
### `BuddiesResource.getProgression()`
```ts
getProgression(buddyId: string, signal?: AbortSignal)
```
### `BuddiesResource.tokens()`
```ts
tokens(buddyId: string, signal?: AbortSignal): Promise
```
Typed token balances for a buddy, grouped into primary (spendable) and
progression (accumulate-only). Returns null for either slot if the
customer has not configured that kind.
### `BuddiesResource.evolutions()`
```ts
evolutions(buddyId: string, params: { page?: number; limit?: number; signal?: AbortSignal } = {}): Promise<{
data: BuddyEvolutionRecord[];
pagination: { page: number; limit: number; total: number };
}>
```
Paginated stage-transition timeline for a buddy (includes both prod
and demo evolutions).
### `BuddiesResource.getUserSummary()`
```ts
getUserSummary(userId: string, signal?: AbortSignal)
```
## EventsResource
### `EventsResource.send()`
```ts
send(params: SendEventParams, signal?: AbortSignal): Promise
```
Ingests a domain event. The same `eventId` returning twice yields the
cached effect without re-applying rules.
@example
```ts
const effects = await hatched.events.send({
eventId: 'lesson_lsn_42_user_123',
userId: 'user_123',
type: 'lesson_completed',
properties: { score: 94 },
});
```
### `EventsResource.sendBatch()`
```ts
sendBatch(events: SendEventParams[], signal?: AbortSignal): Promise<{ results: EventEffects[] }>
```
Sends a batch of events in a single call.
## OperationsResource
### `OperationsResource.get()`
```ts
get(operationId: string, signal?: AbortSignal): Promise>
```
Fetches an operation's current status.
### `OperationsResource.wait()`
```ts
wait(operationId: string, options: WaitOptions = {}): Promise>
```
Polls an operation until it reaches `completed` or `failed`.
@throws `Error` if the operation doesn't finish before `timeoutMs` elapses.
@example
```ts
await hatched.eggs.updateStatus(egg.eggId, 'ready');
const op = await hatched.eggs.hatch(egg.eggId);
const finished = await hatched.operations.wait(op.operationId);
```
### `OperationsResource.waitForCompletion()`
```ts
waitForCompletion(operationId: string, options: { timeout?: number; interval?: number; signal?: AbortSignal } = {}): Promise>
```
@deprecated Use `OperationsResource.wait` instead.
## WidgetSessionsResource
### `WidgetSessionsResource.create()`
```ts
create(params: CreateSessionParams, signal?: AbortSignal): Promise
```
Mints a short-lived widget session token for browser/interactive
widgets. Never ship a secret API key to the browser — always go
through this endpoint.
### `WidgetSessionsResource.revoke()`
```ts
revoke(sessionId: string, signal?: AbortSignal): Promise
```
## EmbedTokensResource
### `EmbedTokensResource.create()`
```ts
create(params: CreateEmbedTokenParams, signal?: AbortSignal): Promise
```
Mints a signed token for a read-only embedded widget.
## WebhooksResource
### `WebhooksResource.list()`
```ts
list(signal?: AbortSignal): Promise
```
Lists webhook endpoints registered for the current customer.
### `WebhooksResource.create()`
```ts
create(params: CreateWebhookParams, signal?: AbortSignal): Promise
```
Registers a new webhook endpoint.
### `WebhooksResource.delete()`
```ts
delete(endpointId: string, signal?: AbortSignal): Promise
```
Deletes a webhook endpoint.
### `WebhooksResource.deliveries()`
```ts
deliveries(params: ListDeliveriesParams): Promise>
```
Lists recent deliveries for a given endpoint.
### `WebhooksResource.replay()`
```ts
replay(endpointId: string, deliveryId: string, signal?: AbortSignal): Promise
```
Replays a specific delivery attempt.
### `WebhooksResource.redeliver()`
```ts
redeliver(endpointId: string, deliveryId: string, signal?: AbortSignal): Promise
```
Re-enqueues a stored webhook delivery.
### `WebhooksResource.verifySignature()`
```ts
static verifySignature(rawBody: string | Buffer, signatureHeader: string, secret: string, options: VerifySignatureOptions = {}): boolean
```
Verifies the `Hatched-Signature` header for a webhook payload.
The signature format is `t=,v1=`.
Pass the **raw request body bytes** (not the parsed JSON) — any
reformatting will invalidate the signature.
@example
```ts
const valid = WebhooksResource.verifySignature(rawBody, header, process.env.HATCHED_WEBHOOK_SECRET!);
if (!valid) return new Response('invalid signature', { status: 400 });
```
## HatchedError
Base class for every error raised by the Hatched SDK. Every subclass
carries the HTTP status, stable error code, optional details payload, and
the request id the API echoed back for support correlation.
_No public methods._
## AuthError
Shared base for 401/403 responses.
_No public methods._
## UnauthorizedError
_No public methods._
## ForbiddenError
_No public methods._
## PublishableKeyScopeError
Raised when a request uses a publishable key for an operation the
publishable key is not scoped for (e.g. mutation endpoints).
_No public methods._
## NotFoundError
_No public methods._
## ValidationError
_No public methods._
## RateLimitError
_No public methods._
## InsufficientBalanceError
_No public methods._
## TooManyItemsError
Raised when an equip request asks the buddy to wear more items than the
image compositing pipeline can reliably render. The current cap is four —
the SDK surfaces `max` and `attempted` on `details` so callers can show a
precise error to the end-user.
_No public methods._
## CategoryConflictError
Raised when an equip request tries to put two items in the same category
slot (e.g. two head items). Only the `accessory` category allows stacking.
_No public methods._
## ConflictError
_No public methods._
## ConfigVersionMismatchError
Raised when a buddy is pinned to a config version that does not match
the one the caller expected (e.g. after a migration race).
_No public methods._
## NoPublishedConfigError
Raised by `POST /eggs` (and the bootstrap flow) when the customer has not
published a config version yet. `details.publish_url` points at the
dashboard publish page.
_No public methods._
## ActiveEggLimitError
Raised when `POST /eggs` would exceed the per-user active-egg cap.
`details.active` lists the existing eggs (id + status) so you can hatch or
cancel one — or retry the create with `?ensure=true` to reuse one.
_No public methods._
## UpstreamImageError
_No public methods._
## CreditInsufficientError
Raised when an AI / generative request cannot be authorised because the
customer has no available credits across any pool. The `details` object
includes the amount required and what remains in each pool, plus a URL
the caller can redirect to so the end-customer can top up.
_No public methods._
## EventQuotaExceededError
Raised when a POST /events call would push the customer over the monthly
event quota allowed by their plan. `reset_at` is an ISO timestamp for the
first of the next UTC month; callers should back off until then or upgrade.
_No public methods._
## PlanFeatureLockedError
Raised when the customer's plan does not include the requested feature
(e.g. a Free tier customer trying to use the marketplace API). The SDK
surfaces which plan is required so callers can prompt an upgrade.
_No public methods._
## GatesResource
Generic spend-to-unlock primitive. Customers define gates in their
dashboard (gate_key, token_key, cost, metadata). A buddy calls `unlock`
to spend the configured token cost and flip the gate open — idempotent:
repeat calls return `alreadyUnlocked: true` without touching the economy.
### `GatesResource.list()`
```ts
list(signal?: AbortSignal): Promise<{ gates: TokenGate[] }>
```
Lists gates configured on this customer. Secret-key only.
### `GatesResource.unlock()`
```ts
unlock(buddyId: string, gateKey: string, signal?: AbortSignal): Promise
```
Buddy spends `gate.cost` of `gate.tokenKey` to unlock `gateKey`.
Fails with `InsufficientBalanceError` if the buddy lacks tokens and
with `ValidationError('progression_not_spendable')` if the gate
references a progression token.
### `GatesResource.unlocks()`
```ts
unlocks(buddyId: string, signal?: AbortSignal): Promise<{ unlocks: BuddyUnlock[] }>
```
List gates a buddy has unlocked.
## PathsResource
Guided journey primitive — a path is an ordered list of steps; each
step holds an ordered list of sub-steps with an optional completion
condition. Sub-step completions advance the buddy through the path
automatically (rule-engine) or manually via `completeSubStep`.
The `HttpClient` auto-converts wire snake_case → camelCase on every
response, so resource methods read camelCase fields directly without
an intermediate DTO mapping layer.
### `PathsResource.list()`
```ts
list(audience?: string, signal?: AbortSignal): Promise
```
### `PathsResource.get()`
```ts
get(definitionId: string, signal?: AbortSignal): Promise
```
### `PathsResource.create()`
```ts
create(params: CreatePathDefinitionParams, signal?: AbortSignal): Promise
```
### `PathsResource.update()`
```ts
update(definitionId: string, params: UpdatePathDefinitionParams, signal?: AbortSignal): Promise
```
### `PathsResource.delete()`
```ts
delete(definitionId: string, signal?: AbortSignal): Promise
```
### `PathsResource.setActive()`
```ts
setActive(definitionId: string, isActive: boolean, signal?: AbortSignal): Promise
```
Atomic single-active activation: deactivates every other path on
the same (customer, audience) in a single transaction.
### `PathsResource.addStep()`
```ts
addStep(definitionId: string, params: CreatePathStepParams, signal?: AbortSignal): Promise
```
### `PathsResource.updateStep()`
```ts
updateStep(definitionId: string, stepId: string, params: UpdatePathStepParams, signal?: AbortSignal): Promise
```
### `PathsResource.deleteStep()`
```ts
deleteStep(definitionId: string, stepId: string, signal?: AbortSignal): Promise
```
### `PathsResource.reorderSteps()`
```ts
reorderSteps(definitionId: string, ordering: Array<{ id: string; ordinal: number }>, signal?: AbortSignal): Promise
```
### `PathsResource.addSubStep()`
```ts
addSubStep(definitionId: string, stepId: string, params: CreatePathSubStepParams, signal?: AbortSignal): Promise
```
### `PathsResource.updateSubStep()`
```ts
updateSubStep(definitionId: string, stepId: string, subStepId: string, params: UpdatePathSubStepParams, signal?: AbortSignal): Promise
```
### `PathsResource.deleteSubStep()`
```ts
deleteSubStep(definitionId: string, stepId: string, subStepId: string, signal?: AbortSignal): Promise
```
### `PathsResource.reorderSubSteps()`
```ts
reorderSubSteps(definitionId: string, stepId: string, ordering: Array<{ id: string; ordinal: number }>, signal?: AbortSignal): Promise
```
### `PathsResource.getForBuddy()`
```ts
getForBuddy(buddyId: string, pathKey: string, signal?: AbortSignal): Promise
```
### `PathsResource.completeSubStep()`
```ts
completeSubStep(buddyId: string, pathKey: string, subStepKey: string, signal?: AbortSignal): Promise
```
Manually mark a sub-step complete. Idempotent on (buddy, sub-step).
Returns cascade flags so callers can paint celebrations without an
extra round-trip.
## Types
### `NoPublishedConfigDetails`
```ts
export interface NoPublishedConfigDetails {
customerId?: string;
customer_id?: string;
publishUrl?: string;
publish_url?: string;
docsUrl?: string;
docs_url?: string;
}
```
### `ActiveEggLimitEgg`
```ts
export interface ActiveEggLimitEgg {
eggId: string;
status: string;
createdAt: string;
}
```
### `ActiveEggLimitDetails`
```ts
export interface ActiveEggLimitDetails {
max?: number;
active?: ActiveEggLimitWireEgg[];
}
```
### `CreditInsufficientDetails`
```ts
export interface CreditInsufficientDetails {
required?: number;
available?: number;
welcome?: number;
paid?: number;
promo?: number;
upgrade_url?: string;
top_up_url?: string;
}
```
### `EventQuotaExceededDetails`
```ts
export interface EventQuotaExceededDetails {
used?: number;
limit?: number;
reset_at?: string;
upgrade_url?: string;
}
```
### `PlanFeatureLockedDetails`
```ts
export interface PlanFeatureLockedDetails {
feature?: string;
required_plan?: string;
current_plan?: string;
upgrade_url?: string;
}
```
### `EggStatus`
```ts
export type EggStatus = 'waiting' | 'ready' | 'hatching' | 'hatched' | 'cancelled';
```
### `CreateEggParams`
```ts
export interface CreateEggParams {
/** The external user id that owns the egg. */
userId: string;
/** Free-form metadata attached to the egg. */
metadata?: Record;
/**
* When true, return the user's most recent `waiting`/`ready` egg if one
* already exists instead of creating a new one (idempotent first-run
* bootstrap; avoids hitting the per-user active-egg cap on retries).
*/
ensure?: boolean;
}
```
### `Egg`
```ts
export interface Egg {
eggId: string;
userId: string;
status: EggStatus;
visualVariant: number;
configVersionId: string;
/** The buddy hatched from this egg. Non-null once `status === 'hatched'`. */
buddyId: string | null;
metadata: Record;
createdAt: string;
}
```
### `EggStatusChange`
```ts
export interface EggStatusChange {
eggId: string;
status: EggStatus;
previousStatus: EggStatus;
}
```
### `HatchResult`
```ts
export interface HatchResult {
operationId: string;
status: string;
}
```
### `ListEggsParams`
```ts
export interface ListEggsParams {
userId?: string;
status?: EggStatus;
page?: number;
limit?: number;
signal?: AbortSignal;
}
```
### `Buddy`
```ts
export interface Buddy {
id: string;
customerId: string;
userId: string;
audience: string;
name: string;
configVersionId: string;
evolutionStage: number;
coins: number;
status: 'active' | 'archived';
skills: Record;
tokens: Record;
progression?: BuddyProgression;
imageUrl: string | null;
baseImageUrl: string | null;
thumbUrl: string | null;
equippedItems: BuddyEquippedItem[];
appearance?: BuddyAppearance;
createdAt: string;
updatedAt: string;
}
```
### `BuddyProgression`
```ts
export interface BuddyProgression {
/** Player-facing XP. Currently maps to totalSkillLevel. */
xp: number;
totalSkillLevel: number;
badgeCount: number;
itemCount: number;
currentStreak: number;
longestStreak: number;
customCounters: Record;
}
```
### `BuddyEquippedItem`
```ts
export interface BuddyEquippedItem {
itemId: string;
name: string;
imageUrl: string | null;
}
```
### `BuddyAppearanceStatus`
```ts
export type BuddyAppearanceStatus = 'ready' | 'pending' | 'awaiting_credits' | 'failed';
```
### `BuddyAppearance`
```ts
export interface BuddyAppearance {
status: BuddyAppearanceStatus;
operationId: string | null;
desiredEquippedItemIds: string[];
renderedEquippedItemIds: string[];
retryable: boolean;
message: string | null;
error: Record | null;
}
```
### `BuddyListParams`
```ts
export interface BuddyListParams {
userId?: string;
status?: string;
evolutionStage?: number;
page?: number;
limit?: number;
sort?: string;
order?: 'asc' | 'desc';
signal?: AbortSignal;
}
```
### `SkillUpdate`
```ts
export interface SkillUpdate {
key: string;
action: 'increase' | 'decrease' | 'set';
amount?: number;
value?: number;
}
```
### `EarnCoinsParams`
```ts
export interface EarnCoinsParams {
amount: number;
reason: string;
referenceId?: string;
/**
* Token key to earn. Defaults to the customer's `primary` token. Passing
* a progression token grants progress; passing any other configured token
* key is accepted by the rule engine. Omit to earn the default coin / primary.
*/
token?: string;
}
```
### `SpendCoinsParams`
```ts
export interface SpendCoinsParams {
amount: number;
reason: string;
itemId?: string;
/**
* Token key to spend. Defaults to the customer's `primary` token. Spending
* a progression token fails with ValidationError('progression_not_spendable').
*/
token?: string;
}
```
### `TokenBalance`
```ts
export interface TokenBalance {
/** Canonical `primary` or `progression` identifier. */
kind: 'primary' | 'progression';
/** Customer-defined token key (e.g. `gems`, `xp`). */
key: string;
/** Human-readable label for display. */
label: string;
/** Current balance. */
balance: number;
/** Lifetime earned (earn ledger sum). */
lifetimeEarned: number;
/** Lifetime spent (spend ledger sum) — always 0 for progression. */
lifetimeSpent: number;
}
```
### `TokensSummary`
```ts
export interface TokensSummary {
primary: TokenBalance | null;
progression: TokenBalance | null;
}
```
### `BuddyEvolutionRecord`
```ts
export interface BuddyEvolutionRecord {
id: string;
buddyId: string;
fromStage: number;
toStage: number;
triggeredByEventId: string | null;
imageUrl: string | null;
source: 'prod' | 'demo' | 'auto';
metadata: Record;
occurredAt: string;
}
```
### `EquipItemsParams`
```ts
export interface EquipItemsParams {
equip?: string[];
unequip?: string[];
}
```
### `EquipItemsResult`
```ts
export interface EquipItemsResult {
accepted: boolean;
operationId: string | null;
status: 'pending' | 'completed' | string;
appearanceStatus: BuddyAppearanceStatus | string;
cached: boolean;
}
```
### `RerenderAppearanceResult`
```ts
export interface RerenderAppearanceResult {
accepted: boolean;
operationId: string;
status: 'pending' | string;
appearanceStatus: BuddyAppearanceStatus | string;
}
```
### `BuddyList`
```ts
export interface BuddyList {
data: Buddy[];
meta: { total: number; page: number; limit: number };
}
```
### `SendEventParams`
```ts
export interface SendEventParams {
/** Stable id used for idempotency. Re-sending the same eventId is a no-op. */
eventId: string;
/** External user id the event belongs to. */
userId: string;
/** Event type (e.g. `lesson_completed`, `workout_finished`). */
type: string;
/**
* Audience (role) this event belongs to. Required for customers with 2+
* audiences; omit for single-audience customers and the server applies
* the implicit default. Lowercase, snake_case, max 32 chars.
*/
audience?: string;
/** When the event occurred. Defaults to "now" server-side if omitted. */
occurredAt?: Date | string;
/** Arbitrary key-value payload forwarded to the rule engine. */
properties?: Record;
}
```
### `EventStreakUpdate`
Per-streak progression entry returned alongside coin/badge effects when a
tracked event advances a streak. The HTTP client deep-converts snake_case
→ camelCase, so SDK consumers see camelCase keys here.
```ts
export interface EventStreakUpdate {
definitionKey: string;
label: string;
icon: string;
current: number;
longest: number;
milestoneHit: number | null;
hero: boolean;
}
```
### `EventPathSubStepCompletion`
Per-path completion delta produced when a tracked event closes a sub-step.
```ts
export interface EventPathSubStepCompletion {
pathKey: string;
stepKey: string;
subStepKey: string;
rewardCoins: number;
rewardBadgeKey: string | null;
}
```
### `EventPathStepCompletion`
```ts
export interface EventPathStepCompletion {
pathKey: string;
stepKey: string;
rewardCoins: number;
rewardBadgeKey: string | null;
}
```
### `EventPathCompletion`
```ts
export interface EventPathCompletion {
pathKey: string;
}
```
### `EventPathUpdate`
```ts
export interface EventPathUpdate {
pathKey: string;
subStepCompleted?: EventPathSubStepCompletion;
stepCompleted?: EventPathStepCompletion;
pathCompleted?: EventPathCompletion;
}
```
### `EventEffects`
```ts
export interface EventEffects {
coins?: number;
badgesAwarded?: string[];
badgesReady?: string[];
tokens?: string[];
/**
* True when the buddy has met the next evolution condition. If the
* customer's config does not auto-evolve, call `buddies.evolve(buddyId)`
* server-side and wait on the returned operation.
*/
evolutionReady?: boolean;
streakMilestones?: number[];
/** Per-streak deltas (current/longest, milestone hits) for active streaks. */
streakUpdates?: EventStreakUpdate[];
/**
* Path widget reconciliation deltas. Each entry covers one sub-step
* completion plus optional step / path roll-up flags so the host page
* can paint celebrations without an extra round-trip.
*/
pathUpdates?: EventPathUpdate[];
}
```
### `OperationStatus`
```ts
export type OperationStatus = 'pending' | 'processing' | 'completed' | 'failed';
```
### `Operation`
```ts
export interface Operation {
operationId: string;
/** Alias for {@link Operation.operationId} for callers that prefer `id`. */
id: string;
type: string;
status: OperationStatus;
result?: TResult;
error?: string;
createdAt: string;
updatedAt: string;
}
```
### `WaitOptions`
```ts
export interface WaitOptions {
/** Maximum total time to wait, in milliseconds. Default 30_000. */
timeoutMs?: number;
/** Poll interval, in milliseconds. Default 2000. */
intervalMs?: number;
/** External abort signal. */
signal?: AbortSignal;
}
```
### `CreateSessionParams`
```ts
export interface CreateSessionParams {
buddyId: string;
userId: string;
scopes: string[];
ttlSeconds?: number;
}
```
### `SessionToken`
```ts
export interface SessionToken {
token: string;
sessionId: string;
expiresAt: string;
scopes: string[];
}
```
### `CreateEmbedTokenParams`
```ts
export interface CreateEmbedTokenParams {
buddyId: string;
userId: string;
ttlSeconds?: number;
}
```
### `EmbedToken`
```ts
export interface EmbedToken {
token: string;
expiresAt: string;
mode: 'read-only';
}
```
### `WebhookEndpoint`
```ts
export interface WebhookEndpoint {
id: string;
url: string;
events: string[];
active: boolean;
status: 'active' | 'paused';
secret?: string;
maskedSecret?: string;
createdAt: string;
updatedAt: string;
}
```
### `CreateWebhookParams`
```ts
export interface CreateWebhookParams {
url: string;
events: string[];
description?: string;
}
```
### `WebhookDelivery`
```ts
export interface WebhookDelivery {
id: string;
endpointId?: string;
event: string;
eventType: string;
status: 'pending' | 'success' | 'succeeded' | 'failed';
responseCode?: number | null;
responseStatus?: number | null;
attempt: number;
attempts: number;
durationMs?: number | null;
errorMessage?: string | null;
createdAt: string;
timestamp: string;
lastAttemptAt?: string;
}
```
### `ListDeliveriesParams`
```ts
export interface ListDeliveriesParams {
endpointId: string;
status?: 'pending' | 'success' | 'failed';
cursor?: string;
limit?: number;
signal?: AbortSignal;
}
```
### `Page`
```ts
export interface Page {
data: T[];
nextCursor: string | null;
}
```
### `VerifySignatureOptions`
```ts
export interface VerifySignatureOptions {
/** Maximum clock-skew in seconds. Defaults to 5 minutes. */
toleranceSeconds?: number;
/** Clock used for timestamp validation — useful in tests. */
now?: () => number;
}
```
### `TokenGate`
```ts
export interface TokenGate {
id: string;
gateKey: string;
tokenKey: string;
cost: number;
label: string | null;
description: string | null;
metadata: Record;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
```
### `UnlockResult`
```ts
export interface UnlockResult {
gateKey: string;
unlocked: true;
alreadyUnlocked: boolean;
unlockedAt: string;
balanceAfter: number | null;
metadata: Record;
}
```
### `BuddyUnlock`
```ts
export interface BuddyUnlock {
gateKey: string;
unlockedAt: string;
metadata: Record;
}
```
### `PathDisplayMode`
```ts
export type PathDisplayMode = 'straight' | 'zigzag' | 'stepper';
```
### `PathIcon`
```ts
export type PathIcon = 'path' | 'flame' | 'heart' | 'bolt' | 'star' | 'leaf';
```
### `PathConditionType`
```ts
export type PathConditionType =
| 'event_count'
| 'milestone'
| 'streak'
| 'skill_level'
| 'collection'
| 'evolution'
| 'coin'
| 'badge_earned'
| 'gate_unlocked'
| 'custom';
```
### `PathCondition`
```ts
export interface PathCondition {
type: PathConditionType;
config: Record;
}
```
### `PathDefinition`
```ts
export interface PathDefinition {
id: string;
customerId: string;
audience: string;
key: string;
label: string;
description: string | null;
icon: PathIcon;
accentColor: string | null;
displayMode: PathDisplayMode;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
```
### `PathStep`
```ts
export interface PathStep {
id: string;
pathDefinitionId: string;
key: string;
label: string;
description: string | null;
icon: string | null;
ordinal: number;
unlockCondition: Record | null;
completionCondition: PathCondition | null;
rewardCoins: number;
rewardBadgeKey: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
```
### `PathSubStep`
```ts
export interface PathSubStep {
id: string;
pathStepId: string;
key: string;
label: string;
description: string | null;
ordinal: number;
completionCondition: PathCondition | null;
allowManualComplete: boolean;
allowSkipAhead: boolean;
rewardCoins: number;
rewardBadgeKey: string | null;
contentUrl: string | null;
ctaLabel: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
```
### `PathSubStepStatus`
```ts
export type PathSubStepStatus = 'locked' | 'available' | 'completed';
```
### `PathSubStepRuntime`
```ts
export interface PathSubStepRuntime {
id: string;
key: string;
label: string;
description: string | null;
ordinal: number;
rewardCoins: number;
rewardBadgeKey: string | null;
contentUrl: string | null;
ctaLabel: string | null;
allowManualComplete: boolean;
allowSkipAhead: boolean;
isActive: boolean;
status: PathSubStepStatus;
completedAt: string | null;
}
```
### `PathStepRuntime`
```ts
export interface PathStepRuntime {
id: string;
key: string;
label: string;
description: string | null;
icon: string | null;
ordinal: number;
rewardCoins: number;
rewardBadgeKey: string | null;
isActive: boolean;
unlocked: boolean;
completed: boolean;
subSteps: PathSubStepRuntime[];
}
```
### `PathRuntimePayload`
```ts
export interface PathRuntimePayload {
definition: {
key: string;
label: string;
description: string | null;
icon: PathIcon;
accentColor: string | null;
displayMode: PathDisplayMode;
};
steps: PathStepRuntime[];
currentStepKey: string | null;
completed: boolean;
completedAt: string | null;
}
```
### `CreatePathDefinitionParams`
```ts
export interface CreatePathDefinitionParams {
audience?: string;
key: string;
label: string;
description?: string;
icon?: PathIcon;
accentColor?: string;
displayMode?: PathDisplayMode;
isActive?: boolean;
}
```
### `UpdatePathDefinitionParams`
```ts
export type UpdatePathDefinitionParams = Partial;
```
### `CreatePathStepParams`
```ts
export interface CreatePathStepParams {
key: string;
label: string;
description?: string;
icon?: string;
ordinal: number;
unlockCondition?: Record;
completionCondition?: PathCondition;
rewardCoins?: number;
rewardBadgeKey?: string;
isActive?: boolean;
}
```
### `UpdatePathStepParams`
```ts
export type UpdatePathStepParams = Partial;
```
### `CreatePathSubStepParams`
```ts
export interface CreatePathSubStepParams {
key: string;
label: string;
description?: string;
ordinal: number;
completionCondition?: PathCondition;
allowManualComplete?: boolean;
allowSkipAhead?: boolean;
rewardCoins?: number;
rewardBadgeKey?: string;
contentUrl?: string;
ctaLabel?: string;
isActive?: boolean;
}
```
### `UpdatePathSubStepParams`
```ts
export type UpdatePathSubStepParams = Partial;
```
### `ManualCompleteResult`
```ts
export interface ManualCompleteResult {
alreadyCompleted: boolean;
subStepKey: string;
stepKey: string;
stepCompleted: boolean;
pathCompleted: boolean;
rewardCoins: number;
rewardBadgeKey: string | null;
}
```
## Related
- [Error codes](/docs/reference/error-codes)
- [HTTP API](/docs/reference/http-api)
- [Getting started](/docs/guides/getting-started)
---
# Buddy widget
> Animated companion with stage, coins, equipped items, and live event effects.
Source: https://docs.hatched.live/docs/reference/widgets/buddy
## Mount
```html
```
Use `data-embed-token` on the script instead of `data-session-token` for a
read-only buddy.
For client-rendered routes, call `window.__HATCHED_WIDGET__?.init({ token })`
after the mount element exists. The global is `window.__HATCHED_WIDGET__`; the
init payload uses `token`, not `embedToken`.
## Script attributes
| Attribute | Values | Default |
| -------------------- | ---------------------- | --------------------------------- |
| `data-session-token` | widget session token | interactive mode |
| `data-embed-token` | embed token | read-only mode |
| `data-theme` | `light` `dark` | `light` |
| `data-api-base-url` | API origin + `/api/v1` | `https://api.hatched.live/api/v1` |
## Required scopes
- `read` renders buddy state.
- `events:track` enables `window.__HATCHED_WIDGET__.track(...)`.
## Appearance and pending operations
The buddy widget displays `buddy.appearance` from `/widget/state`. When an
equip or evolve operation is still rendering marketplace items, the widget keeps
the latest safe `image_url` visible and shows a small status banner:
- `pending` while an appearance job is running.
- `awaiting_credits` while Hatched waits for image credits before retrying.
- `failed` when recovery or rerender is needed.
`/widget/state` also includes `pending_operations` and an `ETag`; the loader
uses both to poll efficiently and refresh every mounted widget on the page.
## Host API
```ts
await window.__HATCHED_WIDGET__.track('lesson_completed', {
lessonId: 'lesson_1',
});
```
The loader reconciles effects into every mounted widget on the page.
---
# Badges widget
> A shelf of the buddy's earned and locked badges.
Source: https://docs.hatched.live/docs/reference/widgets/badges
## Mount
```html
```
## Required scopes
- `read`
Badges are included in the shared `/widget/state` snapshot, so the widget stays
in sync with the buddy widget without a second token.
---
# Streak widget
> A compact streak counter with milestones and current progress.
Source: https://docs.hatched.live/docs/reference/widgets/streak
## Mount
```html
```
The loader mounts every `data-hatched-mount="streak"` element, so one page can
render multiple streaks.
Copy the `data-streak-key` value from the streak definition in Dashboard →
Streaks.
## Display modes
A streak definition picks one of three render modes (Dashboard → Streaks →
Display mode):
| Mode | What it renders |
| ------- | ------------------------------------------------------------------------------- |
| `count` | A hero icon plus a large `N ×` counter and a status sub-line. The default. |
| `row` | `N` icons in a row (up to `max_row_icons`), then a `+overflow` chip and sub-line. |
| `mini` | A bare inline `🔥 N` chip — no label, sub-line, or attribution. Built to sit inside a host navbar or menu item. |
A single mount can override the definition's mode with `data-display-mode`,
so the same streak can render `count` on a dashboard page and `mini` in the
top nav without a second definition:
```html
```
The `mini` chip renders at the widget's base type scale (~14px); set
`font-size` on the mount element to resize the whole chip. The flame tints
to the widget accent only while the streak is active for the current period —
otherwise it stays muted.
## Mount attributes
| Attribute | Values | Default |
| ------------------- | ---------------------------- | ---------------------- |
| `data-streak-key` | streak definition key | required |
| `data-display-mode` | `count` · `row` · `mini` | the definition's mode |
## Required scopes
- `read`
---
# Path widget
> A guided journey of steps and sub-steps, rendered as a straight column, a zigzag quest, or a horizontal stepper.
Source: https://docs.hatched.live/docs/reference/widgets/path
## Mount
```html
```
The loader mounts every `data-hatched-mount="path"` element. Each mount
hits `/widget/path[/]` once on init and never re-fetches — further
state updates ride on `effects.path_updates` after `track()`
round-trips.
## Mount attributes
| Attribute | Values | Default |
| ---------------- | ----------------------------------------------- | -------- |
| `data-path-key` | A specific path definition key | optional |
When `data-path-key` is omitted, the server returns the audience's
currently-active path. When set, the widget renders that path even if it
is no longer active — useful for previewing a draft.
## Display modes
The widget renders either layout based on the definition's
`display_mode` field. Operators flip the mode from the dashboard; the
host page does not pick.
- **`straight`** (Straight column) — readable vertical path with a
continuous accent connector. Tap a node to expand its sub-steps inline.
- **`zigzag`** (Zigzag quest) — alternating nodes and labels,
Duolingo-style. Tap a node to expand its sub-steps inline.
- **`stepper`** (Compact stepper) — horizontal scrolling chip row with
the active step's sub-steps expanded inline below.
## Path icon
The definition's `icon` field renders optional decoration next to the
path label. Allowed values are `path`, `flame`, `heart`, `bolt`, `star`,
and `leaf`; `path` means no icon. The API rejects other values.
## Sub-step CTAs
Each sub-step can render up to two interactive elements:
- An external link, when `content_url` is set, labelled with
`cta_label` (or "Open" by default). Opens in a new tab.
- A "Mark complete" button, when `allow_manual_complete: true` *and* the
current token has the `events:track` scope. Embed-token mounts
(read-only) hide the button.
Sub-steps with neither will be rendered as a labelled list item with no
actions — operators usually pair `content_url` with manual completion or
with an automatic `completion_condition`.
## Required scopes
- `read` — to load the path payload.
- `events:track` — only if the page wants the manual-complete CTA. Embed
tokens with `read` only still render the path; the CTA is silently
hidden.
## Empty state
When the audience has no active path, the widget renders "No active path
yet" instead of spinning. Embedders can use this to verify the mount
worked even before the operator activates a path.
If `data-path-key` points to a path that does not exist for the buddy's
audience, the API returns 404 and the widget renders the error state.
## Theming
The widget honours the customer brand theme by default. To override the
accent for a specific path, set `accent_color` on the definition — the
widget exposes it as `--hw-path-accent`. The eight-axis personality
system from the workspace theme (surface, geometry, motion profile, …)
is forwarded as `data-*` attributes on the size wrapper.
---
# Tokens widget
> The buddy's wallet — the spendable primary balance plus every progression token, each with progress toward its gate.
Source: https://docs.hatched.live/docs/reference/widgets/tokens
## Mount
```html
```
The loader mounts every `data-hatched-mount="tokens"` element. Each mount
hits `/widget/tokens` once on init. There are no per-mount attributes —
the wallet is scoped to the buddy carried by the token.
## What it renders
- **Primary balance** — the spendable currency (coins, or whatever the
customer renamed it to in the dashboard), shown as the headline number.
- **Progression tokens** — one row per active progression token (the
earn-only gate currency, XP-like). Each row shows the token's label, a
glyph, the current balance, and — when the token defines a
`max_balance` — a progress bar toward it (`7 / 10 Mastery Stars`).
Tokens without a cap render the balance alone.
Customers without a progression token get a clean single-balance card.
See [Tokens](/docs/concepts/tokens) for the two-tier model behind this.
## Liveness
The primary number stays live: it is read from the shared `/widget/state`
snapshot (`buddy.coins`), so `hatched.track()` round-trips update it
without re-fetching `/widget/tokens`.
Progression balances update from `effects.tokens` returned by `track()`
(format `token_key:amount`), so earning a progression token reflects in
the wallet immediately. A token that did not exist in the wallet at mount
time appears after the next remount.
## Required capability and scopes
- Capability: `tokens` must be enabled for the customer. If it is off the
endpoint returns 403 and the widget renders the error state.
- Scope: `read` — to load the wallet. No write scope is needed; the
tokens widget is read-only (spending happens through gates, not here).
## Theming
The widget honours the customer brand theme. The eight-axis personality
system from the workspace theme (surface, geometry, motion profile,
reward voice, iconography, typography) is forwarded as `data-*`
attributes on the size wrapper, exactly like the other widgets.
---
# Leaderboard widget
> Ranked list surface for the buddy's audience.
Source: https://docs.hatched.live/docs/reference/widgets/leaderboard
## Mount
```html
```
## Required scopes
- `read`
Leaderboards are read-only in the widget runtime. Use your server-side SDK for
admin configuration and metric ingestion.
---
# Marketplace widget
> Browse items, purchase with coins, and equip items on the buddy.
Source: https://docs.hatched.live/docs/reference/widgets/marketplace
## Mount
```html
```
## Required scopes
- `read` renders catalog, ownership, and affordability.
- `marketplace:browse` documents browse intent for session review, but browse
itself is read-only.
- `marketplace:purchase` enables purchases.
- `items:equip` enables equip/unequip.
Read-only embed tokens can display state, but buying and equipping require a
widget session token with the mutation scopes above.
## Appearance updates
Equip and unequip actions render a new buddy image. The action can complete from
cache immediately or return an operation id while `buddy.appearance.status` is
`pending`. During that window the marketplace disables further outfit changes
and shows an appearance banner.
Possible statuses:
- `ready` — current `image_url` reflects the rendered outfit.
- `pending` — an image composite is running.
- `awaiting_credits` — Hatched will retry after image credits are available.
- `failed` — the user or operator must retry or rerender the base.
The widget reads `scopes` from `/widget/state`. If the token lacks
`marketplace:purchase` or `items:equip`, the catalog remains visible but
mutating actions are disabled.
## Rerender recovery
When `appearance.status === 'failed'` and `appearance.error.code === 'needs_rerender'`,
the buddy needs a clean bare stage before items can change.
Use a session token with `items:equip` and call:
```http
POST /api/v1/widget/appearance/rerender
Authorization: Bearer WIDGET_SESSION_TOKEN
```
After the rerender operation returns `ready`, re-equip the desired items.
Rerender clears the rendered item set so the UI never claims an item is visible
when the current image is bare.
---
# Celebrate host
> One-shot celebration overlay for badges, evolutions, and streak milestones.
Source: https://docs.hatched.live/docs/reference/widgets/celebrate
Celebrations are exposed from the shared loader. Include the loader once, then
call the host API when your product wants a moment.
```html
```
```ts
window.__HATCHED_WIDGET__.celebrate({
kind: 'badge',
badge: {
key: 'week_warrior',
label: 'Week Warrior',
description: 'Seven active days in a row',
},
tier: 'bloom',
});
```
The celebrate bundle lazy-loads only when the first celebration is fired.
---
# Webhook payloads
> Every event type Hatched emits, with the shape of the payload you'll receive.
Source: https://docs.hatched.live/docs/reference/webhook-payloads
All webhook requests share the same envelope:
```json
{
"deliveryId": "wh_01HX…",
"eventId": "evt_01HX…",
"type": "badge.awarded",
"customerId": "cus_01HX…",
"createdAt": "2026-04-22T10:30:00Z",
"data": { "...": "see per-type shapes below" }
}
```
Headers on every delivery:
```
Hatched-Signature: t=,v1=
Hatched-Delivery-Id: wh_01HX…
Hatched-Event-Type: badge.awarded
```
Verify with `WebhooksResource.verifySignature` from `@hatched/sdk-js` — see
[Handle webhooks](/docs/guides/handle-webhooks).
## Event types
### buddy.hatched
Emitted when an egg's hatch operation completes.
```json
{
"buddyId": "buddy_01…",
"userId": "user_42",
"configVersionId": "cfg_v12",
"image": { "url": "https://cdn.hatched.live/…", "stage": 1 }
}
```
### buddy.evolved
```json
{
"buddyId": "buddy_01…",
"fromStage": 1,
"toStage": 2,
"image": { "url": "…", "stage": 2 }
}
```
### coin.earned / coin.spent
```json
{
"buddyId": "buddy_01…",
"amount": 10,
"balanceAfter": 120,
"source": { "type": "event", "eventType": "lesson_completed" }
}
```
### token.earned / token.spent
```json
{
"buddyId": "buddy_01…",
"tokenKey": "gem",
"amount": 1,
"balanceAfter": 3,
"source": { "type": "event", "eventType": "weekly_quiz_passed" }
}
```
### skill.leveled
```json
{
"buddyId": "buddy_01…",
"skillKey": "pronunciation",
"fromLevel": 2,
"toLevel": 3,
"value": 65
}
```
### skill.decayed
Fires once per buddy per cadence period when a [decay rule](/docs/concepts/skill-decay)
subtracts skill points. Idempotent — the same period for the same buddy
will only deliver one event even if the sweep is re-run.
```json
{
"buddy_id": "buddy_01…",
"user_id": "user_42",
"skill_key": "vocabulary",
"previous_level": 80,
"new_level": 78,
"delta": -2,
"rule_id": "dec_01…",
"period_key": "2026-05-06"
}
```
A `skill.updated` event with the same change is also emitted so
listeners that already handle `skill.updated` from rule-engine paths
don't need a separate decay branch.
### badge.ready / badge.awarded
`badge.ready` fires for manual badges awaiting review; `badge.awarded`
fires when the badge actually attaches.
```json
{
"buddyId": "buddy_01…",
"badgeId": "badge_week_warrior",
"awardedAt": "2026-04-22T10:30:00Z"
}
```
### streak.ticked / streak.milestone / streak.burned
```json
{
"buddyId": "buddy_01…",
"streakKey": "daily_lesson",
"count": 7,
"milestone": 7
}
```
`streak.milestone` only fires on the configured thresholds. `streak.burned`
fires when the streak resets on a missed day.
### marketplace.purchased / marketplace.equipped
```json
{
"buddyId": "buddy_01…",
"itemId": "item_cowboy_hat",
"price": { "type": "coin", "amount": 50 }
}
```
### evolution.ready
```json
{
"buddyId": "buddy_01…",
"nextStage": 2,
"conditions": { "xp": true, "badgeStreak7": true }
}
```
### leaderboard.snapshot.ready
```json
{
"leaderboardId": "lb_weekly",
"snapshotId": "snap_01…",
"window": { "from": "…", "to": "…" }
}
```
## Delivery semantics
- **At-least-once.** Retries happen on non-2xx. Dedupe on `deliveryId`.
- **No global ordering.** Events for the same buddy _tend_ to arrive in
order but don't rely on it — compare `createdAt` or carry sequence
numbers in `data` if ordering matters.
- **Replay window on your end.** Reject requests where `t=` in the
`Hatched-Signature` header is older than 5 minutes to defend against
replay attacks. The SDK's `verifySignature` does this automatically.
---
# Error codes
> Every stable error code Hatched raises — HTTP status, SDK class, and how to fix.
Source: https://docs.hatched.live/docs/reference/error-codes
{/* AUTO-GENERATED from packages/sdk-js/src/errors.ts by apps/docs/scripts/generate-error-codes.ts. */}
Every error response follows the canonical envelope:
```json
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded. Retry after 60s",
"requestId": "req_abc_123",
"details": { "...": "..." }
}
}
```
The SDK parses this envelope and throws a typed subclass of `HatchedError` with `code`, `statusCode`, `requestId`, and `details`.
## Catalogue
| Code | HTTP | SDK class | Meaning |
|---|---|---|---|
| `unauthorized` | 401 | `UnauthorizedError` | The API key is missing, invalid, or revoked. |
| `forbidden` | 403 | `ForbiddenError` | Key is valid but lacks permission for this endpoint. |
| `publishable_key_scope` | 403 | `PublishableKeyScopeError` | Publishable key cannot mutate — use a secret key server-side. |
| `resource_not_found` | 404 | `NotFoundError` | The referenced id does not exist or was archived. |
| `config_version_mismatch` | 409 | `ConfigVersionMismatchError` | The buddy is pinned to a different config version than expected. |
| `no_published_config` | 409 | `NoPublishedConfigError` | The customer has no published config version yet, so eggs cannot be created. |
| `active_egg_limit` | 409 | `ActiveEggLimitError` | The per-user active-egg cap was reached. `err.details.active` lists the existing eggs (id + status). |
| `conflict` | 409 | `ConflictError` | A competing mutation won; retry is safe if you re-read state first. |
| `validation_failed` | 422 | `ValidationError` | Fields failed schema/business validation. See `err.details.fields`. |
| `insufficient_balance` | 400 | `InsufficientBalanceError` | Buddy does not have enough coins/tokens for the spend. |
| `rate_limited` | 429 | `RateLimitError` | Over the per-minute quota. Honour `err.retryAfter` (seconds). |
| `upstream_image_error` | 502 | `UpstreamImageError` | The art provider failed during hatch/evolve. No ledger writes happened. |
## unauthorized
- **HTTP status:** 401
- **SDK class:** `UnauthorizedError` (from `@hatched/sdk-js`)
- **Meaning:** The API key is missing, invalid, or revoked.
- **Fix:** Double-check `HATCHED_API_KEY`; rotate in Dashboard → Developers if leaked.
- **More:** [Troubleshooting →](/docs/guides/troubleshooting#401-unauthorized)
## forbidden
- **HTTP status:** 403
- **SDK class:** `ForbiddenError` (from `@hatched/sdk-js`)
- **Meaning:** Key is valid but lacks permission for this endpoint.
- **Fix:** Check your plan tier or the key's scope.
## publishable_key_scope
- **HTTP status:** 403
- **SDK class:** `PublishableKeyScopeError` (from `@hatched/sdk-js`)
- **Meaning:** Publishable key cannot mutate — use a secret key server-side.
- **Fix:** Move the call to a server route. See Auth model.
- **More:** [Troubleshooting →](/docs/concepts/auth-model)
## resource_not_found
- **HTTP status:** 404
- **SDK class:** `NotFoundError` (from `@hatched/sdk-js`)
- **Meaning:** The referenced id does not exist or was archived.
- **Fix:** Verify the id from a recent list/create response.
## config_version_mismatch
- **HTTP status:** 409
- **SDK class:** `ConfigVersionMismatchError` (from `@hatched/sdk-js`)
- **Meaning:** The buddy is pinned to a different config version than expected.
- **Fix:** Migrate the buddy or pin your write to its current config.
## no_published_config
- **HTTP status:** 409
- **SDK class:** `NoPublishedConfigError` (from `@hatched/sdk-js`)
- **Meaning:** The customer has no published config version yet, so eggs cannot be created.
- **Fix:** Publish the gamification plan first. `err.details.publish_url` points at the dashboard publish page.
- **More:** [Troubleshooting →](/docs/guides/first-user-bootstrap)
## active_egg_limit
- **HTTP status:** 409
- **SDK class:** `ActiveEggLimitError` (from `@hatched/sdk-js`)
- **Meaning:** The per-user active-egg cap was reached. `err.details.active` lists the existing eggs (id + status).
- **Fix:** Hatch or cancel one of the listed eggs, or retry the create with `?ensure=true` (`eggs.create({ ..., ensure: true })`) to reuse one.
- **More:** [Troubleshooting →](/docs/guides/first-user-bootstrap#common-pitfalls)
## conflict
- **HTTP status:** 409
- **SDK class:** `ConflictError` (from `@hatched/sdk-js`)
- **Meaning:** A competing mutation won; retry is safe if you re-read state first.
- **Fix:** Re-fetch the resource and retry the mutation.
## validation_failed
- **HTTP status:** 422
- **SDK class:** `ValidationError` (from `@hatched/sdk-js`)
- **Meaning:** Fields failed schema/business validation. See `err.details.fields`.
- **Fix:** Log `err.details`; fix the failing field and resend.
- **More:** [Troubleshooting →](/docs/guides/troubleshooting#422-validation-failed)
## insufficient_balance
- **HTTP status:** 400
- **SDK class:** `InsufficientBalanceError` (from `@hatched/sdk-js`)
- **Meaning:** Buddy does not have enough coins/tokens for the spend.
- **Fix:** Check `err.balance` / `err.required`; surface to the user.
## rate_limited
- **HTTP status:** 429
- **SDK class:** `RateLimitError` (from `@hatched/sdk-js`)
- **Meaning:** Over the per-minute quota. Honour `err.retryAfter` (seconds).
- **Fix:** Let the SDK retry (default on) or backoff manually.
- **More:** [Troubleshooting →](/docs/guides/troubleshooting#429-too-many-requests)
## upstream_image_error
- **HTTP status:** 502
- **SDK class:** `UpstreamImageError` (from `@hatched/sdk-js`)
- **Meaning:** The art provider failed during hatch/evolve. No ledger writes happened.
- **Fix:** Re-call the operation; idempotent.
- **More:** [Troubleshooting →](/docs/guides/troubleshooting#502-upstream-image-error)
## Programmatic handling
```ts
import {
HatchedError,
ValidationError,
RateLimitError,
InsufficientBalanceError,
} from '@hatched/sdk-js';
try {
await hatched.buddies.spend(buddyId, { amount: 100, reason: "item" });
} catch (err) {
if (err instanceof InsufficientBalanceError) {
return showInsufficientFunds(err.balance, err.required);
}
if (err instanceof ValidationError) {
return showFieldErrors(err.details);
}
if (err instanceof RateLimitError) {
return retryLater(err.retryAfter);
}
if (err instanceof HatchedError) {
console.error(err.code, err.requestId, err.message);
}
throw err;
}
```
---
# Rate limits
> Per-customer and per-endpoint quotas, the 429 response shape, and how to back off gracefully.
Source: https://docs.hatched.live/docs/reference/rate-limits
Rate limits protect shared infrastructure. They're generous for normal
product integrations but exist to prevent runaway loops.
## Quotas
| Surface | Limit | Window |
| --- | --- | --- |
| `POST /events` | 500 req | 1 second per customer |
| `POST /eggs` / `POST /eggs/:id/hatch` | 60 req | 1 minute per customer |
| `POST /widget-sessions` | 300 req | 1 minute per customer |
| All other write endpoints | 60 req | 1 second per customer |
| All read endpoints | 300 req | 1 second per customer |
| Widget bundle fetches (CDN) | not rate-limited | — |
Higher tiers lift the event ingestion ceiling. Talk to sales if you need
more than 500 events/s.
## The 429 response
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 2
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Too many requests on POST /events (500/s)",
"details": { "retryAfter": 2 },
"requestId": "…"
}
}
```
The SDK surfaces this as `RateLimitError` with `.retryAfter` populated (in
seconds).
## Built-in retry
The SDK retries 429s automatically (honouring `Retry-After`) up to
`maxRetries` (default 3) with exponential backoff + jitter. You only need
manual backoff for sustained overages or custom queue drains.
```ts
new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
maxRetries: 5, // default 3
});
```
## Backoff pattern
```ts
import { RateLimitError } from '@hatched/sdk-js';
async function sendWithBackoff(event) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await hatched.events.send(event);
} catch (err) {
if (err instanceof RateLimitError) {
await sleep((err.retryAfter + Math.random()) * 1000);
continue;
}
throw err;
}
}
throw new Error('rate limit exhausted');
}
```
Add jitter (the `Math.random()` term) so concurrent callers don't all
retry on the same millisecond.
## Bulk ingestion
For high-volume backfills, don't serialise through `events.send`. Instead:
1. Batch historical events into a single file.
2. Upload via the bulk-ingest endpoint (see
[HTTP API](/docs/reference/http-api)).
3. The bulk endpoint bypasses the per-second cap and processes
asynchronously with its own throughput quota.
## Headers
Every response includes:
```
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 498
X-RateLimit-Reset: 1745327400
```
Use them to pace yourself *before* a 429 fires.
---
# Changelog
> Release notes for @hatched/sdk-js — mirrored from the package's CHANGELOG.md.
Source: https://docs.hatched.live/docs/reference/changelog
{/* AUTO-MIRRORED from packages/sdk-js/CHANGELOG.md by apps/docs/scripts/generate-changelog.ts. */}
Release notes for `@hatched/sdk-js`. Produced by [changesets](https://github.com/changesets/changesets) on every merge to `main`.
# @hatched/sdk-js
## 1.0.0
### Major Changes
- 6dff237: PathDisplayMode renamed: replace `'path'` with `'straight'` (clean centered column) and add `'zigzag'` (Duolingo-style alternating sides). Existing `'path'` consumers must migrate to `'straight'`. The legacy half-zigzag rendering (node offset, text static) is removed.
### Minor Changes
- d73cafe: First-run bootstrap ergonomics: `eggs.create({ userId, ensure: true })` now reuses the user's existing waiting/ready egg instead of creating a new one (avoids the per-user active-egg cap on retries). `Egg` responses include `buddyId` (non-null once the egg is hatched). Two new typed 409 errors are surfaced: `NoPublishedConfigError` (raised by `eggs.create` before a config version is published — exposes `publishUrl`) and `ActiveEggLimitError` (raised when the active-egg cap is hit — exposes `max` and `active[]` with the existing egg ids/statuses).
## 0.5.0
### Minor Changes
- d758872: Add `paths` resource — guided multi-step journeys (Duolingo-style). New
`client.paths` exposes admin CRUD over path definitions, steps, and
sub-steps; `setActive()` flips the audience's single active path
atomically; `getForBuddy()` returns the buddy-scoped runtime payload
with locked / available / completed sub-step states; `completeSubStep()`
manually marks a sub-step done and returns cascade flags so callers can
celebrate without an extra round-trip. Sub-steps support an optional
`contentUrl` + `ctaLabel` for deep-linking into a customer's LMS.
`events.send()` response now also exposes `streakUpdates` and
`pathUpdates` on the returned `EventEffects`, so a single track call can
drive streak counters and path widgets in addition to coins/badges
without an extra fetch.
- 42907d4: Added new feature: path and performance improvements
## 0.4.4
### Patch Changes
- ea76743: Republish 0.4.3 with corrected package metadata: `repository.url` and `bugs.url` now point to the public SDK repo at `github.com/hatched-live/hatched-sdk-js`. (0.4.3 was never published to npm.)
## 0.4.3
### Patch
- **Buddy appearance types** — `Buddy` now exposes `baseImageUrl`, typed equipped item objects, and the `appearance` status block used by the persistent AI compositing pipeline.
- **Equip result typing** — `buddies.equip()` now returns a typed `{ accepted, operationId, status, appearanceStatus, cached }` result so clients can distinguish instant cache hits from queued appearance generation.
- **Rerender appearance** — new `buddies.rerenderAppearance(buddyId)` method backed by `POST /buddies/:id/appearance/rerender`. Use when `appearance.status === 'failed'` (especially `error.code === 'needs_rerender'` after the appearance pipeline migration) to regenerate the bare stage image. Equipped items are cleared from the rendered set; re-equip after status returns to `ready`.
### Behavior change (non-breaking type-wise, breaking semantically)
- **Evolution no longer blocks on item composite failure.** Previously, if the
bare stage image succeeded but compositing equipped items failed, the buddy
stayed on the prior stage and the evolve operation was marked failed. Now the
buddy advances to the new stage with its bare base image and `appearance`
reports `failed` or `awaiting_credits`. The evolve operation completes
successfully and the appearance recovery flow re-attempts the composite.
- **Awaiting-credits self-recovery.** When equip/evolve hits a credit limit, an
internal scheduler retries with exponential backoff (60s → 5m → 15m → 30m).
Clients should poll `buddy.appearance` rather than the original operation
status to track final resolution.
## 0.4.1
### Patch
- **Docs domain** — all references now point to `docs.hatched.live` (single canonical domain). The legacy `docs.hatched.com` host is retired; error messages, README links, and `package.json.homepage` have been updated. No API or behavior changes.
## 0.4.0
### Minor
- **Publishable-key hardening** — browser keys can mint scoped read-only embed tokens, but no longer mint interactive widget sessions. Interactive session tokens stay server-only.
- **Webhooks resource alignment** — SDK methods now target the production `/webhook-configs` API and unwrap dashboard response envelopes.
- **Docs/examples refresh** — README snippets, widget examples, and auth guidance now match the current CDN loader and `Authorization: Bearer` API model.
## 0.3.0
### Major
- **Two-tier token model**. Tokens now have an explicit `kind`: `primary` (spendable via `buddies.spend`, marketplace purchases, gate unlocks) or `progression` (earn-only, feeds evolution readiness). `token_config` DTOs unlocked from the legacy 4-tuple (`hatch_token`/`evolution_token`/`reroll_token`/`gift_token`) — customers now pick their own `token_key` (e.g. `gems`, `mana`, `xp`). Spending a progression token returns `progression_not_spendable`.
- **Canonical item categories**. The two coexisting taxonomies (`hat`/`held_item`/… vs `headwear`/`eyewear`/…) collapse into 8 canonical slots: `background`, `body`, `feet`, `hand`, `neck`, `face`, `head`, `accessory`. Migration 023 normalises existing rows and locks the column via CHECK.
### Minor
- **`buddies.tokens(buddyId)`** — typed primary + progression balance snapshot with lifetime earn/spend sums.
- **`buddies.evolutions(buddyId)`** — paginated stage-transition timeline (prod + demo). Backed by a new `buddy_evolutions` table that captures every evolve with its image and trigger event.
- **`GatesResource`** — new `hatched.gates.unlock(buddyId, gateKey)` / `unlocks(buddyId)` primitive. Customers author gates in the dashboard (`gate_key`, `token_key`, `cost`, `metadata`); end-users spend tokens to unlock features. Unlock is idempotent — repeat calls return `alreadyUnlocked: true` without touching the economy.
- **Equip safety rails** — `TooManyItemsError` (max 4 equipped) and `CategoryConflictError` (two non-accessory items in the same category) surface at the SDK layer with `details` carrying the specifics.
- **Stage-aware item artwork** — `items.stage_image_urls` jsonb lets designers ship stage-2-specific hats; the composite pipeline picks the right variant per stage.
- **Evolve×equip pipeline (pre-0.4.3)** — the initial implementation attempted to block `operations.wait(evolveOp)` until equipped items were composited. In 0.4.3 this was replaced by `buddy.appearance` status recovery so stage advancement can complete even when item compositing is delayed.
- **Theme-aware defaults** — empty marketplace or token bundle at onboarding seeds from a theme-appropriate catalog (fantasy → gems/mana + fantasy items, fitness → reps/streaks, etc.). Source tracked in `customers.settings.applied_sources`.
## 0.2.1
### Patch
- **Docs**: README now has a dedicated "Two ways to authenticate" section with a secret-vs-publishable key comparison, a browser-safe publishable-key example, and a per-resource secret/publishable capability matrix. No code changes.
## 0.2.0
### Major
- **camelCase public surface** — all params and response fields exposed by the SDK are now camelCase. Snake_case wire format is converted transparently. Migration: rename `user_id` → `userId`, `event_id` → `eventId`, `occurred_at` → `occurredAt`, etc. The same applies to response fields (`egg.egg_id` → `egg.eggId`, `op.operation_id` → `op.operationId`).
- **Operation.wait** — `operations.waitForCompletion` has been replaced with the shorter `operations.wait`. The old name is still exported as a deprecated alias.
### Minor
- **Server-only runtime guard** — the SDK now throws when constructed in a browser-like environment with a secret key. Override with `allowBrowser: true` (test-only).
- **Publishable key support** — browser-safe `HatchedClient({ publishableKey })` constructor variant. Only read endpoints and `embedTokens.create` are allowed; mutation methods return `PublishableKeyScopeError` at runtime.
- **Automatic retries** — exponential backoff + jitter on network errors, 408, 429 (retry-after honoured), and 5xx. Configurable via `maxRetries` (default 3).
- **AbortSignal on every method** — pass `signal` to cancel in-flight requests; combined with the internal timeout via `AbortSignal.any`.
- **Request id tracking** — `hatched.getLastRequestId()` exposes the `X-Request-Id` of the most recent response. SDK-generated request ids are sent on every call.
- **Webhooks resource** — `hatched.webhooks.list/create/delete/deliveries/replay` + `WebhooksResource.verifySignature(rawBody, header, secret)` for `Hatched-Signature` verification.
- **New error classes** — `AuthError` (base for 401/403), `PublishableKeyScopeError`, `ConfigVersionMismatchError`, and a `ResourceNotFoundError` alias for `NotFoundError`.
- **tsup dual build** — `dist/index.mjs`, `dist/index.cjs`, plus `.d.ts`/`.d.cts`. Subpath exports for tree-shaking: `@hatched/sdk-js/errors`, `@hatched/sdk-js/webhooks`.
- **`sideEffects: false`** — enables aggressive tree-shaking by bundlers.
- **`timeoutMs` alias** — equivalent to `timeout`, aligns with the docs.
- **`fetch` override** — supply a custom `fetch` implementation for edge runtimes and tests.
### Fixes
- Correct URL concatenation — paths now preserve the base `/api/v1` prefix (previously absolute paths could drop it).
## 0.1.1
Initial private-preview release.
---
# Pricing
> Fixed platform fee + metered AI credits. Four plans (Free, Growth, Pro, Enterprise).
Source: https://docs.hatched.live/docs/billing/pricing
Hatched separates **platform fee** from **AI usage**.
- The platform fee covers everything that's free to run at scale: events,
rules engine, widgets, analytics, SDK, webhooks, dashboard, config versions.
- AI usage — image generation, onboarding chat, plan/theme/guide generation —
is metered in **credits** at a flat rate: **1 credit = $0.10 = 1 completed AI job**.
## Plans
| Plan | Monthly | Events / mo | Credits included / mo | Welcome credits |
| ------------ | ------- | ----------- | --------------------- | --------------- |
| Free | $0 | 10,000 | 0 | 20 (one-time) |
| Growth | $149 | 500,000 | 50 | — |
| Pro | $499 | 5,000,000 | 250 | — |
| Enterprise | custom | contract | contract | — |
Billing is monthly via Stripe. Annual billing is available on Growth/Pro at
~17% discount (`$1,490/yr` and `$4,990/yr`).
## Feature gates
| Capability | Free | Growth | Pro | Enterprise |
| ----------------------- | ---- | ------ | --- | ---------- |
| Skills, coins, badges, streaks, leaderboards | ✓ | ✓ | ✓ | ✓ |
| All widgets | ✓ | ✓ | ✓ | ✓ |
| Webhooks, SDK, API keys | ✓ | ✓ | ✓ | ✓ |
| Evolution (preset art) | ✓ | ✓ | ✓ | ✓ |
| Evolution (generative) | — | ✓ | ✓ | ✓ |
| Marketplace | — | ✓ | ✓ | ✓ |
| Tokens (secondary currency) | — | ✓ | ✓ | ✓ |
| Multi-audience | — | — | up to 3 | contract |
| Advanced analytics (retention, cohorts) | — | — | ✓ | ✓ |
| Generative media | 4 during onboarding | ✓ | ✓ | ✓ |
When a plan-locked endpoint is called, the API returns `403 plan_feature_locked`
with `details.required_plan` so the caller can prompt an upgrade.
## Event quota enforcement
Each plan has a monthly event ingestion quota. Requests include
`X-Event-Quota-*` response headers; crossing 80% triggers a dashboard banner
and a `usage.threshold_reached` webhook; crossing 100% returns
`402 event_quota_exceeded` until the monthly reset (first of the next UTC month)
or a plan upgrade.
## Related
- [Credits](/docs/billing/credits)
- [Stripe portal](/docs/billing/stripe-portal)
- [Handling 402](/docs/billing/handling-402)
---
# Credits
> How the credit pool works, which jobs cost credits, and the spend order.
Source: https://docs.hatched.live/docs/billing/credits
**1 credit = $0.10 = 1 completed AI job.**
Every call to Hatched's generative pipeline authorizes one credit up-front,
runs, then commits (success) or rolls back (failure). Preset and cache-hit
paths cost zero credits.
## Job types
| Job | Credits |
| ---------------------------- | ------- |
| Onboarding chat turn | 1 |
| Website scan | 1 |
| Plan generation / regeneration | 1 |
| Theme synthesis | 1 |
| Integration guide | 1 |
| Hatch (creature) | 1 |
| Equip / composite edit | 1 |
| Evolve (generative) | 1 |
| Badge icon | 1 |
| Skill icon | 1 |
| Marketplace item image | 1 |
| Stage asset | 1 |
| Preset / cache-hit evolution | 0 |
| Automatic asset prompt preparation | 0 |
## Three pools
Credits live in three pools, spent in this order:
1. **promo** — time-limited (30-day expiry), spent first.
2. **welcome** — 20 one-time credits granted on signup, never expires while
the account exists.
3. **paid** — top-ups and subscription grants. Top-up credits persist while
the account exists and the payment is not refunded.
A single 1-credit job debits from exactly one pool — no splitting across
pools. If the pool with enough balance for the requested cost exists, we use
it; otherwise the API returns `402 credit_insufficient`.
## Monthly subscription grant
Growth grants 50 credits each month on successful `invoice.payment_succeeded`.
Pro grants 250. Granted credits go to the **paid** pool. Paid credits do not
expire until the subscription ends.
## Top-up bundles
Bought via Stripe Checkout from the Billing page. Available on every plan,
including Free:
| Bundle | Price | Effective per-credit |
| --------- | ----- | -------------------- |
| 100 | $10 | $0.100 |
| 500 | $50 | $0.100 |
| 1,000 | $99 | $0.099 |
## Reading the balance
```http
GET /api/v1/credits/balance
Authorization: Bearer hatch_live_…
```
```jsonc
{
"welcome": 7,
"paid": 43,
"promo": 0,
"promo_expires_at": null,
"total_spendable": 50
}
```
Every authenticated response also includes:
- `X-Credits-Remaining`
- `X-Credits-Welcome-Remaining`
- `X-Credits-Paid-Remaining`
- `X-Credits-Promo-Remaining`
## Ledger
`GET /api/v1/credits/ledger?limit=50` returns the 50 most recent AI usage
rows (authorize/commit/rollback) — the same rows the dashboard's Billing
page displays.
## Onboarding cap
During the very first publish, Hatched generates at most **4 images**
(creature, one stage preview, one badge, one item). Remaining badge/item
icons stay in `pending` and surface as "Generate now" actions in the
dashboard — the operator pays 1 credit per asset they actually need.
---
# Stripe portal
> Subscription management, invoices, and top-up purchases.
Source: https://docs.hatched.live/docs/billing/stripe-portal
All subscription and top-up actions route through the **Stripe Customer
Portal**. Hatched does not ship its own billing UI — the portal is the single
source of truth for payment methods, invoices, plan switches, and credit
bundle purchases.
## Open the portal
From the dashboard: **Billing → Manage billing** opens a portal session
scoped to the signed-in customer. Under the hood:
```http
POST /api/v1/billing/portal
Authorization: Bearer
Content-Type: application/json
{ "flow": "default" }
```
```jsonc
{ "portal_url": "https://billing.stripe.com/p/session/…" }
```
`flow` can be:
- `default` — the full portal (subscription, invoices, payment method, credit add-ons)
- `top_up` — deep-link to the credit bundle add-on flow
- `cancel` — deep-link to the cancel confirm
## Subscription checkout (for new customers)
Free plan customers upgrading to Growth or Pro:
```http
POST /api/v1/billing/checkout
Content-Type: application/json
{ "flow": "subscription", "plan": "growth" }
```
```jsonc
{ "checkout_url": "https://checkout.stripe.com/c/pay/cs_…" }
```
## One-off credit bundle
Top-ups use a one-off Stripe Checkout session (or the portal's add-on UI).
```http
POST /api/v1/billing/checkout
Content-Type: application/json
{ "flow": "credit_bundle", "credit_bundle": "100" }
```
Valid bundle keys: `"100"`, `"500"`, `"1000"`.
The `checkout.session.completed` webhook (mode=payment) grants credits into
the **paid** pool atomically, keyed on `stripe_event_id` so a double delivery
is a no-op.
## Webhook handling
Hatched subscribes to the following Stripe events:
| Event | Effect |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `checkout.session.completed` (subscription) | Set `customer.plan`, grant the plan's included credits — full 12-month allotment upfront for annual, one month's worth for monthly. |
| `checkout.session.completed` (payment) | Top-up: split the bundle into `paid` pool (non-expiring) + `promo` pool (bonus credits, expire after `bonus_expires_days`). |
| `invoice.payment_succeeded` (subscription_cycle) | Grant the plan's included credits for the cycle — 12 months upfront on annual renewal, one month on monthly renewal. |
| `invoice.payment_failed` | Set `billing_status = past_due`. |
| `customer.subscription.updated` | Reconcile plan and status from Stripe truth. |
| `customer.subscription.deleted` | Downgrade to `starter`, keep paid credits. |
| `charge.refunded` | Logged; manual credit reversal required for top-ups. |
All credit grants are idempotent on `stripe_event_id` via
`credit_transactions.uq_credit_tx_stripe_event`.
## Stripe product setup
One-time setup in the Stripe dashboard:
1. Create two recurring products for plans:
- `Hatched Growth Monthly` → `price_growth_monthly` → env `STRIPE_GROWTH_PRICE_ID`
- `Hatched Growth Annual` → `price_growth_annual` → env `STRIPE_GROWTH_ANNUAL_PRICE_ID`
- `Hatched Pro Monthly` → `price_pro_monthly` → env `STRIPE_PRO_PRICE_ID`
- `Hatched Pro Annual` → `price_pro_annual` → env `STRIPE_PRO_ANNUAL_PRICE_ID`
2. Create four one-off products for top-ups:
- 100 credits · $10 → env `STRIPE_CREDITS_100_PRICE_ID`
- 500 credits + 50 bonus · $50 → env `STRIPE_CREDITS_500_PRICE_ID`
- 1,000 credits + 150 bonus · $99 → env `STRIPE_CREDITS_1000_PRICE_ID`
- 2,500 credits + 500 bonus · $249 → env `STRIPE_CREDITS_2500_PRICE_ID`
3. In **Customer Portal**, enable subscription update (Growth ↔ Pro), cancel,
invoice history, and **customer-initiated one-off purchases** scoped to
the four credit bundle products. Save the configuration id to
`STRIPE_PORTAL_CONFIGURATION_ID`.
---
# Handling 402 responses
> credit_insufficient, event_quota_exceeded — what they mean and how to recover.
Source: https://docs.hatched.live/docs/billing/handling-402
Hatched uses HTTP `402 Payment Required` for two conditions the caller can
fix by topping up or upgrading:
- `credit_insufficient` — no pool has enough credits for the requested AI job.
- `event_quota_exceeded` — the monthly event quota for the plan is exhausted.
Plus `403 plan_feature_locked` when a plan doesn't include the requested feature
at all (e.g. Free plan hitting `/marketplace/*`).
## Envelope
All three errors share the canonical envelope:
```jsonc
{
"error": {
"code": "credit_insufficient",
"message": "Not enough credits for this AI job (need 1, have 0).",
"details": {
"required": 1,
"available": 0,
"welcome": 0,
"paid": 0,
"promo": 0,
"upgrade_url": "https://app.hatched.dev/dashboard/billing",
"top_up_url": "https://app.hatched.dev/dashboard/billing?action=top_up"
},
"requestId": "req_abc123"
}
}
```
## Do NOT retry
Neither 402 nor 403 are transient. **Do not wrap them in exponential backoff**.
The SDK's built-in retry only kicks in for 429 and upstream 5xx; 402/403 are
surfaced to the caller immediately.
## Recover
- `credit_insufficient` → send the user to `details.upgrade_url` or
`details.top_up_url` (both open the Stripe portal).
- `event_quota_exceeded` → back off until `details.reset_at` (first of next
UTC month) or upgrade to a higher plan.
- `plan_feature_locked` → prompt upgrade to `details.required_plan`.
## SDK recipe
```ts
import {
HatchedClient,
CreditInsufficientError,
EventQuotaExceededError,
PlanFeatureLockedError,
} from '@hatched/sdk-js';
const hatched = new HatchedClient({ apiKey: process.env.HATCHED_SECRET_KEY! });
try {
await hatched.events.send({ userId: 'u_1', type: 'lesson_completed' });
} catch (err) {
if (err instanceof EventQuotaExceededError) {
console.warn(
`Event quota exceeded (${err.used}/${err.limit}). Resets ${err.resetAt}.`,
);
redirect(err.upgradeUrl!);
} else if (err instanceof CreditInsufficientError) {
redirect(err.topUpUrl ?? err.upgradeUrl!);
} else if (err instanceof PlanFeatureLockedError) {
showUpgradePrompt(err.requiredPlan, err.upgradeUrl);
} else {
throw err;
}
}
```
## Response headers
Every authenticated response includes credit / quota metadata in headers so
you can warn the operator before they hit the wall:
- `X-Credits-Remaining`, `X-Credits-Welcome-Remaining`, `X-Credits-Paid-Remaining`, `X-Credits-Promo-Remaining`
- `X-Event-Quota-Limit`, `X-Event-Quota-Used`, `X-Event-Quota-Remaining`, `X-Event-Quota-Reset-At`
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
## Webhook signal
When a customer crosses 80% of their monthly event quota we emit one
`usage.threshold_reached` webhook event (`limit_type: 'event_quota'`). The
100% boundary is not webhooked — it is hard enforced via 402 and surfaced
in the dashboard banner.
---
# Login widget
> Historical note for the retired login widget.
Source: https://docs.hatched.live/docs/reference/widgets/login
The current widget loader does not ship a login widget. Hatched assumes your
product owns authentication, then your backend mints widget session tokens for
the signed-in user.
Use [Widget integration](/docs/guides/widget-integration) for the supported
flow.