Skip to content

Error handling

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).

{
"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 alone is not enough in three places: rate_limited covers four different limits; quota_exceeded is a 403 (a plan cap on watchlist or webhook size) and a 429 (the monthly quota, or the monthly geo-check allowance); 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). 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). 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). 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_jurisdiction POST / PATCH /v1/webhooks: an unknown jurisdiction code in the endpoint’s jurisdictions filter (field: jurisdictions). 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 plan_ended The account’s plan ended on the date in suggestion. Keys, watchlist and webhook endpoints are kept, no webhook is sent, and renewing restores everything. No — renew from your account’s Billing page (or email founder@igregulator.io).
402 payment_required export_requires_pro /v1/export/:dataset on a plan below Pro — Starter (plan_tier, required_plan: "pro", X-Upgrade-URL). Pro is paid by card, or by invoice for three months or more — both from your account’s Billing page. See export. No.
402 payment_required geo_checks_require_business Any geo-check endpoint (/v1/geo-checks, /v1/geo-check-requests) on a plan below Business (required_plan: "business"). See geo-checks. No.
403 forbidden oauth_read_only A connection signed in through MCP (OAuth, not an API key) calling the watchlist, webhooks or export. Signed-in MCP connections make read-only lookups. No — use an API key from app.igregulator.io/api-keys.
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.
404 not_found dataset_not_found /v1/export/:dataset with a dataset other than licences, operators, domains. 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.
409 conflict watchlist_duplicate POST /v1/watchlist/operators: the operator is already on your watchlist. No — nothing to do.
409 conflict 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 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).
429 quota_exceeded geo_check_quota_exceeded POST /v1/geo-checks with your plan’s geo-checks for this UTC month used (current_usage, limit, plan_tier). Not before 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. Without a key there is no such refund: the hourly per-IP counter counts requests, so a 400 spends one of the ten.

  • 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 export daily limit 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 key by starting Starter’s 14-day free trial (card required) at app.igregulator.io/signup; with a key, move to a larger plan on Billing or 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.
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;
}

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 — a structured-field date, @<unix-epoch>) and Sunset (RFC 8594 — an HTTP-date, at least 90 days out), announced in the changelog.