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 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.
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 export daily limit send
Retry-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 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.
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.