Docs

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 — TEST or LIVE, 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 as type in the body)
  • x-croombit-delivery — the id of this delivery to this endpoint
  • x-croombit-signature — t=<unix seconds>,v1=<hex hmac-sha256>. While a rotated secret is in its grace period there are two v1= values (newest secret first); accept the request if any one matches. constructWebhookEvent handles 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.

EventWhen it fires`data` keys
consent.grantedA first GRANTED consent record for a subject and purpose.subjectId, purpose, state, purposeVersion, recordId, source, receiptId?
consent.updatedAny 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.withdrawnA WITHDRAWN record.subjectId, purpose, state, purposeVersion, recordId, source, receiptId?
consent.expiredA 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.requiredA subject must be asked again. Today: emitted when a consent expires (reason "expiry").subjectId, purpose, reason
preference.updatedPreference-center values were written (single opt-in, or after a double opt-in confirmation).subjectId, fields, templateVersion
preference.confirmation_requestedA double opt-in request leg was written; nothing is live until confirmed.subjectId, confirmationId, fields, expiresAt, channel
preference.confirmedA double opt-in was confirmed (the confirm link was clicked).subjectId, confirmationId, fields, templateVersion
preference.confirmation_email_sentCroombit sent the double opt-in email itself (only when to was passed).subjectId, confirmationId
preference.confirmation_email_failedCroombit could not send the double opt-in email.subjectId, confirmationId
subject.mergedTwo 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.erasedA subject was crypto-shredded after an ERASURE data request.subjectId
cookie.scan_completedA cookie scan landed (the review queue may have grown).scanRunId, trigger, url, cookiesFound, newUnclassified, llmBatched
dsar.verification_requestedA data request was created and needs the requester's identity verified.dataRequestId, type, subjectId
dsar.identity_verifiedThe requester verified their identity by email.dataRequestId, type, subjectId
dsar.identity_document_submittedThe requester uploaded an identity document for review.dataRequestId, type, subjectId
dsar.identity_document_approvedStaff approved the identity document.dataRequestId, type, subjectId
dsar.identity_document_rejectedStaff rejected the identity document.dataRequestId, type, subjectId, reason
Consent events follow the record. 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.

Verify the exact bytes you received. Read the raw body as text before any JSON parsing and sign over that. If you parse and re-serialize (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.

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

  1. Split the header on , and read t (unix seconds) and every v1 (hex) — there are two while a rotated secret is in its grace period.
  2. Reject the request if |now − t| is more than your tolerance (300 s is a good default).
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) as lowercase hex.
  4. Compare it to each v1 in 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, or POST /admin/sites/{siteId}/webhook-endpoints/{id}/rotate-secret with { "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 TEST and LIVE.
  • Endpoints must be public https:// URLs. Private, loopback and link-local addresses (including cloud metadata) are refused, and redirects are not followed.