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.
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 500. - 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 + widget-session mint.
Fix. Move the call server-side with a secret hatch_live_* key. See
Auth model.
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-widget="buddy"> stays empty. No
network requests in DevTools.
Why. Checklist:
- The
<script src=".../loader.min.js">tag is missing or loaded after the widget div renders, and you never calledwindow.Hatched.mount()manually. data-hatched-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.rescan()after hydration.
Fix.
<script src="https://cdn.hatched.com/widget/v1/loader.min.js" defer></script>
<div
data-hatched-widget="buddy"
data-hatched-token="{{ mint_in_request }}"
></div>
<script>
document.addEventListener('DOMContentLoaded', () => window.Hatched?.rescan());
</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.
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.