Docs

Webhooks & events

AdminUpdated Sep 15, 2026

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

consent.recorded

A visitor records a consent decision on a subscribed site.

consent ingest (POST /api/v1/consent)

scan.completed

A cookie scan finishes for a site.

scan job completion

scan.cookies_changed

A completed scan finds a different cookie set than the last scan.

scan job completion, when changed

dsar.created

A new DSAR is opened (dashboard, POST /v1/dsar, or the public DSAR intake form).

dev-api.ts / privacy-api.ts

dsar.updated

A DSAR's status advances (dashboard or POST /v1/dsar/{id}/advance).

privacy-api.ts

banner.published

A banner-library design is published to a site.

control-plane.ts / client.banners.publish()

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 again

Pass 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

id

string

Unique event id (evt_<uuid>) — safe to use for idempotency dedup.

type

string

One of the catalog values above, or ping for test deliveries.

cbid

string | null

The site the event is about, or null for org-level events.

createdAt

number

Epoch-ms when the event was emitted.

data

object

Event-specific payload — see below.

data shape per event

Event

data

consent.recorded

{ choices: { preferences?, statistics?, marketing? }, region: string }

scan.completed

{ cookieCount: number }

scan.cookies_changed

{ cookieCount: number }

dsar.created / dsar.updated

The full DsarRequest object: { id, type, subjectEmail, regulation, status, createdAt, dueAt, note? }

banner.published

{ cbid: string }

Headers

Header

Description

Content-Type

Always application/json.

X-CookieMunch-Event

The event type, duplicated as a header so you can route without parsing the body.

X-CookieMunch-Timestamp

Unix seconds when the delivery was signed. Part of the signed payload — verify it's recent to reject replays.

X-CookieMunch-Signature

sha256=<hex hmac> — HMAC-SHA256 of `${timestamp}.${rawBody}`, keyed by your subscription's secret.

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 respond 2xx quickly (verify the signature, enqueue, return) rather than processing inline.

  • Only active subscriptions receive events, and a subscription's cbid filter (if set) must match the event's site or it's skipped (matches() in webhook-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.

Was this page helpful?