HatchedDocs
Guides

Troubleshooting

Reproduce common failures and the exact fix for each — 401, 429, validation errors, image errors, and widget mount issues.

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

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.

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

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

Typical shape:

{
  "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:

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.

403 Origin not allowed for widget access

Signal. Widget API requests fail with:

{
  "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 server-side and pass that to the browser.
  • Use a publishable key 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.

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