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

HTTPcodedetails.reasonWhenRetry?
400invalid_queryinvalid_inputA 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.
400invalid_queryas_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.
400invalid_querynot_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.
400invalid_queryhostname_too_long/v1/check: domain is longer than 253 characters.No.
400invalid_querymissing_required_parameter/v1/check: neither domain nor license_number.No.
400invalid_queryconflicting_parameters/v1/check: both domain and license_number. /v1/watchlist/events: both since and cursor.No.
400invalid_queryinvalid_pagination/v1/watchlist/operators: limit outside 1–200 or a negative offset.No.
400invalid_queryinvalid_cursor/v1/watchlist/events: the cursor can’t be decoded.No — pass next_cursor verbatim.
400invalid_querysince_exceeds_retention_window/v1/watchlist/events: since is more than 30 days ago.No.
400invalid_queryinvalid_event_typePOST / PATCH /v1/webhooks: an unknown event name (field: events).No.
400invalid_queryinvalid_url, invalid_scheme, empty_host, blocked_hostname, unresolvable_host, private_ip_blockedPOST / PATCH /v1/webhooks: the URL was rejected by the SSRF policy (field: url).No — use a public host.
400invalid_sluginvalid_inputOperator slug path parameter is empty or too long (/v1/operators/:slug…), or not lowercase letters, digits and hyphens (DELETE /v1/watchlist/operators/:slug).No.
400invalid_paginationinvalid_input/v1/jurisdictions/:code/operators: limit outside 1–200 or a negative offset.No.
400invalid_license_idinvalid_input/v1/licenses/:license_id…: not a UUID (it takes the licence id, not the licence number).No.
400invalid_jurisdiction_codeinvalid_input/v1/jurisdictions/:code…: not 2–16 letters or digits (case doesn’t matter: ukgc is UKGC).No.
401auth_requiredapi_key_missingNo Authorization header on an endpoint that needs a key.No — attach a key.
401auth_invalidmalformed_headerThe header isn’t Bearer <key>.No.
401auth_invalidapi_key_invalidThe key isn’t recognised. A key sent to a public endpoint is checked too.No.
401auth_revokedapi_key_revokedThe key was revoked.No — create a new key.
402payment_requiredplan_inactiveThe account has no plan, or a canceled one.No.
402payment_requiredendpoint_requires_paid_planA legacy trial key on anything other than GET /v1/check and the endpoints that work without a key (rate limits).No.
402payment_requiredexport_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.No.
403quota_exceededwatchlist_quota_exceededPOST /v1/watchlist/operators with the watchlist at your plan’s cap (current_usage, limit, plan_tier).No — remove an operator first.
403quota_exceededwebhook_quota_exceededPOST /v1/webhooks with your plan’s number of active endpoints already in use.No — pause or delete one first.
404not_foundroute_not_foundNo such route.No.
404not_foundoperator_not_found, license_not_found, jurisdiction_not_found, webhook_not_foundUnknown 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.
405method_not_allowedmethod_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.
404not_founddataset_not_found/v1/export/:dataset with a dataset other than licences, operators, domains.No.
409invalid_querywatchlist_duplicatePOST /v1/watchlist/operators: the operator is already on your watchlist.No — nothing to do.
409invalid_queryno_active_secretPOST /v1/webhooks/:id/test: the endpoint has no unexpired signing secret.No — rotate the secret.
429rate_limitedquota_exceededKeyless 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.
429rate_limitedper_second_limit_exceededOver your plan’s per-second limit.Yes — after Retry-After (1 s).
429rate_limitedprelaunch_daily_capLegacy trial key over 1,000 requests / UTC day.After Retry-After.
429rate_limitedwatchlist_events_poll_limitGET /v1/watchlist/events over your plan’s polls per hour.After details.reset_at.
429rate_limitedexport_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).
429quota_exceededmonthly_request_limit_reachedYour plan’s monthly quota is used up.Not before details.reset_at (the 1st of next month).
500server_errorinternal_errorUnhandled 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.

  • 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; 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.
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.