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).
Response shape
Section titled “Response shape”{ "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 samecodecovers 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_tieron quota and limit errors.
Code + reason matrix
Section titled “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). 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_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). | 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. | 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
Section titled “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
trialdaily cap sendRetry-After; the keyless cap says when indetails.reset_at(ISO-8601) andX-RateLimit-Reset(Unix epoch seconds) — both the top of the next UTC hour — and the monthly quota and the watchlist poll limit indetails.reset_at. A monthlyquota_exceededwill 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.
Reference implementation (JavaScript)
Section titled “Reference implementation (JavaScript)”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
Section titled “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 —
a structured-field date, @<unix-epoch>) and Sunset
(RFC 8594 — an HTTP-date, at
least 90 days out), announced in the changelog.