# 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/<key>` 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 `<div data-hatched-mount="buddy">` stays empty. No
network requests in DevTools.

**Why.** Checklist:

1. The `<script src=".../widget.js">` tag is missing or loaded _after_
   the widget div renders, and you never called `window.__HATCHED_WIDGET__.init()`
   manually.
2. `data-session-token` or `data-embed-token` is empty / invalid / expired.
3. A CSP blocks the loader script (`connect-src`, `script-src`).
4. The page uses strict mode + hydration, and the widget div is
   client-rendered after the loader already ran. Call
   `window.__HATCHED_WIDGET__.init({ token })` after hydration.

**Fix.**

```html
<script
  src="https://cdn.hatched.live/widget.js"
  data-session-token="{{ mint_in_request }}"
  defer
></script>
<div data-hatched-mount="buddy"></div>
<script>
  document.addEventListener('DOMContentLoaded', () => window.__HATCHED_WIDGET__?.init());
</script>
```

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