# Webhook payloads

> Common event payloads Hatched emits, with the shape of the body you'll receive.

Source: https://docs.hatched.live/docs/reference/webhook-payloads

There is no wrapping envelope. The POST body **is** the raw event payload —
the per-type object shown below, with `snake_case` keys. Event metadata
travels only in HTTP headers, never in the body.

Headers on every delivery:

```
Content-Type: application/json
X-Hatched-Event: badge.awarded
X-Hatched-Delivery: <delivery uuid>
X-Hatched-Timestamp: <unix_seconds>
X-Hatched-Signature: sha256=<hex HMAC-SHA256 over `${timestamp}.${rawBody}`>
```

- `X-Hatched-Event` — the event name (e.g. `badge.awarded`).
- `X-Hatched-Delivery` — the delivery id. **This is the dedupe key** — there
  is no `deliveryId` in the body.
- `X-Hatched-Timestamp` — its own header (unix seconds), not embedded in the
  signature.
- `X-Hatched-Signature` — `sha256=<hex>`, where the hex is `HMAC-SHA256`
  of `` `${timestamp}.${rawBody}` ``.

Verify with `WebhooksResource.verifySignature` from `@hatched/sdk-js` — see
[Handle webhooks](/docs/guides/handle-webhooks).

## Event types

The shapes below cover the most commonly subscribed events. They are not the
full catalog — Hatched emits many more (mystery box, lottery, league, kudos,
group quest, booster, and others). The canonical, always-current list of every
event name is served by `GET /webhook-configs/events`; treat that endpoint as
the source of truth for what you can subscribe to.

### buddy.hatched

Emitted when an egg's hatch operation completes.

```json
{
  "buddy_id": "buddy_01…",
  "egg_id": "egg_01…",
  "user_id": "user_42",
  "name": "Pip",
  "image_url": "https://cdn.hatched.live/…",
  "thumb_url": "https://cdn.hatched.live/…",
  "evolution_stage": 1
}
```

### buddy.evolved

```json
{
  "buddy_id": "buddy_01…",
  "previous_stage": 1,
  "new_stage": 2,
  "image_url": "https://cdn.hatched.live/…"
}
```

### coins.earned / coins.spent

```json
{
  "buddy_id": "buddy_01…",
  "amount": 10,
  "balance": 120,
  "reason": "lesson_completed",
  "reference_id": "ref_01…",
  "ledger_id": "ledger_01…",
  "is_lucky": false
}
```

### token.earned / token.spent

```json
{
  "buddy_id": "buddy_01…",
  "token_type": "gem",
  "amount": 1,
  "reason": "rule:weekly_quiz_passed"
}
```

### gate.unlocked

Fires when a buddy spends tokens to unlock a token gate.

```json
{
  "buddy_id": "buddy_01…",
  "gate_key": "premium_lessons",
  "token_key": "gem",
  "cost": 5,
  "unlocked_at": "2026-06-01T12:00:00.000Z"
}
```

### skill.level_up

```json
{
  "buddy_id": "buddy_01…",
  "skill_key": "pronunciation",
  "level": 3,
  "previous": 2,
  "change": 1
}
```

### skill.updated

Fires whenever a buddy's skill value changes — including rule-engine reward
paths. Use this when you want every skill movement rather than only the
threshold crossings that `skill.level_up` reports.

```json
{
  "buddy_id": "buddy_01…",
  "updates": {
    "vocabulary": { "level": 78, "previous": 80, "change": -2 }
  }
}
```

### skill.decayed

Fires when a skill-decay rule lowers (or refreshes) a buddy's skill level for
a decay period. A drop also emits a paired `skill.updated`.

```json
{
  "buddy_id": "buddy_01…",
  "user_id": "user_123",
  "skill_key": "vocabulary",
  "previous_level": 80,
  "new_level": 78,
  "delta": -2,
  "rule_id": "sdr_01…",
  "period_key": "2026-W22"
}
```

### badge.ready / badge.awarded

`badge.ready` fires for manual badges awaiting review; `badge.awarded`
fires when the badge actually attaches. `badge.ready` carries only
`{ buddy_id, badge_key }`; `badge.awarded` carries the full shape below.

```json
{
  "buddy_id": "buddy_01…",
  "badge_key": "week_warrior",
  "label": "Week Warrior",
  "awarded_at": "2026-04-22T10:30:00Z",
  "coin_reward": 50,
  "reason": null
}
```

### streak.milestone

```json
{
  "buddy_id": "buddy_01…",
  "current_streak": 7,
  "longest_streak": 12,
  "milestone": 7,
  "event_id": "evt_01…"
}
```

`streak.milestone` only fires on the configured thresholds. To react when a
streak is about to lapse, subscribe to `streak.at_risk`.

### item.purchased / item.equipped

```json
{
  "buddy_id": "buddy_01…",
  "item_id": "item_cowboy_hat",
  "item_name": "Cowboy Hat",
  "price_paid": 50,
  "purchase_id": "purchase_01…"
}
```

### evolution.ready

```json
{
  "buddy_id": "buddy_01…",
  "current_stage": 1,
  "next_stage": 2,
  "progress": 1,
  "auto_evolve": false
}
```

### buddy.prestiged

Fires when a buddy crosses the Prestige threshold and resets its evolution
stage with the prestige counter incremented. The buddy keeps its history;
the public share page picks up a prestige aura automatically.

```json
{
  "buddy_id": "buddy_01…",
  "prestige_level": 1,
  "from_evolution_stage": 5,
  "season_id": "season_2026q2",
  "occurred_at": "2026-05-25T10:30:00Z"
}
```

### team_membership.role_upgraded

Fires when a buddy is granted a higher role within a Team — currently the
only upgrade path is to `mentor` (after meeting the configured mentor-hour
threshold). Use this to surface a "you're now a mentor" notification in your
product.

```json
{
  "buddy_id": "buddy_01…",
  "from_role": "member",
  "to_role": "mentor",
  "occurred_at": "2026-05-25T10:30:00Z"
}
```

### cause.threshold_reached

Symbolic Humanity Hero counter crossed a configured unit threshold
(e.g. every 100 trees planted, every 1,000 meals served). Unlike other
webhook types, **this event delivers to the cause's own `webhook_url`** —
configured per-cause in the dashboard — rather than the customer-wide
endpoints. Tenants typically wire this to a charity API or internal
reporting service.

```json
{
  "event": "cause.threshold_reached",
  "cause_id": "cause_01…",
  "cause_key": "trees_planted",
  "customer_id": "cus_01…",
  "total_units": 1300,
  "threshold_unit": 1300,
  "is_test": false,
  "occurred_at": "2026-05-25T10:30:00Z"
}
```

Unlike the other events, the cause payload **embeds the event name and
timestamp in the body** (`event` + `occurred_at`) rather than relying solely
on the headers — this is a deliberate distinct shape, kept for compatibility
with charity/reporting consumers. The delivery is still HMAC-signed the same
way as every other webhook (same headers, same signature). `is_test` is true
when the dashboard's "Send test delivery" button triggers the call.

### council.proposal_approved

Fires when a Council proposal (UGC narrative line for a level-up slot) is
approved by the configured moderator quorum. Use this to ship the new copy
to your product — for example, refreshing localized strings in your CMS or
re-rendering cached share pages.

```json
{
  "customer_id": "cus_01…",
  "proposal_id": "council_prop_01…",
  "target_slot": "level_up_copy.stage_3",
  "occurred_at": "2026-05-25T10:30:00Z"
}
```

`target_slot` is dot-notation into the customer's `narrative` JSONB —
i.e. `level_up_copy.stage_3` points at the level-3 evolution flavor text.
This lets a downstream system know exactly which surface to invalidate.

## Delivery semantics

- **At-least-once.** Retries happen on non-2xx. Dedupe on the
  `X-Hatched-Delivery` header.
- **No global ordering.** Events for the same buddy _tend_ to arrive in
  order but don't rely on it — carry your own sequence numbers in the payload
  if ordering matters.
- **Replay window on your end.** Reject requests where the
  `X-Hatched-Timestamp` header is older than 5 minutes to defend against
  replay attacks. The SDK's `verifySignature` does this automatically when
  you pass the timestamp via `options.timestamp` (the framework adapters
  extract it for you).
