# Webhooks

URL: https://igregulator.io/docs/webhooks/
Markdown: https://igregulator.io/docs/webhooks.md

> Push-based alerts on licence changes, expiries, and regulatory actions. HMAC-signed, retried, deliverable to any HTTPS endpoint.

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](https://app.igregulator.io/webhooks)** — no code
needed, create a URL, select events, copy the secret once.

> Just want to wire it up fast? Skip to
> [/docs/webhooks/quickstart](https://igregulator.io/docs/webhooks/quickstart/) for a
> 2-minute tour using webhook.site as the receiver.

## 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). |

> **Subscribing to multiple expiry windows**
>
> Subscribing to all three (`license.expiring_30d`, `_60d`, `_90d`)
> delivers three separate webhooks per licence as it approaches
> expiry. By design — they're semantically distinct warnings, not
> duplicates. Subscribe only to the warning period(s) you act on.
>
> ```js
> function onWebhook(event) {
>   switch (event.event) {
>     case 'license.expiring_90d':
>       // Notify, no blocker — 3 months is plenty
>       notifyCompliance(event.data);
>       break;
>     case 'license.expiring_60d':
>       // Compose renewal paperwork
>       kickoffRenewalFlow(event.data);
>       break;
>     case 'license.expiring_30d':
>       // Escalate, block new referrals to the operator
>       lockOperator(event.data);
>       break;
>   }
> }
> ```

## 2. Envelope

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

```json
{
  "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 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.

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

## 3. Headers

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`.

## 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; 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.

**Node.js**

```js
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)),
  );
}
```

**Python**

```python
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)
```

**curl + jq**

```bash
# 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"
```

> **Replay protection**
>
> Reject the delivery if `|now - X-iGregulator-Timestamp|` exceeds
> 300 seconds — an attacker who captured a delivery can't re-send it
> an hour later. Our retries never push timestamps past this window
> (the worker re-signs on each attempt with the current time).

## 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

- **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.

## 7. Integration patterns

### Pattern A — Webhooks primary

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

```js
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

Agent can't accept inbound webhooks (locked-down corporate network,
local dev). Use `GET /v1/watchlist/events` — see
[watchlist docs](https://igregulator.io/docs/watchlist/). 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

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.

**Node.js**

```js
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;
  }
}
```

**Python**

```python
import os
import threading
from datetime import datetime, timedelta, timezone

import redis
import requests
from 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'): break
```

## 8. Testing

- **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.

## 9. Secret rotation

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.

> **Silent fail after rotation**
>
> If your server isn't updated to accept the new secret before the
> old one expires (day 7), every delivery after that starts failing
> signature verification — silently, from your end, because we still
> deliver successfully (we don't know your verification is wrong).
> If your receiver answers non-2xx on a bad signature (as the examples
> above do), the dashboard's Deliveries modal shows a rising failure rate;
> it's the canary. For a second layer, poll
> `GET /v1/webhooks/:id/deliveries?status=abandoned` and alert on
> `X-iGregulator-Missed-Deliveries` — the `webhook.endpoint_degraded`
> event is reserved and not sent yet.

## 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 (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.

## 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.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.)
