Webhooks: payloads & signature
The complete receiver guide: every event, the payload, verifying the signature, retries and secrets.
This is everything a receiver needs once you’ve registered an endpoint in the console: what arrives, how to prove it came from Croombit, and how to handle retries. Verify the signature before you trust anything in the request — the URL itself is public.
What a delivery looks like
Every delivery is an HTTP POST with content-type: application/json and this body:
{
"id": "evt_…",
"type": "consent.granted",
"createdAt": "2026-09-08T12:00:00.000Z",
"environment": "LIVE",
"data": { "subjectId": "…", "purpose": "marketing_email", "state": "GRANTED",
"purposeVersion": 3, "recordId": "…", "source": "API", "receiptId": "rcpt_…" }
}id— the event id (evt_…). It is the same on every retry and on a replay, so it is the key to dedupe on.environment—TESTorLIVE, matching the endpoint. It is the only place the environment is stated, so check it if one URL serves both.data— depends on the event; see the table below.
Headers
x-croombit-event— the event type (same astypein the body)x-croombit-delivery— the id of this delivery to this endpointx-croombit-signature—t=<unix seconds>,v1=<hex hmac-sha256>. While a rotated secret is in its grace period there are twov1=values (newest secret first); accept the request if any one matches.constructWebhookEventhandles this for you.
Events
Subscribe an endpoint to the events you need (the API rejects any name that isn’t in this list). A ? after a key means it is not always present.
| Event | When it fires | `data` keys |
|---|---|---|
consent.granted | A first GRANTED consent record for a subject and purpose. | subjectId, purpose, state, purposeVersion, recordId, source, receiptId? |
consent.updated | Any record that is not a first GRANTED or a WITHDRAWN: every DENIED, and any change of state on a purpose the subject already had a record for. | subjectId, purpose, state, purposeVersion, recordId, source, receiptId? |
consent.withdrawn | A WITHDRAWN record. | subjectId, purpose, state, purposeVersion, recordId, source, receiptId? |
consent.expired | A consent was expired by the site's expiry policy (scheduled job). A per-record expiresAt on its own emits no event. | subjectId, purpose, purposeVersion |
reconsent.required | A subject must be asked again. Today: emitted when a consent expires (reason "expiry"). | subjectId, purpose, reason |
preference.updated | Preference-center values were written (single opt-in, or after a double opt-in confirmation). | subjectId, fields, templateVersion |
preference.confirmation_requested | A double opt-in request leg was written; nothing is live until confirmed. | subjectId, confirmationId, fields, expiresAt, channel |
preference.confirmed | A double opt-in was confirmed (the confirm link was clicked). | subjectId, confirmationId, fields, templateVersion |
preference.confirmation_email_sent | Croombit sent the double opt-in email itself (only when to was passed). | subjectId, confirmationId |
preference.confirmation_email_failed | Croombit could not send the double opt-in email. | subjectId, confirmationId |
subject.merged | Two subjects were merged (anonymous → identified); vault records moved to the target. Not emitted when only a device was re-linked (outcome: device_relinked). | sourceSubjectId, targetSubjectId, movedRecords |
subject.erased | A subject was crypto-shredded after an ERASURE data request. | subjectId |
cookie.scan_completed | A cookie scan landed (the review queue may have grown). | scanRunId, trigger, url, cookiesFound, newUnclassified, llmBatched |
dsar.verification_requested | A data request was created and needs the requester's identity verified. | dataRequestId, type, subjectId |
dsar.identity_verified | The requester verified their identity by email. | dataRequestId, type, subjectId |
dsar.identity_document_submitted | The requester uploaded an identity document for review. | dataRequestId, type, subjectId |
dsar.identity_document_approved | Staff approved the identity document. | dataRequestId, type, subjectId |
dsar.identity_document_rejected | Staff rejected the identity document. | dataRequestId, type, subjectId, reason |
consent.granted is a subject’s first GRANTED for a purpose; consent.withdrawn is a WITHDRAWN record; every other write is consent.updated, including a DENIED (whether or not it is the first). data.source says who acted: API (the subject), or IMPORT / MIGRATION (you asserted it from your server) — see Recording & reading consent.reconsent.required fires only when a consent expires (reason: "expiry", right after consent.expired). Publishing a purpose version as Requires re-consent does not emit it: read needsReconsent from GET /v1/subjects/{ref}/consent (or run a re-consent campaign) instead. A consent written with its own expiresAt reads as needsReconsent too, but emits no event.test.ping is a synthetic event sent only by the Test button in the console. It is signed and delivered like a real event and ignores the endpoint’s subscription list, but its body has no environment and data is just { "message": "…" }. It is not part of the table because you can’t subscribe to it.To discover the events at runtime instead of hard-coding a list, call GET /v1/webhook-events with any valid API key. It returns { events, delivery }: the same catalog as above (type, description, data) plus the header names, signature scheme, timeout and retry schedule. In the SDK it is tc.getWebhookEvents(); for AI agents, the MCP tool list_webhook_events.
Verify the signature
The signature is HMAC-SHA256 of the string "<t>.<raw body>", keyed with the endpoint’s whsec_… secret (the whole string, prefix included), hex-encoded. Each attempt — including retries — is signed afresh, so a retry never fails on age.
JSON.stringify(req.body)), key order, spacing or number formatting can change and a genuine delivery will fail verification. In Express use express.raw({ type: 'application/json' }), not express.json(), on this route.Node.js / Next.js (recommended)
constructWebhookEvent from the server entry of the SDK verifies the signature (constant-time, 300 s replay window by default) and returns the parsed, typed event. It throws WebhookSignatureError when the signature is missing, wrong or stale. It works on Node 18+, Deno, Bun and edge runtimes (Web Crypto, no dependencies). If you only need a yes/no, verifyWebhookSignature({ header, rawBody, secret }) returns a boolean.
// app/api/croombit/route.ts (Next.js App Router)
import { constructWebhookEvent, WebhookSignatureError } from '@croombit/sdk/server';
export async function POST(req: Request) {
const rawBody = await req.text(); // the exact bytes — do NOT req.json()
let event;
try {
event = await constructWebhookEvent({
header: req.headers.get('x-croombit-signature'),
rawBody,
secret: process.env.TC_WEBHOOK_SECRET!, // whsec_…
});
} catch (err) {
if (err instanceof WebhookSignatureError) return new Response('invalid signature', { status: 400 });
throw err;
}
// Idempotency: the same event.id can arrive more than once.
if (await alreadyProcessed(event.id)) return new Response('ok');
switch (event.type) {
case 'consent.withdrawn':
await suppressInEsp(event.data.subjectId as string, event.data.purpose as string);
break;
case 'subject.erased':
await deleteLocalCopies(event.data.subjectId as string);
break;
}
await markProcessed(event.id);
return new Response('ok'); // any 2xx, within 10 s
}Any other language
- Split the header on
,and readt(unix seconds) and everyv1(hex) — there are two while a rotated secret is in its grace period. - Reject the request if
|now − t|is more than your tolerance (300 s is a good default). - Compute
HMAC-SHA256(secret, t + "." + rawBody)as lowercase hex. - Compare it to each
v1in constant time; the request is authentic if any one matches. Only then parse the JSON.
import hashlib, hmac, time
def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
t, v1s = None, []
for kv in header.split(","):
k, _, v = kv.partition("=")
if k.strip() == "t":
try:
t = int(v)
except ValueError:
return False
elif k.strip() == "v1":
v1s.append(v.strip())
if t is None or not v1s or abs(time.time() - t) > tolerance:
return False
signed = str(t).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# a rotated secret in its grace period adds a second v1: any match is valid
return any(hmac.compare_digest(expected, v) for v in v1s)import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func Verify(header string, rawBody []byte, secret string, tolerance time.Duration) bool {
var ts string
var v1s []string
for _, kv := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(kv, "="); ok {
switch strings.TrimSpace(k) {
case "t":
ts = strings.TrimSpace(v)
case "v1":
v1s = append(v1s, strings.TrimSpace(v))
}
}
}
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || len(v1s) == 0 {
return false
}
if math.Abs(float64(time.Now().Unix()-t)) > tolerance.Seconds() {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
want := mac.Sum(nil)
// a rotated secret in its grace period adds a second v1: any match is valid
for _, v1 := range v1s {
if got, err := hex.DecodeString(v1); err == nil && hmac.Equal(want, got) {
return true
}
}
return false
}Retries, timeouts and duplicates
Respond 2xx within 10 s. Anything else (another status, a timeout, a redirect) counts as a failure — redirects are not followed. The first attempt goes out immediately, then up to 8 retries: after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h, about 2.8 days in total, enough to ride out a weekend outage. After that the delivery is marked failed and you can replay it from the console or the API.
Deliveries are durable: each one is written before it is sent, so a crash on our side delays it but never loses it. Delivery is at least once, so make your handler idempotent: store the evt_ id of every event you have handled and skip repeats. A timeout on your side can mean the event was processed and then retried. Do the slow work after you have answered (queue it), and answer fast.
Events are not guaranteed to arrive in order. If order matters (say, consent.granted then consent.withdrawn), compare createdAt, or read the current state from GET /v1/subjects/{ref}/consent instead of trusting the last event you saw.
Secrets and rotation
- The
whsec_…signing secret is shown once, when the endpoint is created. Store it in your secret manager right away; we can’t show it again. - Rotate from the console (Rotate secret on the endpoint), the MCP tool
rotate_webhook_secret, orPOST /admin/sites/{siteId}/webhook-endpoints/{id}/rotate-secretwith{ "gracePeriodHours": 0–72 }. The new secret is shown once; subscriptions and the delivery log are kept. - Zero-downtime switch: rotate with a grace period (say 24 h). Until it ends, every delivery is signed with both secrets (two
v1=values, newest first), so a receiver that still has the old secret keeps verifying while you deploy the new one. The old secret then stops signing by itself. Rotating again during a grace period drops the older secret. - If a secret leaked: rotate with
gracePeriodHours: 0(the default) — the old secret stops working immediately, and deliveries retried after that are signed with the new one only. - Endpoints belong to one environment. Use a separate endpoint (and secret) for
TESTandLIVE. - Endpoints must be public
https://URLs. Private, loopback and link-local addresses (including cloud metadata) are refused, and redirects are not followed.