Webhooks & events
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Webhooks & events
Webhooks push events to your own HTTPS endpoint as they happen, so you don't have to poll the API. Delivery is implemented in packages/server/src/webhook-delivery.ts; subscriptions are managed via /v1/webhooks (packages/server/src/dev-api.ts) or client.webhooks.*.
Event catalog
The full set of deliverable event types (WEBHOOK_EVENTS, defined in packages/saas/src/tenancy.ts):
Event | Fires when | Emitted from |
|---|---|---|
| A visitor records a consent decision on a subscribed site. | consent ingest ( |
| A cookie scan finishes for a site. | scan job completion |
| A completed scan finds a different cookie set than the last scan. | scan job completion, when changed |
| A new DSAR is opened (dashboard, |
|
| A DSAR's status advances (dashboard or |
|
| A banner-library design is published to a site. |
|
A one-off ping event (see Testing a subscription) is also delivered, but it's never something you subscribe to.
Subscribe
curl -X POST https://api.cookiemunch.net/v1/webhooks \
-H "Authorization: Bearer $COOKIEMUNCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/cookiemunch",
"events": ["consent.recorded", "dsar.created", "dsar.updated"]
}'const sub = await client.webhooks.create({
url: 'https://example.com/hooks/cookiemunch',
events: ['consent.recorded', 'dsar.created', 'dsar.updated'],
});
console.log(sub.secret); // store this now — it's returned once and never againPass cbid to scope delivery to a single site; omit it (null) to receive the event for every site in the org. events must be a non-empty array drawn from the catalog above — the server rejects unknown event names with 400. GET /v1/webhooks lists subscriptions with the secret field stripped; DELETE /v1/webhooks/{id} removes one.
Delivery payload
Every delivery is a POST to your url with this JSON body (deliver() in webhook-delivery.ts):
{
"id": "evt_1b1e6f2a-9c3d-4a11-8f2e-6a2b7c9d0e11",
"type": "consent.recorded",
"cbid": "site_abc123",
"createdAt": 1730332800000,
"data": { "...": "event-specific payload" }
}Field | Type | Description |
|---|---|---|
| string | Unique event id ( |
| string | One of the catalog values above, or |
| string | null | The site the event is about, or |
| number | Epoch-ms when the event was emitted. |
| object | Event-specific payload — see below. |
data shape per event
Event |
|
|---|---|
|
|
|
|
|
|
| The full |
|
|
Headers
Header | Description |
|---|---|
| Always |
| The event |
| Unix seconds when the delivery was signed. Part of the signed payload — verify it's recent to reject replays. |
|
|
Verifying signatures
The signature covers the timestamp plus the raw body (not the body alone), so a captured delivery can't be replayed forever. Verification is two steps: (1) reject a stale timestamp (outside a freshness window, e.g. 5 minutes), then (2) recompute the HMAC over `${timestamp}.${rawBody}` and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const TOLERANCE_S = 300; // reject deliveries older than 5 minutes (replay protection)
app.post('/hooks/cookiemunch', express.raw({ type: 'application/json' }), (req, res) => {
const ts = Number(req.header('X-CookieMunch-Timestamp') ?? '0');
if (!ts || Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_S) {
return res.status(401).send('stale or missing timestamp');
}
const signature = req.header('X-CookieMunch-Signature') ?? '';
const expected = `sha256=${createHmac('sha256', process.env.COOKIEMUNCH_WEBHOOK_SECRET!)
.update(`${ts}.${req.body.toString('utf8')}`)
.digest('hex')}`;
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
console.log('received', event.type, event.id);
res.status(200).end();
});Use the raw body bytes for the HMAC, not a re-serialized JSON.stringify — even one whitespace difference fails the check, which is why the example uses express.raw(). This mirrors the server's own signing code exactly:
// packages/server/src/webhook-delivery.ts
function sign(secret: string, timestamp: number, body: string): string {
return `sha256=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
}Delivery & retries
Delivery is fire-and-forget and best-effort — a failed delivery never blocks or fails the API call that triggered the event.
Only HTTPS URLs are accepted; the server SSRF-screens the destination (resolves the host and rejects private/loopback/link-local/metadata addresses) before every delivery, including test pings.
A non-2xx response or network error is retried up to 6 attempts total, with exponential backoff (~1s, 5s, 25s, then capped at 60s), so a transient outage of a minute or two doesn't lose the event. Redirects are not followed (
redirect: 'manual'), so a 3xx to an internal host can't slip past the SSRF screen.After the final attempt, a failed delivery is written to a dead-letter store instead of being dropped. List and replay dead-lettered deliveries from the org webhook admin routes (
GET /api/v1/orgs/:orgId/webhooks/dead-letters,POST /api/v1/orgs/:orgId/webhooks/dead-letters/:id/replay); a replay re-resolves the subscription (so a rotated secret / updated URL is used) and removes the entry on success. Still respond2xxquickly (verify the signature, enqueue, return) rather than processing inline.Only active subscriptions receive events, and a subscription's
cbidfilter (if set) must match the event's site or it's skipped (matches()inwebhook-delivery.ts).
Testing a subscription
The dashboard's webhooks page can send a one-off test delivery — a single attempt, no retries, delivered even to a currently-paused subscription so you can verify your endpoint before flipping it live:
{
"id": "evt_c4f1...",
"type": "ping",
"cbid": null,
"createdAt": 1730332800000,
"data": { "message": "This is a test delivery from Cookie Munch." }
}It's signed exactly like a real event — verify it the same way, and expect type: "ping" instead of a catalog value.
Full example
import { createCookieMunch } from '@cookiemunch/sdk';
const client = createCookieMunch({ apiKey: process.env.COOKIEMUNCH_API_KEY!, baseUrl: 'https://api.cookiemunch.net' });
const sub = await client.webhooks.create({
url: 'https://example.com/hooks/cookiemunch',
events: ['consent.recorded', 'dsar.created', 'dsar.updated'],
});
// store sub.secret in your own secret manager now — it will not be returned again
await client.webhooks.list();
await client.webhooks.delete(sub.id);See also: Client reference for the full client.webhooks surface, and MCP tools for list_webhooks / create_webhook / delete_webhook.