# Error handling

URL: https://igregulator.io/docs/errors/
Markdown: https://igregulator.io/docs/errors.md

> Every iGregulator API error: HTTP status, code and reason, what each means and whether to retry — plus the structured error body and deprecation headers.

Every non-2xx response is JSON with a stable `code` and a `details.reason`.
Branch on those, not on the human message (the `error` text can change).

## Response shape

```json
{
  "error": "Monthly quota exceeded",
  "code": "quota_exceeded",
  "details": {
    "reason": "monthly_request_limit_reached",
    "current_usage": 10000,
    "limit": 10000,
    "reset_at": "2026-10-01T00:00:00.000Z",
    "plan_tier": "starter",
    "suggestion": "…"
  }
}
```

- `code` — the class of error. A small, stable enum.
- `details.reason` — always present. The machine-readable cause; the
  same `code` covers several causes (see below).
- `details.field` — the request input at fault, when there is one
  (`domain`, `as_of`, `slug`, `url`, `events`…).
- `details.suggestion` — a fix you can show to a user or act on directly.
- Extra fields on some errors: `current_usage`, `limit`, `reset_at`,
  `plan_tier` on quota and limit errors.

## Code + reason matrix

`code` alone is not enough in three places: `rate_limited` covers five
different limits; `quota_exceeded` is a 403 (a plan cap on watchlist or
webhook size) and a 429 (the monthly quota); and the keyless hourly cap
answers `code: rate_limited` with `details.reason: quota_exceeded`. Branch
on the HTTP status, `code` **and** `details.reason`.

| HTTP | `code` | `details.reason` | When | Retry? |
| --- | --- | --- | --- | --- |
| 400 | `invalid_query` | `invalid_input` | A query parameter or JSON body is missing or malformed. `as_of` (`field: as_of`) that is neither a `YYYY-MM-DD` date nor an ISO-8601 datetime with a UTC offset, names a date that doesn't exist (`2026-02-30`), or is in the future ([point-in-time](https://igregulator.io/docs/point-in-time/#date-semantics)). `POST /v1/check/batch` whose body isn't `{ "domains": [...] }` with 1–100 entries — one bad entry is a per-row `error` in a `200`, not a `400` ([batch](https://igregulator.io/docs/batch/#partial-success)). On `/v1/export/:dataset`: a bad `format`, `jurisdiction` or `status`, or a parameter exports don't take (`field` names it). | No — fix the request. |
| 400 | `invalid_query` | `as_of_not_supported` | `/v1/export/:dataset` with `as_of`: exports are the current state only. | No — use `as_of` on `/v1/licenses/{id}`, `/v1/operators/{slug}` or `/v1/check`. |
| 400 | `invalid_query` | `not_a_valid_hostname` | `/v1/check`: `domain` isn't a bare hostname, even after trimming, lowercasing, dropping a trailing dot and converting a Unicode name to punycode — a URL with a scheme or path, a port, an underscore, a single label ([hostnames we accept](https://igregulator.io/docs/confidence/#hostnames-we-accept)). | No. |
| 400 | `invalid_query` | `hostname_too_long` | `/v1/check`: `domain` is longer than 253 characters. | No. |
| 400 | `invalid_query` | `missing_required_parameter` | `/v1/check`: neither `domain` nor `license_number`. | No. |
| 400 | `invalid_query` | `conflicting_parameters` | `/v1/check`: both `domain` and `license_number`. `/v1/watchlist/events`: both `since` and `cursor`. | No. |
| 400 | `invalid_query` | `invalid_pagination` | `/v1/watchlist/operators`: `limit` outside 1–200 or a negative `offset`. | No. |
| 400 | `invalid_query` | `invalid_cursor` | `/v1/watchlist/events`: the `cursor` can't be decoded. | No — pass `next_cursor` verbatim. |
| 400 | `invalid_query` | `since_exceeds_retention_window` | `/v1/watchlist/events`: `since` is more than 30 days ago. | No. |
| 400 | `invalid_query` | `invalid_event_type` | `POST` / `PATCH /v1/webhooks`: an unknown event name (`field: events`). | No. |
| 400 | `invalid_query` | `invalid_url`, `invalid_scheme`, `empty_host`, `blocked_hostname`, `unresolvable_host`, `private_ip_blocked` | `POST` / `PATCH /v1/webhooks`: the URL was rejected by the SSRF policy (`field: url`). | No — use a public host. |
| 400 | `invalid_slug` | `invalid_input` | Operator slug path parameter is empty or too long (`/v1/operators/:slug…`), or not lowercase letters, digits and hyphens (`DELETE /v1/watchlist/operators/:slug`). | No. |
| 400 | `invalid_pagination` | `invalid_input` | `/v1/jurisdictions/:code/operators`: `limit` outside 1–200 or a negative `offset`. | No. |
| 400 | `invalid_license_id` | `invalid_input` | `/v1/licenses/:license_id…`: not a UUID (it takes the licence `id`, not the licence number). | No. |
| 400 | `invalid_jurisdiction_code` | `invalid_input` | `/v1/jurisdictions/:code…`: not 2–16 letters or digits (case doesn't matter: `ukgc` is `UKGC`). | No. |
| 401 | `auth_required` | `api_key_missing` | No `Authorization` header on an endpoint that needs a key. | No — attach a key. |
| 401 | `auth_invalid` | `malformed_header` | The header isn't `Bearer <key>`. | No. |
| 401 | `auth_invalid` | `api_key_invalid` | The key isn't recognised. A key sent to a public endpoint is checked too. | No. |
| 401 | `auth_revoked` | `api_key_revoked` | The key was revoked. | No — create a new key. |
| 402 | `payment_required` | `plan_inactive` | The account has no plan, or a `canceled` one. | No. |
| 402 | `payment_required` | `endpoint_requires_paid_plan` | A legacy `trial` key on anything other than `GET /v1/check` and the endpoints that work without a key ([rate limits](https://igregulator.io/docs/rate-limits/#legacy-trial-keys)). | No. |
| 402 | `payment_required` | `export_requires_pro` | `/v1/export/:dataset` on a plan below Pro — Starter or a legacy `trial` key (`plan_tier`, `required_plan: "pro"`, `X-Upgrade-URL`). Pro isn't open yet: email founder@igregulator.io. See [export](https://igregulator.io/docs/export/). | No. |
| 403 | `quota_exceeded` | `watchlist_quota_exceeded` | `POST /v1/watchlist/operators` with the watchlist at your plan's cap (`current_usage`, `limit`, `plan_tier`). | No — remove an operator first. |
| 403 | `quota_exceeded` | `webhook_quota_exceeded` | `POST /v1/webhooks` with your plan's number of active endpoints already in use. | No — pause or delete one first. |
| 404 | `not_found` | `route_not_found` | No such route. | No. |
| 404 | `not_found` | `operator_not_found`, `license_not_found`, `jurisdiction_not_found`, `webhook_not_found` | Unknown slug, id or code — `/v1/jurisdictions/:code/operators` included (an unknown code is a 404 there, not an empty list). A webhook that belongs to another account is also `webhook_not_found`. | No. |
| 405 | `method_not_allowed` | `method_not_allowed` | `/v1/check/batch` with any method but `POST` (`Allow: POST`), with or without a key. | No — `POST` it, or use `GET /v1/check` for one domain. |
| 404 | `not_found` | `dataset_not_found` | `/v1/export/:dataset` with a dataset other than `licences`, `operators`, `domains`. | No. |
| 409 | `invalid_query` | `watchlist_duplicate` | `POST /v1/watchlist/operators`: the operator is already on your watchlist. | No — nothing to do. |
| 409 | `invalid_query` | `no_active_secret` | `POST /v1/webhooks/:id/test`: the endpoint has no unexpired signing secret. | No — rotate the secret. |
| 429 | `rate_limited` | `quota_exceeded` | Keyless call over 10 / hour / IP on a public endpoint (`details.limit`, `details.reset_at`). | After `details.reset_at` / `X-RateLimit-Reset` — the top of the next UTC hour. |
| 429 | `rate_limited` | `per_second_limit_exceeded` | Over your plan's per-second limit. | Yes — after `Retry-After` (1 s). |
| 429 | `rate_limited` | `prelaunch_daily_cap` | Legacy `trial` key over 1,000 requests / UTC day. | After `Retry-After`. |
| 429 | `rate_limited` | `watchlist_events_poll_limit` | `GET /v1/watchlist/events` over your plan's polls per hour. | After `details.reset_at`. |
| 429 | `rate_limited` | `export_daily_limit_reached` | `/v1/export/:dataset` over your plan's exports per UTC day (Pro 10, Business 100 — dashboard and API together). | After `Retry-After` / `details.reset_at` (00:00 UTC). |
| 429 | `quota_exceeded` | `monthly_request_limit_reached` | Your plan's monthly quota is used up. | Not before `details.reset_at` (the 1st of next month). |
| 500 | `server_error` | `internal_error` | Unhandled failure on our side. | Yes — exponential backoff, 3 attempts. |

A refused (4xx) or failed (5xx) request is not charged against your
monthly quota; it still counts toward the per-second limit.

## Retry strategy

- **4xx other than 429** — fix the request, don't retry. The same bad input will always 4xx.
- **429** — wait for the moment the table names, then retry once. Only the
  per-second limit and the `trial` daily cap send `Retry-After`; the
  keyless cap says when in `details.reset_at` (ISO-8601) and
  `X-RateLimit-Reset` (Unix epoch seconds) — both the top of the next UTC hour —
  and the monthly quota and the watchlist poll limit in `details.reset_at`. A monthly `quota_exceeded` will not clear until the
  1st — stop, don't loop. If you keep hitting 429 without a key, get a
  free one at [app.igregulator.io/signup](https://app.igregulator.io/signup);
  with a key, email founder@igregulator.io.
- **5xx** — exponential backoff up to 3 attempts (1s, 2s, 4s). If a 5xx persists past 4 seconds, you're better off surfacing a failure state than holding the UI hostage.

## Reference implementation (JavaScript)

```js
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

async function igRequest(path, init = {}, attempt = 0) {
  const r = await fetch('https://api.igregulator.io' + path, init);
  if (r.ok) return r.json();

  const body = await r.json().catch(() => ({ code: 'parse_error' }));
  const code = body.code ?? 'unknown';

  // Short, self-clearing limits only. A monthly `quota_exceeded` (or a keyless
  // cap an hour away) is surfaced to the caller instead of slept on.
  if (r.status === 429 && code === 'rate_limited' && attempt < 3) {
    const retryAfter = r.headers.get('Retry-After');
    const reset = r.headers.get('X-RateLimit-Reset');
    const waitMs = retryAfter
      ? Number(retryAfter) * 1_000
      : reset
        ? Number(reset) * 1_000 - Date.now()
        : null;
    if (waitMs !== null && waitMs < 5 * 60_000) {
      await sleep(Math.max(0, waitMs) + 1_000);
      return igRequest(path, init, attempt + 1);
    }
  }

  if (r.status >= 500 && attempt < 3) {
    await sleep(2 ** attempt * 1_000);
    return igRequest(path, init, attempt + 1);
  }

  const err = new Error(body.error ?? r.statusText);
  err.status = r.status;
  err.code = code;
  err.reason = body.details?.reason;
  err.details = body.details;
  throw err;
}
```

## Deprecation headers

No endpoint or field is deprecated today, so the API sends no
`Deprecation` or `Sunset` header. When a field is marked for removal, its
responses will carry `Deprecation` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745) —
a structured-field date, `@<unix-epoch>`) and `Sunset`
([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) — an HTTP-date, at
least 90 days out), announced in the [changelog](https://igregulator.io/docs/changelog/).
