# 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));
  }
}
```

## 400 bad_request

**Signal.** `HatchedError` with `err.code === 'bad_request'` and `err.statusCode === 400`.

**Why.** A service or controller rejected the request with an explicit
domain check — not a schema/field problem. Examples: a `from` date that
falls after `to`, an unparseable date filter, a malformed query argument.
Request-body and DTO validation does **not** land here; that surfaces as
`422 validation_failed` (below).

**Fix.** Read `err.message` (and `err.details` when present) to see which
input the endpoint refused, correct it, and resend. Do not retry blindly —
the same input fails the same way.

## 422 validation_failed

**Signal.** `ValidationError: Validation failed` with a `details` payload
carrying a `fields` map.

**Why.** Request input failed validation. This one code covers every
validation path so you only handle one error class:

- **DTO validation** — a body field is missing, the wrong type, or an
  unknown property (the global `ValidationPipe` rejects it).
- **Zod-parsed bodies** — endpoints that `Schema.parse(body)` directly.
- **Business rules** — e.g. event `type` not registered, `eventId` collision.

**Fix.** Read `err.details.fields` — a `{ "<field path>": ["<message>", …] }`
map you can drop straight onto a form:

```ts
catch (err) {
  if (err instanceof ValidationError) {
    console.error('fields:', JSON.stringify(err.details.fields, null, 2));
  }
}
```

Typical shape:

```json
{
  "fields": {
    "properties.score": ["must be a number"]
  }
}
```

Zod-parsed endpoints additionally include `details.issues` (the raw Zod
issue array) alongside the same `fields` map.

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

## CORS — diagnose the failure mode first

Browser console errors that *look* like CORS can come from at least four
distinct places. Pick the matching row before changing settings.

| What you see in the console / network panel | Origin of the block | Where to fix |
| --- | --- | --- |
| `Access to fetch at 'https://api.hatched.live/...' from origin 'https://yoursite.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header` | Hatched API rejected the preflight | Did you call a non-widget endpoint with a publishable/widget key from the browser? Move the call server-side. |
| `403 forbidden — Origin "X" is not allowed for widget access` | Hatched origin allowlist | Dashboard → Settings → General → Widget allowed origins. Add origin. |
| `Refused to load the script 'https://cdn.hatched.live/widget.js' because it violates the following Content Security Policy directive` | Host site's CSP | Add `cdn.hatched.live` to `script-src`. See [widget CSP guide](/docs/guides/widget-csp). |
| `Refused to connect to 'https://api.hatched.live/...' because it violates the following Content Security Policy directive: "connect-src ..."` | Host site's CSP | Add `https://api.hatched.live` to `connect-src`. See [widget CSP guide](/docs/guides/widget-csp). |

The four are independent. If the first request the browser ever sends a
non-widget endpoint with a publishable key, the API legitimately blocks it
with a 403 *and* it shows up in DevTools as a CORS failure because the
preflight gets a 403 with no allow-origin header. Move the call
server-side; do not fight the CORS rule.

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

### Preflight 200 but actual request blocked

**Signal.** DevTools shows the `OPTIONS` request returns 200 with a valid
`Access-Control-Allow-Origin`, but the follow-up `GET`/`POST` is red and
the response body never reaches your code.

**Why.** Almost always one of:

- The host site sits behind a CDN/proxy (Cloudflare, Vercel, custom
  Nginx) that strips or rewrites response headers. The browser sees the
  proxy's headers, not Hatched's.
- The host site is serving the page over HTTP while calling the API over
  HTTPS — mixed content cancels the request after the preflight.
- The host site uses a custom middleware that rewrites the `Origin`
  header before forwarding; Hatched then compares the *rewritten* origin
  against its allowlist.

**Fix.** Strip your CDN/proxy out of the equation: hit the API directly
from a fresh terminal with `curl -v -H "Origin: https://yoursite.com" ...`.
If the response headers look correct from `curl`, the proxy is the
culprit — disable header rewriting for `*.hatched.live`. If `curl` is
also wrong, send the `X-Request-Id` to support.

### CORS works in dev but breaks on staging or prod

**Signal.** Everything is green on `http://localhost:3000`. The same
embed on `https://app.staging.yoursite.com` returns CORS errors.

**Why.** Each Hatched workspace stores its origin allowlist *literally*.
`http://localhost:3000` and `https://app.staging.yoursite.com` are two
different origins as far as the API is concerned. Adding the production
domain doesn't grant staging access; adding the apex doesn't grant
subdomains.

**Fix.** List every origin you serve embeds from, explicitly:

```
http://localhost:3000
http://localhost:4002
https://app.staging.yoursite.com
https://app.yoursite.com
```

Wildcard entries are not supported. The list lives in Dashboard →
Settings → General → Widget allowed origins.

### "No 'Access-Control-Allow-Origin'" when calling secret-key endpoints from a browser

**Signal.** The browser console shows the canonical CORS error and the
request you fired was `POST /buddies/.../coins` or any other write
endpoint.

**Why.** Write endpoints require a secret API key — and secret keys are
*server-only*. The API never sends CORS headers on secret-key routes for
browser preflights, so calls from the browser look like a CORS failure
when they are actually an "auth model" failure.

**Fix.** Move the call server-side. Mint a widget session token on your
server and use that in the browser, or proxy the action through your
own backend. See [auth model](/docs/concepts/auth-model).

### Curl reproduction

The fastest way to confirm whether the API is blocking you vs your CDN
is reproducing the preflight by hand:

```bash
curl -i -X OPTIONS \
  -H "Origin: https://app.yoursite.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: authorization" \
  https://api.hatched.live/api/v1/widget/buddy/me
```

A green path looks like:

```
HTTP/2 200
access-control-allow-origin: https://app.yoursite.com
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
access-control-allow-headers: authorization, content-type, x-request-id, idempotency-key
access-control-max-age: 600
```

A failing path returns a non-2xx status, a missing
`access-control-allow-origin`, or a value that doesn't match the `Origin`
you sent. The mismatch tells you whether the next step is "add the
origin to the allowlist" or "untangle your CDN".

## Where SDK warnings appear

The SDK uses a pluggable logger — when you pass `logger` to `new HatchedClient(...)`,
warnings go to that logger; otherwise they go to `console.warn`. Three messages
operators commonly hit:

| Substring to grep | Meaning | Action |
|---|---|---|
| `hardcoded literal secret key` | You constructed the client with a string that looks like a placeholder (`hatch_live_xxxxxxxxxxxx`). | Replace with `process.env.HATCHED_API_KEY`. |
| `allowBrowser=true: running with a secret key in a browser-like test runner` | `allowBrowser: true` was honored because a test runner was detected. Expected in test runs. | If you see this in production logs, your runtime is exposing test-runner globals — investigate. |
| `[hatched-sdk] logger threw while emitting; suppressed.` | The logger you provided raised. The SDK swallowed it so the request still completes. | Fix the logger (Pino transport offline, Sentry rate-limited, etc.). The SDK never crashes on a logger throw. |

In CI, the `allowBrowser` line is normal during unit tests. In production the
first or third line is a real problem — either fix the literal key or fix your
logger.

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