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.
1. Event types
Section titled “1. Event types”Ten dot-notation events. Subscribe to any subset per endpoint.
| Event | Fires |
|---|---|
license.status_changed | Any transition between active / suspended / revoked / expired. |
license.expiring_30d | Active licence with expiry_date exactly 30 days from now. |
license.expiring_60d | Same, 60 days. |
license.expiring_90d | Same, 90 days. |
license.expired | Status became expired (either explicitly or via date). |
license.issued | New licence first observed in a scraper run. |
regulatory_action.added | Fine / 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_degraded | Self-notification: one of your endpoints is failing >20% of deliveries. Fires only to your OTHER endpoints. |
2. Envelope
Section titled “2. Envelope”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_statusmay benullwhen a licence is first observed (change_type: created— status is its initial state).api_version— date constant. Bumped when adatashape gets a breaking change; old subscribers keep receiving the previous version until they upgrade.livemode—falseonly fortest.pingevents from the dashboard Test button.timestamp— when we emitted the event. Not when the change happened (that’s indata.*_at).
Regulatory action amounts
Section titled “Regulatory action amounts”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"}3. Headers
Section titled “3. Headers”Every delivery, including retries:
Content-Type: application/jsonUser-Agent: iGregulator-Webhook/1 (+https://igregulator.io)X-iGregulator-Event: license.status_changedX-iGregulator-Event-Id: evt_01HX8EGQK3J7WA6MYTP7ZGYF21X-iGregulator-Timestamp: 1776717845X-iGregulator-Delivery-Id: 3b1e2c4a-f8d1-4a7c-8b5f-111111111111X-iGregulator-Attempt: 2X-iGregulator-Signature: t=1776717845,v1=abcd…ef01X-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.
4. Signature verification
Section titled “4. Signature verification”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; eachv1is the signature computed with a different active secret. Accept the delivery if anyv1value 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)), );}import hmac, hashlib
def verify(headers, raw_body: bytes, secret: str) -> bool: header = headers.get('x-igregulator-signature') if not header: return False parts = dict(p.split('=', 1) for p in header.split(',')) signed = f"{parts['t']}.{raw_body.decode()}" expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest() candidates = [p[3:] for p in header.split(',') if p.startswith('v1=')] return any(hmac.compare_digest(c, expected) for c in candidates)# Debug verification from a saved request. Pass raw body on stdin.SIG_HEADER="t=1776717845,v1=abcd...ef01"SECRET="whsec_..."T=$(echo "$SIG_HEADER" | tr ',' '\n' | awk -F= '/^t/{print $2}')EXPECTED=$(printf '%s.%s' "$T" "$(cat)" \ | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')echo "$SIG_HEADER" | tr ',' '\n' | grep "^v1=" | awk -F= '{print $2}' \ | grep -Fqx "$EXPECTED" && echo "ok" || echo "mismatch"5. Retry policy
Section titled “5. Retry policy”Failed deliveries retry on a jittered schedule:
| Attempt | Delay after previous | With ± 20 % jitter |
|---|---|---|
| 1 | — | fired immediately |
| 2 | 30 s | 24 – 36 s |
| 3 | 2 m | 1:36 – 2:24 m |
| 4 | 10 m | 8 – 12 m |
| 5 | 1 h | 48 – 72 m |
| 6 | 6 h | 4.8 – 7.2 h |
| 7 | 24 h | 19.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).
6. Delivery guarantees
Section titled “6. Delivery guarantees”- 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.*_attimestamps 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) andwebhook_deliveries(delivery history). Fetch deliveries viaGET /v1/webhooks/:id/deliverieswhile they’re still in the window; older rows are pruned daily.
7. Integration patterns
Section titled “7. Integration patterns”Pattern A — Webhooks primary
Section titled “Pattern A — Webhooks primary”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);});Pattern B — Polling fallback
Section titled “Pattern B — Polling fallback”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.
Pattern C — Hybrid webhook + polling
Section titled “Pattern C — Hybrid webhook + polling”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; }}import redis, time, requests
r = redis.Redis.from_url(os.environ['REDIS_URL'])DEDUPE_TTL = 7 * 24 * 3600
def process_once(event): # SET NX returns None if key already exists. if r.set(f"event_seen:{event['event_id']}", '1', ex=DEDUPE_TTL, nx=True) is None: return # already processed handle_event(event) # your business logic
# Webhook handler (Flask):@app.post('/webhook')def webhook(): if not verify(request.headers, request.get_data(), SECRET): abort(400) event = request.get_json() # ACK fast, process async in a worker queue spawn(process_once, event) return '', 200
# Hourly backfill:def poll_backfill(): cursor = r.get('watchlist:cursor') while True: params = {'limit': 100} if cursor: params['cursor'] = cursor else: params['since'] = (time.time() - 3600).isoformat() + 'Z' resp = requests.get( 'https://api.igregulator.io/v1/watchlist/events', headers={'Authorization': f'Bearer {API_KEY}'}, params=params, timeout=10, ).json() for event in resp['events']: process_once(event) if resp.get('next_cursor'): r.set('watchlist:cursor', resp['next_cursor']) if not resp.get('has_more'): break8. Testing
Section titled “8. Testing”- Dashboard Test button — fires a synthetic
test.pingevent 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://localhostis blocked by our SSRF filter at creation time; a tunnel gives you a real routable host.
9. Secret rotation
Section titled “9. Secret rotation”Rotation overlap is 7 days. The rotation flow:
- Click Rotate on the endpoint in the dashboard.
- We issue a new secret and stamp the previous one with
expires_at = NOW() + 7 days. - Deliveries sign with both secrets during the overlap —
every delivery carries
v1=<hex>,v1=<hex>in the signature header. - Update your server to accept the new secret. Existing code that uses the old one keeps verifying until day 7.
- After day 7, the old secret expires and deliveries sign only with the new one.
10. Errors at creation time
Section titled “10. Errors at creation time”400 invalid_webhook_url+details.reason: private_ip_blocked— URL resolved to a private / loopback / link-local IP (including169.254.169.254, AWS + GCP metadata). Use a public host or a tunnel.400 invalid_webhook_url+details.reason: invalid_scheme— Onlyhttp://andhttps://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’smax_webhook_endpoints. Pause or delete an endpoint, or upgrade.
11. Best practices
Section titled “11. Best practices”- 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.pingif 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.*_attimestamps. - 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.