Skip to content

Webhooks

iGregulator delivers change alerts via HTTP POST to a URL you control. Nine event types (plus one reserved name), HMAC-SHA256 signed, up to seven delivery attempts — the first plus six retries — with jittered backoff. Sign up for alerts in the dashboard — no code needed, create a URL, select events, copy the secret once.

Just want to wire it up fast? Skip to /docs/webhooks/quickstart for a 2-minute tour using webhook.site as the receiver.

Ten dot-notation event names — nine sent today, one reserved. Subscribe to any subset per endpoint.

EventFires
license.status_changedA scraper recorded a licence moving from one status to another — active, suspended, revoked, pending, surrendered, not_in_register, unknown. Both previous_status and new_status are always set. A move to expired sends license.expired instead, and a newly observed licence sends license.issued. Branch on new_status: only active means licensed, and surrendered / not_in_register are not revocations.
license.expiring_30dActive licence with expiry_date exactly 30 days from now.
license.expiring_60dSame, 60 days.
license.expiring_90dSame, 90 days.
license.expiredA scraper recorded the licence’s status becoming expired — the regulator’s register said so (Expired, Lapsed, “not extended by the CGA”…). Sent instead of license.status_changed for that transition. An expiry_date passing on its own does not send it.
license.issuedNew licence first observed in a scraper run. The only event for that first observation — no license.status_changed accompanies it.
regulatory_action.addedFine / warning / revocation / licence_suspension entry added.
coverage.degraded/v1/health/coverage transitions to degraded for a jurisdiction.
coverage.restored/v1/health/coverage transitions back to healthy.
webhook.endpoint_degradedReserved — not sent yet. Accepted in an endpoint’s events list, but nothing emits it today. To notice a failing endpoint, poll GET /v1/webhooks/:id/deliveries?status=abandoned and watch for X-iGregulator-Missed-Deliveries on the deliveries you do receive (§3).

Every event — production or test — carries the same outer shape:

{
"event": "license.status_changed",
"event_id": "evt_01HX8EGQK3J7WA6MYTP7ZGYF21",
"api_version": "2026-04-20",
"timestamp": "2026-04-20T14:32:00.000Z",
"livemode": true,
"data": {
"license_id": "uuid",
"license_number": "000000-R-000000-001",
"operator_id": "uuid",
"operator_slug": "example-operator-limited",
"jurisdiction_code": "UKGC",
"previous_status": "active",
"new_status": "suspended",
"changed_at": "2026-04-20T03:04:12.000Z",
"source_url": "https://www.gamblingcommission.gov.uk/..."
}
}

(An illustration — the licence and operator are placeholders, not a real event.)

  • event_id — evt_ + a ULID, sorted lexicographically. Dedupe on this.
  • data.previous_status on license.status_changed is never null: a licence’s first observation is license.issued (with data.status), not a status change.
  • api_version — a date constant: every delivery today carries 2026-04-20. Pinning a subscriber to an older version when a data shape changes is planned, not built — there is one version, and no endpoint setting selects another.
  • livemode — false only for test.ping events from the dashboard Test button.
  • timestamp — when we emitted the event. Not when the change happened (that’s in data.*_at).

regulatory_action.added includes amount_minor_units — in the smallest currency unit (pence for GBP, cents for USD). £5 million = 5000000000. We store it this way so a £5M fine never gets confused with £5,000.

{
"action_type": "fine",
"amount_minor_units": 5000000000,
"currency": "GBP"
}

Every delivery, including retries:

Content-Type: application/json
User-Agent: iGregulator-Webhook/1 (+https://igregulator.io)
X-iGregulator-Event: license.status_changed
X-iGregulator-Event-Id: evt_01HX8EGQK3J7WA6MYTP7ZGYF21
X-iGregulator-Timestamp: 1776717845
X-iGregulator-Delivery-Id: 3b1e2c4a-f8d1-4a7c-8b5f-111111111111
X-iGregulator-Attempt: 2
X-iGregulator-Signature: t=1776717845,v1=abcd…ef01

X-iGregulator-Attempt — the attempt number: 1 for the first delivery, 2–7 for retries. X-iGregulator-Missed-Deliveries: N is added to every delivery attempt to an endpoint that has had N deliveries abandoned (all 7 attempts exhausted) since its last successful one; it disappears once a delivery succeeds. Fetch the missed ones via GET /v1/webhooks/:id/deliveries?status=abandoned.

The X-iGregulator-Signature header is a Stripe-style CSV:

t=<unix-epoch-seconds>,v1=<hex>[,v1=<hex>…]
  • t — the timestamp used in the HMAC input.
  • v1 — HMAC-SHA256 hex digest. May appear multiple times when a secret rotation is in progress; each v1 is the signature computed with a different active secret. Accept the delivery if any v1 value matches.

Signed input = ${t}.${raw_body} — the HTTP body is included byte- for-byte; do not re-serialise the JSON before verifying, or whitespace drift will break the MAC.

import crypto from 'node:crypto';
function verify(req, rawBody, secret) {
const header = req.headers['x-igregulator-signature'];
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
);
const signed = `${parts.t}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret)
.update(signed).digest('hex');
// Header may have several v1= values — iterate and accept any.
const candidates = header
.split(',')
.filter((p) => p.startsWith('v1='))
.map((p) => p.slice(3));
return candidates.some((candidate) =>
candidate.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)),
);
}

Failed deliveries retry on a jittered schedule:

AttemptDelay after previousWith ± 20 % jitter
1—fired immediately
230 s24 – 36 s
32 m1:36 – 2:24 m
410 m8 – 12 m
51 h48 – 72 m
66 h4.8 – 7.2 h
724 h19.2 – 28.8 h
—abandon after 7 total attempts

Failure = any non-2xx response or network error (DNS, timeout, TLS, connection reset). 3xx redirects are treated as failures on purpose — point your URL at the final destination. Following them silently would let an attacker redirect deliveries to an internal metadata service after the URL passed creation-time checks. Common gotcha: API gateways that issue a transparent https:// upgrade on http:// URLs — register the https:// form directly to avoid the redirect.

Timeout per attempt: 10 seconds. Long-running receivers should ack fast and process async (return 2xx immediately, queue the body for your worker).

  • At-least-once. A network blip may have us deliver the same event twice. Dedupe on event_id.
  • No ordering. Events from different operators run independently; even events on the same operator can arrive out of order during a retry burst. Use data.*_at timestamps inside the payload to sequence consumer-side state.
  • Scraper outages queue. If a jurisdiction’s scraper stalls, detected changes queue up and fire on the next successful run — no lost events, just a delayed batch.
  • Retention: 30 days for both webhook_events (replay window) and webhook_deliveries (delivery history). Fetch deliveries via GET /v1/webhooks/:id/deliveries while they’re still in the window; older rows are pruned daily.

The simplest setup. Create an endpoint, subscribe to events, process them in real time.

app.post('/igregulator-webhook', async (req, res) => {
if (!verify(req, rawBody, process.env.WEBHOOK_SECRET)) {
return res.status(400).send('bad signature');
}
res.sendStatus(200); // ACK fast, process async
void queueForProcessing(req.body);
});

Agent can’t accept inbound webhooks (locked-down corporate network, local dev). Use GET /v1/watchlist/events — see watchlist docs. Bootstrap with since=<ISO>, then switch to cursor pagination. No endpoint needed: polling returns the events for the operators on your watchlist.

Run both. Webhooks are the low-latency primary; polling covers the few-hour window where your receiver was down and deliveries might abandon. Because the same event_id ships on both channels, dedupe on it and there’s no double-processing.

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const DEDUPE_TTL = 30 * 24 * 60 * 60; // match the 30-day event + delivery retention
async function processOnce(event) {
// SET NX returns null if the key already exists.
const set = await redis.set(
`event_seen:${event.event_id}`, '1',
'EX', DEDUPE_TTL, 'NX',
);
if (set === null) return; // already processed
await handleEvent(event); // your business logic
}
// Webhook handler:
app.post('/webhook', async (req, res) => {
if (!verify(req, rawBody, secret)) return res.sendStatus(400);
res.sendStatus(200);
await processOnce(req.body);
});
// Hourly polling fallback:
async function pollBackfill() {
let cursor = await redis.get('watchlist:cursor');
while (true) {
const url = new URL('https://api.igregulator.io/v1/watchlist/events');
if (cursor) url.searchParams.set('cursor', cursor);
else url.searchParams.set('since', new Date(Date.now() - 3600_000).toISOString());
url.searchParams.set('limit', '100');
const r = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
const { events, next_cursor, has_more } = await r.json();
for (const event of events) await processOnce(event);
if (next_cursor) {
cursor = next_cursor;
await redis.set('watchlist:cursor', cursor);
}
if (!has_more) break;
}
}
  • Dashboard Test button — fires a synthetic test.ping event at your endpoint. Does NOT create delivery history rows. Use during wiring to confirm the signature path.
  • webhook.site — paste your URL there, hit Test, inspect headers + body. Fastest way to see what a delivery looks like before your server exists.
  • ngrok / cloudflared — tunnel a local dev server to a public URL. http://localhost is blocked by our SSRF filter at creation time; a tunnel gives you a real routable host.

Rotation overlap is 7 days. The rotation flow:

  1. Click Rotate on the endpoint in the dashboard.
  2. We issue a new secret and stamp the previous one with expires_at = NOW() + 7 days.
  3. Deliveries sign with both secrets during the overlap — every delivery carries v1=<hex>,v1=<hex> in the signature header.
  4. Update your server to accept the new secret. Existing code that uses the old one keeps verifying until day 7.
  5. After day 7, the old secret expires and deliveries sign only with the new one.

A URL the SSRF policy rejects answers 400 invalid_query with details.field: "url" and one of these details.reason values (the same checks run on PATCH, and again before every delivery):

  • private_ip_blocked — the host resolved to a private / loopback / link-local IP (including 169.254.169.254, AWS + GCP metadata). Use a public host or a tunnel.
  • invalid_scheme — only http:// and https:// are accepted; https strongly preferred.
  • blocked_hostname — a blocklisted name such as localhost.
  • unresolvable_host — the hostname doesn’t resolve.
  • invalid_url / empty_host — not an absolute URL, or no hostname.

Other creation errors:

  • 400 invalid_query + details.reason: invalid_event_type — You passed an unrecognised event name. Compare against the list in §1.
  • 400 invalid_query + details.reason: invalid_input — the body isn’t { url, events: [...], watchlist_only?, description? }.
  • 403 quota_exceeded + details.reason: webhook_quota_exceeded — You hit your plan’s number of active endpoints. Pause or delete an endpoint, or email founder@igregulator.io if you need a higher cap.
  • Respond 2xx in < 5 seconds, process async. We time out at 10 s; if your receiver regularly takes 5+ s you’ll start hitting retries.
  • Verify every delivery. Skip only for test.ping if you treat test events as connectivity checks and not real data.
  • Dedupe on event_id. Always. At-least-once delivery means duplicates will happen eventually.
  • Don’t assume ordering. Use data.*_at timestamps.
  • Keep a polling fallback for critical paths — see Pattern C.
  • Watch for abandoned deliveries. Poll GET /v1/webhooks/:id/deliveries?status=abandoned and alert when X-iGregulator-Missed-Deliveries shows up, so you don’t hear about a failing endpoint from a missing downstream action. (webhook.endpoint_degraded is reserved and not sent yet.)