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.
1. Event types
Section titled “1. Event types”Ten dot-notation event names — nine sent today, one reserved. Subscribe to any subset per endpoint.
| Event | Fires |
|---|---|
license.status_changed | A 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_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 | A 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.issued | New licence first observed in a scraper run. The only event for that first observation — no license.status_changed accompanies it. |
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 | Reserved — 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). |
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": "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_statusonlicense.status_changedis nevernull: a licence’s first observation islicense.issued(withdata.status), not a status change.api_version— a date constant: every delivery today carries2026-04-20. Pinning a subscriber to an older version when adatashape changes is planned, not built — there is one version, and no endpoint setting selects another.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 — 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.
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. No endpoint needed: polling returns the
events for the operators on your watchlist.
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 = 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; }}import osimport threadingfrom datetime import datetime, timedelta, timezone
import redisimport requestsfrom flask import Flask, abort, request
app = Flask(__name__)r = redis.Redis.from_url(os.environ['REDIS_URL'], decode_responses=True)API_KEY = os.environ['IGREGULATOR_API_KEY']SECRET = os.environ['WEBHOOK_SECRET']DEDUPE_TTL = 30 * 24 * 3600 # match the 30-day event + delivery retention
# verify() is the Python function from §4.
def handle_event(event): ... # your business logic
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 (use a real queue in production) threading.Thread(target=process_once, args=(event,)).start() return '', 200
# Hourly backfill:def poll_backfill(): cursor = r.get('watchlist:cursor') while True: params = {'limit': 100} if cursor: params['cursor'] = cursor else: since = datetime.now(timezone.utc) - timedelta(hours=1) params['since'] = since.isoformat(timespec='seconds') 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'): cursor = resp['next_cursor'] r.set('watchlist:cursor', 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”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 (including169.254.169.254, AWS + GCP metadata). Use a public host or a tunnel.invalid_scheme— onlyhttp://andhttps://are accepted; https strongly preferred.blocked_hostname— a blocklisted name such aslocalhost.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.
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.
- Watch for abandoned deliveries. Poll
GET /v1/webhooks/:id/deliveries?status=abandonedand alert whenX-iGregulator-Missed-Deliveriesshows up, so you don’t hear about a failing endpoint from a missing downstream action. (webhook.endpoint_degradedis reserved and not sent yet.)