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 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_keycollision — 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
Authorizationheader 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: 3by 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));
}
}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
ValidationPiperejects it). - Zod-parsed bodies — endpoints that
Schema.parse(body)directly. - Business rules — e.g. event
typenot registered,eventIdcollision.
Fix. Read err.details.fields — a { "<field path>": ["<message>", …] }
map you can drop straight onto a form:
catch (err) {
if (err instanceof ValidationError) {
console.error('fields:', JSON.stringify(err.details.fields, null, 2));
}
}Typical shape:
{
"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:
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.
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. |
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. |
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:
{
"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.livevsapi.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
Originheader 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.comWildcard 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.
Curl reproduction
The fastest way to confirm whether the API is blocking you vs your CDN is reproducing the preflight by hand:
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/meA 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: 600A 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 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:
- The
<script src=".../widget.js">tag is missing or loaded after the widget div renders, and you never calledwindow.__HATCHED_WIDGET__.init()manually. data-session-tokenordata-embed-tokenis empty / invalid / expired.- A CSP blocks the loader script (
connect-src,script-src). - 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
typedoesn't match any rule (typo:lesson_completevslesson_completed). - Rule is on draft, not published.
audiencefilter 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/stateoroperations.wait(...)to report completion. - For
awaiting_credits, add credits or wait for the scheduled retry. - For
failedwitherror.code === 'needs_rerender', callhatched.buddies.rerenderAppearance(buddyId)orPOST /widget/appearance/rerender, wait forready, then re-equip the desired items.
Support
Include these four things in every support ticket:
- Request id from
hatched.getLastRequestId()or theX-Request-Idresponse header. - SDK version (
@hatched/sdk-jsin your lockfile). - Minimal reproduction — the five lines of code, not the whole file.
- What you expected vs what happened.
Best practices
Patterns for a Hatched integration that scales — designing the economy, sending events safely, handling webhooks reliably, and staying multi-tenant clean.
HTTP API
Lean API contract and state machines — V1 scope, endpoints, authentication, and the business processes behind each operation.