# Handling 402 responses

> credit_insufficient, event_quota_exceeded — what they mean and how to recover.

Source: https://docs.hatched.live/docs/billing/handling-402

Hatched uses HTTP `402 Payment Required` for two conditions the caller can
fix by topping up or upgrading:

- `credit_insufficient` — no pool has enough credits for the requested AI job.
- `event_quota_exceeded` — the monthly event quota for the plan is exhausted.

Plus `403 plan_feature_locked` when a plan doesn't include the requested feature
at all (e.g. Free plan hitting `/marketplace/*`).

## Envelope

All three errors share the canonical envelope:

```jsonc
{
  "error": {
    "code": "credit_insufficient",
    "message": "Not enough credits for this AI job (need 1, have 0).",
    "details": {
      "required": 1,
      "available": 0,
      "welcome": 0,
      "paid": 0,
      "promo": 0,
      "upgrade_url": "https://app.hatched.dev/dashboard/billing",
      "top_up_url":  "https://app.hatched.dev/dashboard/billing?action=top_up"
    },
    "requestId": "req_abc123"
  }
}
```

## Do NOT retry

Neither 402 nor 403 are transient. **Do not wrap them in exponential backoff**.
The SDK's built-in retry only kicks in for 429 and upstream 5xx; 402/403 are
surfaced to the caller immediately.

## Recover

- `credit_insufficient` → send the user to `details.upgrade_url` or
  `details.top_up_url` (both open the Stripe portal).
- `event_quota_exceeded` → back off until `details.reset_at` (first of next
  UTC month) or upgrade to a higher plan.
- `plan_feature_locked` → prompt upgrade to `details.required_plan`.

## SDK recipe

```ts
import {
  HatchedClient,
  CreditInsufficientError,
  EventQuotaExceededError,
  PlanFeatureLockedError,
} from '@hatched/sdk-js';

const hatched = new HatchedClient({ apiKey: process.env.HATCHED_SECRET_KEY! });

try {
  await hatched.events.send({ userId: 'u_1', type: 'lesson_completed' });
} catch (err) {
  if (err instanceof EventQuotaExceededError) {
    console.warn(
      `Event quota exceeded (${err.used}/${err.limit}). Resets ${err.resetAt}.`,
    );
    redirect(err.upgradeUrl!);
  } else if (err instanceof CreditInsufficientError) {
    redirect(err.topUpUrl ?? err.upgradeUrl!);
  } else if (err instanceof PlanFeatureLockedError) {
    showUpgradePrompt(err.requiredPlan, err.upgradeUrl);
  } else {
    throw err;
  }
}
```

## Response headers

Every authenticated response includes credit / quota metadata in headers so
you can warn the operator before they hit the wall:

- `X-Credits-Remaining`, `X-Credits-Welcome-Remaining`, `X-Credits-Paid-Remaining`, `X-Credits-Promo-Remaining`
- `X-Event-Quota-Limit`, `X-Event-Quota-Used`, `X-Event-Quota-Remaining`, `X-Event-Quota-Reset-At`
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`

## Webhook signal

When a customer crosses 80% of their monthly event quota we emit one
`usage.threshold_reached` webhook event (`limit_type: 'event_quota'`). The
100% boundary is not webhooked — it is hard enforced via 402 and surfaced
in the dashboard banner.
