# Error codes

> Every stable error code Hatched raises — HTTP status, SDK class, and how to fix.

Source: https://docs.hatched.live/docs/reference/error-codes

{/* AUTO-GENERATED — do not edit by hand. The set of codes is derived from the
    `ErrorCode` export in packages/sdk-js/src/error-codes.ts; descriptions and HTTP
    statuses come from the supplementary META map in
    apps/docs/scripts/generate-error-codes.ts. Regenerate with
    `pnpm --filter @hatched/docs generate:error-codes`. */}

Every error response follows the canonical envelope:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded. Retry after 60s",
    "requestId": "req_abc_123",
    "details": { "...": "..." }
  }
}
```

The SDK parses this envelope and throws a typed subclass of `HatchedError` with `code`, `statusCode`, `requestId`, and `details`. The string codes match the `ErrorCode` enum exported from `@hatched/sdk-js`, so you can `switch` on `err.code` exhaustively.

## Catalogue

| Code | HTTP | SDK class | Meaning |
|---|---|---|---|
| `bad_request` | 400 | `HatchedError` (base) | A generic domain bad-request thrown explicitly by a service or controller (e.g. a `from` date after `to`, an unparseable filter). Request-body/DTO validation does NOT land here — that is `validation_failed` (422). `err.details` shape varies per endpoint. |
| `category_conflict` | 400 | `CategoryConflictError` | An equip request tried to put two items in the same category slot (only `accessory` stacks). `err.category` / `err.conflictingItemIds` are on the error. |
| `insufficient_balance` | 400 | `InsufficientBalanceError` | Buddy does not have enough coins/tokens for the spend. |
| `missing_audience` | 400 | `HatchedError` (base) | The customer has 2+ audiences defined, but the request omitted the `audience` field. Single-audience customers never see this (the server applies the implicit default). |
| `too_many_items` | 400 | `TooManyItemsError` | An equip request asked the buddy to wear more items than the compositing pipeline can render (cap is four). `err.max` / `err.attempted` are on the error. |
| `unknown_audience` | 400 | `HatchedError` (base) | The `audience` value sent on the request is not one of the customer’s configured audiences. |
| `unauthorized` | 401 | `UnauthorizedError` | The API key is missing, invalid, or revoked. |
| `credit_insufficient` | 402 | `CreditInsufficientError` | No credit pool has enough credits for the requested AI job. `err.required` / `err.available` / `err.topUpUrl` / `err.upgradeUrl` are on the error. |
| `event_quota_exceeded` | 402 | `EventQuotaExceededError` | The monthly event quota for the plan is exhausted. `err.used` / `err.limit` / `err.resetAt` / `err.upgradeUrl` are on the error. |
| `onboarding_cap_reached` | 402 | `HatchedError` (base) | A free onboarding/trial cap was hit (images, chat turns, or session duration). `err.details.cap` / `err.details.used` / `err.details.limit` / `err.details.upgrade_url` are on the error. |
| `payment_required` | 402 | `HatchedError` (base) | Generic 402 fallback when a billing block has no more specific code (parsed as a base `HatchedError` with `code: "payment_required"`). |
| `capability_disabled` | 403 | `HatchedError` (base) | A workspace-level capability flag for this feature is turned off, even though the plan would allow it. `err.details.feature` / `err.details.current_plan` are on the error. |
| `forbidden` | 403 | `ForbiddenError` | Key is valid but lacks permission for this endpoint. |
| `plan_feature_locked` | 403 | `PlanFeatureLockedError` | The customer's plan does not include the requested feature (e.g. Free tier hitting `/marketplace/*`). `err.requiredPlan` / `err.upgradeUrl` are on the error. |
| `publishable_key_scope` | 403 | `PublishableKeyScopeError` | Publishable key cannot mutate — use a secret key server-side. |
| `widget_token_scope` *(reserved)* | 403 | `WidgetTokenScopeError` | Reserved — not currently emitted by the API. The SDK defines `WidgetTokenScopeError` defensively for a future widget-token scope check. |
| `not_found` | 404 | `NotFoundError` | Generic 404 fallback — a Nest route or guard threw a `NotFoundException` without the richer `resource_not_found` code (e.g. an unmatched route). |
| `resource_not_found` | 404 | `NotFoundError` | The referenced id does not exist or was archived. |
| `active_egg_limit` | 409 | `ActiveEggLimitError` | The per-user active-egg cap was reached. `err.details.active` lists the existing eggs (id + status). |
| `config_version_mismatch` | 409 | `ConfigVersionMismatchError` | The buddy is pinned to a different config version than expected. |
| `conflict` | 409 | `ConflictError` | A competing mutation won; retry is safe if you re-read state first. |
| `idempotency_key_conflict` | 409 | `ConflictError` | An earlier request reused this `Idempotency-Key` with a different body. The SDK surfaces this as a generic `ConflictError` with `code: "idempotency_key_conflict"`. |
| `no_published_config` | 409 | `NoPublishedConfigError` | The customer has no published config version yet, so eggs cannot be created. |
| `validation_failed` | 422 | `ValidationError` | Request input failed validation (HTTP 422). Emitted for every validation path: class-validator DTO failures (via the global `ValidationPipe`), controllers that `Schema.parse(body)` with Zod, and domain business-rule checks. `err.details.fields` is a consistent `{ "<field path>": ["<message>", ...] }` map across all three; Zod failures additionally include `err.details.issues` (the raw issue array). |
| `rate_limited` | 429 | `RateLimitError` | Over the per-minute quota. Honour `err.retryAfter` (seconds). |
| `internal_server_error` | 500 | `HatchedError` (base) | An unhandled error reached the global filter. The `message` is the thrown error message; no stable `details` shape. |
| `bad_gateway` | 502 | `HatchedError` (base) | Generic 502 fallback when an upstream dependency failed without the more specific `upstream_image_error` code. |
| `upstream_image_error` | 502 | `UpstreamImageError` | The art provider failed during hatch/evolve. No ledger writes happened. |
| `onboarding_extract_failed` | 503 | `HatchedError` (base) | The onboarding pipeline could not verify the gamification selections extracted from the generated plan. No state was persisted. `err.details.reason` is present when a reason was captured. |
| `service_unavailable` | 503 | `HatchedError` (base) | A dependency is temporarily unavailable (generic 503 fallback). |

## bad_request

- **HTTP status:** 400
- **SDK class:** `HatchedError` (base)
- **Meaning:** A generic domain bad-request thrown explicitly by a service or controller (e.g. a `from` date after `to`, an unparseable filter). Request-body/DTO validation does NOT land here — that is `validation_failed` (422). `err.details` shape varies per endpoint.
- **Fix:** Read `err.message` / `err.details` to see which input was rejected; fix it and resend.
- **More:** [Guide →](/docs/guides/troubleshooting#400-bad-request)

## category_conflict

- **HTTP status:** 400
- **SDK class:** `CategoryConflictError` (from `@hatched/sdk-js`)
- **Meaning:** An equip request tried to put two items in the same category slot (only `accessory` stacks). `err.category` / `err.conflictingItemIds` are on the error.
- **Fix:** Unequip the conflicting item before equipping the new one.

## insufficient_balance

- **HTTP status:** 400
- **SDK class:** `InsufficientBalanceError` (from `@hatched/sdk-js`)
- **Meaning:** Buddy does not have enough coins/tokens for the spend.
- **Fix:** Check `err.balance` / `err.required`; surface to the user.

## missing_audience

- **HTTP status:** 400
- **SDK class:** `HatchedError` (base)
- **Meaning:** The customer has 2+ audiences defined, but the request omitted the `audience` field. Single-audience customers never see this (the server applies the implicit default).
- **Fix:** Send a valid `audience` (the role/segment key) on the request. List the configured audiences in Dashboard → Audiences.
- **More:** [Guide →](/docs/concepts/audiences)

## too_many_items

- **HTTP status:** 400
- **SDK class:** `TooManyItemsError` (from `@hatched/sdk-js`)
- **Meaning:** An equip request asked the buddy to wear more items than the compositing pipeline can render (cap is four). `err.max` / `err.attempted` are on the error.
- **Fix:** Unequip something first, then retry with at most `err.max` items.

## unknown_audience

- **HTTP status:** 400
- **SDK class:** `HatchedError` (base)
- **Meaning:** The `audience` value sent on the request is not one of the customer’s configured audiences.
- **Fix:** Use one of the keys configured in Dashboard → Audiences (the `audience` must match exactly).
- **More:** [Guide →](/docs/concepts/audiences)

## unauthorized

- **HTTP status:** 401
- **SDK class:** `UnauthorizedError` (from `@hatched/sdk-js`)
- **Meaning:** The API key is missing, invalid, or revoked.
- **Fix:** Double-check `HATCHED_API_KEY`; rotate in Dashboard → Developers if leaked.
- **More:** [Guide →](/docs/guides/troubleshooting#401-unauthorized)

## credit_insufficient

- **HTTP status:** 402
- **SDK class:** `CreditInsufficientError` (from `@hatched/sdk-js`)
- **Meaning:** No credit pool has enough credits for the requested AI job. `err.required` / `err.available` / `err.topUpUrl` / `err.upgradeUrl` are on the error.
- **Fix:** Do NOT retry. Branch on `err.code`; send the user to `details.top_up_url` or `details.upgrade_url` (Stripe portal).
- **More:** [Guide →](/docs/billing/handling-402)

## event_quota_exceeded

- **HTTP status:** 402
- **SDK class:** `EventQuotaExceededError` (from `@hatched/sdk-js`)
- **Meaning:** The monthly event quota for the plan is exhausted. `err.used` / `err.limit` / `err.resetAt` / `err.upgradeUrl` are on the error.
- **Fix:** Do NOT retry. Branch on `err.code`; back off until `details.reset_at` (first of next UTC month) or show an upgrade prompt.
- **More:** [Guide →](/docs/billing/handling-402)

## onboarding_cap_reached

- **HTTP status:** 402
- **SDK class:** `HatchedError` (base)
- **Meaning:** A free onboarding/trial cap was hit (images, chat turns, or session duration). `err.details.cap` / `err.details.used` / `err.details.limit` / `err.details.upgrade_url` are on the error.
- **Fix:** Do NOT retry. Branch on `err.code`; send the operator to `details.upgrade_url` to lift the onboarding cap.
- **More:** [Guide →](/docs/billing/handling-402)

## payment_required

- **HTTP status:** 402
- **SDK class:** `HatchedError` (base)
- **Meaning:** Generic 402 fallback when a billing block has no more specific code (parsed as a base `HatchedError` with `code: "payment_required"`).
- **Fix:** Do NOT retry. Branch on `err.code`; surface upgrade / top-up to the operator.
- **More:** [Guide →](/docs/billing/handling-402)

## capability_disabled

- **HTTP status:** 403
- **SDK class:** `HatchedError` (base)
- **Meaning:** A workspace-level capability flag for this feature is turned off, even though the plan would allow it. `err.details.feature` / `err.details.current_plan` are on the error.
- **Fix:** Do NOT retry. Enable the capability for the workspace (Dashboard → Settings), or branch on `err.code` and hide the feature.

## forbidden

- **HTTP status:** 403
- **SDK class:** `ForbiddenError` (from `@hatched/sdk-js`)
- **Meaning:** Key is valid but lacks permission for this endpoint.
- **Fix:** Check your plan tier or the key's scope.

## plan_feature_locked

- **HTTP status:** 403
- **SDK class:** `PlanFeatureLockedError` (from `@hatched/sdk-js`)
- **Meaning:** The customer's plan does not include the requested feature (e.g. Free tier hitting `/marketplace/*`). `err.requiredPlan` / `err.upgradeUrl` are on the error.
- **Fix:** Do NOT retry. Branch on `err.code` and prompt an upgrade to `details.required_plan`.
- **More:** [Guide →](/docs/billing/handling-402)

## publishable_key_scope

- **HTTP status:** 403
- **SDK class:** `PublishableKeyScopeError` (from `@hatched/sdk-js`)
- **Meaning:** Publishable key cannot mutate — use a secret key server-side.
- **Fix:** Move the call to a server route. See Auth model.
- **More:** [Guide →](/docs/concepts/auth-model)

## widget_token_scope

> **Reserved — not currently emitted.** The API never returns this code today; it exists in the SDK for forward-compatibility. Listed here so the catalogue stays in sync with the SDK `ErrorCode` enum.

- **HTTP status:** 403
- **SDK class:** `WidgetTokenScopeError` (from `@hatched/sdk-js`)
- **Meaning:** Reserved — not currently emitted by the API. The SDK defines `WidgetTokenScopeError` defensively for a future widget-token scope check.
- **Fix:** No action needed today; handle it the same as `forbidden` if you want forward-compatibility.

## not_found

- **HTTP status:** 404
- **SDK class:** `NotFoundError` (from `@hatched/sdk-js`)
- **Meaning:** Generic 404 fallback — a Nest route or guard threw a `NotFoundException` without the richer `resource_not_found` code (e.g. an unmatched route).
- **Fix:** Verify the URL and the id; both map to `NotFoundError` in the SDK.

## resource_not_found

- **HTTP status:** 404
- **SDK class:** `NotFoundError` (from `@hatched/sdk-js`)
- **Meaning:** The referenced id does not exist or was archived.
- **Fix:** Verify the id from a recent list/create response.

## active_egg_limit

- **HTTP status:** 409
- **SDK class:** `ActiveEggLimitError` (from `@hatched/sdk-js`)
- **Meaning:** The per-user active-egg cap was reached. `err.details.active` lists the existing eggs (id + status).
- **Fix:** Hatch or cancel one of the listed eggs, or retry the create with `?ensure=true` (`eggs.create({ ..., ensure: true })`) to reuse one.
- **More:** [Guide →](/docs/guides/first-user-bootstrap#common-pitfalls)

## config_version_mismatch

- **HTTP status:** 409
- **SDK class:** `ConfigVersionMismatchError` (from `@hatched/sdk-js`)
- **Meaning:** The buddy is pinned to a different config version than expected.
- **Fix:** Migrate the buddy or pin your write to its current config.

## conflict

- **HTTP status:** 409
- **SDK class:** `ConflictError` (from `@hatched/sdk-js`)
- **Meaning:** A competing mutation won; retry is safe if you re-read state first.
- **Fix:** Re-fetch the resource and retry the mutation.

## idempotency_key_conflict

- **HTTP status:** 409
- **SDK class:** `ConflictError` (from `@hatched/sdk-js`)
- **Meaning:** An earlier request reused this `Idempotency-Key` with a different body. The SDK surfaces this as a generic `ConflictError` with `code: "idempotency_key_conflict"`.
- **Fix:** Use a fresh `Idempotency-Key` for the new request, or replay the exact same body to receive the cached response.
- **More:** [Guide →](/docs/concepts/idempotency)

## no_published_config

- **HTTP status:** 409
- **SDK class:** `NoPublishedConfigError` (from `@hatched/sdk-js`)
- **Meaning:** The customer has no published config version yet, so eggs cannot be created.
- **Fix:** Publish the gamification plan first. `err.details.publish_url` points at the dashboard publish page.
- **More:** [Guide →](/docs/guides/first-user-bootstrap)

## validation_failed

- **HTTP status:** 422
- **SDK class:** `ValidationError` (from `@hatched/sdk-js`)
- **Meaning:** Request input failed validation (HTTP 422). Emitted for every validation path: class-validator DTO failures (via the global `ValidationPipe`), controllers that `Schema.parse(body)` with Zod, and domain business-rule checks. `err.details.fields` is a consistent `{ "<field path>": ["<message>", ...] }` map across all three; Zod failures additionally include `err.details.issues` (the raw issue array).
- **Fix:** Map `err.details.fields` onto your form/inputs, fix the offending values, and resend.
- **More:** [Guide →](/docs/guides/troubleshooting#422-validation-failed)

## rate_limited

- **HTTP status:** 429
- **SDK class:** `RateLimitError` (from `@hatched/sdk-js`)
- **Meaning:** Over the per-minute quota. Honour `err.retryAfter` (seconds).
- **Fix:** Let the SDK retry (default on) or backoff manually.
- **More:** [Guide →](/docs/guides/troubleshooting#429-too-many-requests)

## internal_server_error

- **HTTP status:** 500
- **SDK class:** `HatchedError` (base)
- **Meaning:** An unhandled error reached the global filter. The `message` is the thrown error message; no stable `details` shape.
- **Fix:** Not retryable as-is — capture `err.requestId` and report it.

## bad_gateway

- **HTTP status:** 502
- **SDK class:** `HatchedError` (base)
- **Meaning:** Generic 502 fallback when an upstream dependency failed without the more specific `upstream_image_error` code.
- **Fix:** Transient upstream failure — retry with backoff.

## upstream_image_error

- **HTTP status:** 502
- **SDK class:** `UpstreamImageError` (from `@hatched/sdk-js`)
- **Meaning:** The art provider failed during hatch/evolve. No ledger writes happened.
- **Fix:** Re-call the operation; idempotent.
- **More:** [Guide →](/docs/guides/troubleshooting#502-upstream-image-error)

## onboarding_extract_failed

- **HTTP status:** 503
- **SDK class:** `HatchedError` (base)
- **Meaning:** The onboarding pipeline could not verify the gamification selections extracted from the generated plan. No state was persisted. `err.details.reason` is present when a reason was captured.
- **Fix:** Retry plan generation after a short backoff.

## service_unavailable

- **HTTP status:** 503
- **SDK class:** `HatchedError` (base)
- **Meaning:** A dependency is temporarily unavailable (generic 503 fallback).
- **Fix:** Transient — retry with backoff.

## Programmatic handling

```ts
import {
  HatchedError,
  ValidationError,
  RateLimitError,
  InsufficientBalanceError,
  CreditInsufficientError,
  EventQuotaExceededError,
  PlanFeatureLockedError,
} from '@hatched/sdk-js';

try {
  await hatched.buddies.spend(buddyId, { amount: 100, reason: "item" });
} catch (err) {
  if (err instanceof InsufficientBalanceError) {
    return showInsufficientFunds(err.balance, err.required);
  }
  if (err instanceof ValidationError) {
    return showFieldErrors(err.details);
  }
  if (err instanceof RateLimitError) {
    return retryLater(err.retryAfter);
  }
  // 402 billing family: never retry — branch on the code and show upgrade / top-up.
  if (err instanceof CreditInsufficientError) {
    return redirectToBilling(err.topUpUrl ?? err.upgradeUrl);
  }
  if (err instanceof EventQuotaExceededError) {
    return showQuotaWall(err.used, err.limit, err.resetAt, err.upgradeUrl);
  }
  if (err instanceof PlanFeatureLockedError) {
    return showUpgradePrompt(err.requiredPlan, err.upgradeUrl);
  }
  if (err instanceof HatchedError) {
    console.error(err.code, err.requestId, err.message);
  }
  throw err;
}
```
