Consent webhooks: signed events to your server
Receive consent.recorded and consent.withdrawn events on your server, verify the HMAC signature with the SDK, and test delivery with a signed event.
Consent webhooks push every consent decision to your server as a signed HTTP POST: consent.recorded when a visitor grants at least one category, consent.withdrawn when they refuse or retract. This is what makes the right of withdrawal enforceable server-side — when a visitor retracts, your backend learns it and can purge or stop processing.
Create a webhook
Two ways, same contract:
- In the app — the setup wizard on the Builder's Integration page: enter your endpoint URL, optionally scope the webhook to a single banner (default: the whole workspace).
- Via MCP — the
configure_consent_webhooktool, if you drive FlowConsent from Claude or another MCP client.
Either way you get a signing secret in the format whsec_….
The signature
Every delivery carries an X-FlowConsent-Signature header, Stripe-style:
X-FlowConsent-Signature: t=1722326400,v1=5f8a1c…
where t is a Unix timestamp and v1 = HMAC-SHA256(secret, "<t>.<raw body>"). The timestamp bounds replay: reject deliveries whose t is too old.
Verify with the server SDK
@flowconsent/server-sdk ships verifyWebhookSignature — constant-time comparison, 300 s replay tolerance by default:
import { verifyWebhookSignature } from '@flowconsent/server-sdk'
export async function POST(request: Request) {
const rawBody = await request.text() // raw body, before any JSON.parse
const check = await verifyWebhookSignature(
rawBody,
request.headers.get('X-FlowConsent-Signature'),
process.env.FLOWCONSENT_WEBHOOK_SECRET!,
)
if (!check.valid) {
// check.reason: 'missing_header', 'malformed_header',
// 'timestamp_out_of_tolerance', 'signature_mismatch'
return new Response('invalid signature', { status: 400 })
}
const event = JSON.parse(rawBody)
if (event.test) return new Response('ok') // wizard test event — never a real consent
if (event.type === 'consent.withdrawn') {
// stop processing for event.visitor_id, purge downstream systems…
}
return new Response('ok')
}The events
{
"id": "5f0e0a1c-…",
"type": "consent.recorded",
"banner": "FC-A1B2C3",
"visitor_id": "v_8f3d…",
"action": "accept_all",
"categories": {
"functional": true,
"analytics": true,
"marketing": false,
"preferences": false
},
"services": { "google-analytics": true },
"policy_version": "3",
"occurred_at": "2026-07-30T09:41:22.000Z"
}typeisconsent.recordedwhen at least one ofanalytics,marketing,preferencesis granted; otherwiseconsent.withdrawn.banneris the banner's public license code — internal IDs are never exposed.visitor_idis the pseudonymous ID also found in consent logs and in the server SDK'sdecision.visitorId.occurred_atis ISO 8601.
Test the delivery
The wizard's "send test" sends a consent.test event to your endpoint, signed with your real secret and carrying an explicit "test": true marker — never treat it as a real consent. It confirms, end to end, that your endpoint receives, verifies and answers. The delivery result (HTTP status, duration) is shown in the app.
Delivery and retries
- 3 attempts per delivery, exponential backoff (250 ms, 500 ms, 1 s between attempts), 5 s timeout each.
- A
4xxresponse (except429) stops the retries — retrying would not change the outcome. - Respond
2xxfast and process asynchronously: anything else counts as a failed delivery. - The last delivery status and time are visible on each webhook in the app.