Skip to content

Webhooks

iGregulator delivers change alerts via HTTP POST to a URL you control. Ten event types, HMAC-SHA256 signed, retried seven times 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 events. Subscribe to any subset per endpoint.

EventFires
license.status_changedAny transition between active / suspended / revoked / expired.
license.expiring_30dActive licence with expiry_date exactly 30 days from now.
license.expiring_60dSame, 60 days.
license.expiring_90dSame, 90 days.
license.expiredStatus became expired (either explicitly or via date).
license.issuedNew licence first observed in a scraper run.
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_degradedSelf-notification: one of your endpoints is failing >20% of deliveries. Fires only to your OTHER endpoints.

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": "039028-R-319297-013",
"operator_id": "uuid",
"operator_slug": "888-uk-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/..."
}
}
  • event_id — ULID, sorted lexicographically. Dedupe on this. previous_status may be null when a licence is first observed (change_type: created — status is its initial state).
  • api_version — date constant. Bumped when a data shape gets a breaking change; old subscribers keep receiving the previous version until they upgrade.
  • livemodefalse 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 — tells your receiver this is retry N. X-iGregulator-Missed-Deliveries appears on the next successful delivery after one or more deliveries were abandoned (all 7 attempts exhausted). Fetch them 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
1fired 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.

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 = 7 * 24 * 60 * 60; // match webhook_deliveries 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) await redis.set('watchlist:cursor', next_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.
  • 400 invalid_webhook_url + details.reason: private_ip_blocked — URL resolved to a private / loopback / link-local IP (including 169.254.169.254, AWS + GCP metadata). Use a public host or a tunnel.
  • 400 invalid_webhook_url + details.reason: invalid_scheme — Only http:// and https:// accepted; https strongly preferred.
  • 400 invalid_query + details.reason: invalid_event_type — You passed an unrecognised event name. Compare against the list in §1.
  • 403 quota_exceeded — You hit your plan’s max_webhook_endpoints. Pause or delete an endpoint, or upgrade.
  • 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.
  • Alert on webhook.endpoint_degraded. If one of your endpoints is failing, we notify your OTHER endpoints so you don’t have to hear about it from a missing downstream action.