# 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.

> Canonical: https://www.flowconsent.com/en/doc/consent-webhooks
> Last updated: 2026-07-30
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_webhook` tool, if you drive FlowConsent from Claude or another MCP client.

Either way you get a signing secret in the format `whsec_…`.

> [!IMPORTANT]
> The secret is shown **once**, at creation. Store it in your secret manager immediately — if you lose it, delete the webhook and create a new one.

## 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:

```ts title="app/api/flowconsent-webhook/route.ts"
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')
}
```

> [!WARNING]
> Verify against the **raw** request body. Re-serializing the JSON (`JSON.stringify(await request.json())`) changes the bytes and the signature check will fail.

## The events

```json title="consent.recorded — exact delivery format"
{
  "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"
}
```

- `type` is `consent.recorded` when at least one of `analytics`, `marketing`, `preferences` is granted; otherwise `consent.withdrawn`.
- `banner` is the banner's public **license code** — internal IDs are never exposed.
- `visitor_id` is the pseudonymous ID also found in consent logs and in the server SDK's `decision.visitorId`.
- `occurred_at` is ISO 8601.

> [!NOTE]
> Regression alerts from [continuous monitoring](/en/doc/compliance-monitoring) arrive on the same webhooks as `compliance.regression` events — handle unknown `type` values gracefully.

## 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 `4xx` response (except `429`) stops the retries — retrying would not change the outcome.
- Respond `2xx` fast and process asynchronously: anything else counts as a failed delivery.
- The last delivery status and time are visible on each webhook in the app.
