Webhooks
Webhooks tell your systems the moment something changes: a consent is granted or withdrawn, a rights request arrives, an erasure is requested. Every delivery is signed, so you can check it came from OptProof.
Starter and aboveSet up an endpoint#
- In the console, open Integrations, then Webhooks, and choose Add endpoint.
- Enter an
httpsURL that is reachable from the internet, and pick the events you want. - Copy the signing secret (it starts with
whsec_). It is shown once. Store it in your server's secrets. - Send a test event from the console and check it arrives.
Respond with any 2xx status within 10 seconds; slower answers count as a failure and are retried. Redirects are not followed. Do slow work, such as updating your CRM, in a background job after you have answered.
Events#
| Event | Sent when |
|---|---|
consent.granted | Someone agrees to one or more purposes, on any channel. |
consent.withdrawn | Someone withdraws consent for one or more purposes. Stop using their data for those purposes. |
consent.expired | A consent reaches the end of its retention period and is marked expired. |
request.created | A rights request is filed in the portal or added by your team. |
request.due_soon | An open request passes a reminder point before its due date. |
request.resolved | A request is closed with a resolution. |
erasure.requested | An erasure request needs action in your systems. Delete or anonymise the person, or record why you must keep the data. |
incident.created | Your team opens a data breach incident. |
notice.published | A new notice version is published for a property. |
Payload#
Every delivery is a JSON envelope. data holds references only: a pseudonymous principal_ref, receipt codes and ids. Webhooks never contain phone numbers, emails or names.
{ "id": "evt_01JBTT8000E008000000000093", "type": "consent.withdrawn", "created_at": "2026-10-22T11:02:09.412Z", "tenant_id": "ten_01JBTT8000E008000000000001", "test": false, "data": { "event_id": "evt_01JBTT8000E008000000000093", "property_id": "prp_01JBTT8000E008000000000002", "principal_ref": "3f9a0c...e1", "device_ref": null, "action": "withdraw", "purpose_keys": ["whatsapp_reminders"], "notice_version": 3, "channel": "portal", "receipt_code": "OP-4D8H-2M6Q", "occurred_at": "2026-10-22T11:02:09.004Z" }}Data by event type#
| Events | Fields in data |
|---|---|
consent.* | event_id, property_id, principal_ref, device_ref, action, purpose_keys, notice_version, channel, receipt_code, occurred_at |
request.*, erasure.requested | request_id, reference, type, status, principal_ref, due_at |
incident.created | incident_id, title, severity, aware_at |
notice.published | notice_id, property_id, version, material_change |
To find the person behind a principal_ref, look it up in your own records (store the principal_ref returned when you record consent), or call GET /v1/principals/{ref}/consents.
Request headers#
| Header | Value |
|---|---|
OptProof-Signature | t=…,v1=…, explained below. |
OptProof-Event | The event type, for example consent.withdrawn. |
OptProof-Delivery | The delivery id. The same id is sent again on retries. |
User-Agent | OptProof-Webhooks/1.0 |
Content-Type | application/json |
Verify the signature#
Each request carries a signature header:
OptProof-Signature: t=1793000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the Unix time (seconds) when OptProof signed the request.v1is the lowercase hex HMAC-SHA256 of the string`${t}.${raw body}`, keyed with your endpoint secret.- During secret rotation the header may carry more than one
v1=value. Accept the request if any of them matches. - Reject requests whose
tis more than 300 seconds from your clock, to stop replays.
Always compute the HMAC over the raw bytes you received. Parsing and re-serialising the JSON changes the bytes and breaks the signature. Compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';import express from 'express';const TOLERANCE_SECONDS = 300;function isValidSignature(rawBody, header, secret) { if (!header) return false; const parts = header.split(',').map((p) => p.trim().split('=')); const t = Number(parts.find(([k]) => k === 't')?.[1]); if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = Buffer.from( createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'), 'hex', ); return parts .filter(([k]) => k === 'v1') .some(([, v]) => { const given = Buffer.from(v ?? '', 'hex'); return given.length === expected.length && timingSafeEqual(given, expected); });}const app = express();app.post('/webhooks/optproof', express.raw({ type: 'application/json' }), (req, res) => { if (!isValidSignature(req.body, req.header('OptProof-Signature'), process.env.OPTPROOF_WEBHOOK_SECRET)) { return res.status(400).send('Invalid signature'); } const event = JSON.parse(req.body.toString('utf8')); res.sendStatus(200); // queue event.type / event.data for processing});With the Node.js SDK this is one call: optproof.verifyWebhook({ body, signature, secret }). See SDKs.
Retries and the delivery log#
If your endpoint does not answer 2xx, OptProof retries with exponential backoff for about 24 hours: after 1 minute, 5 minutes, 15 minutes, 30 minutes, then 1, 2, 4, 8 and 8 hours, with some random jitter. After the last attempt the delivery is marked failed.
- Every attempt is listed in the console's delivery log with the status code and timing.
- You can replay any delivery from the log, for example after fixing a bug.
- Deliveries can arrive more than once and out of order. Use the envelope
id(or theOptProof-Deliveryheader) to ignore duplicates, and theoccurred_attime to decide which change is newest.
Test events#
Events produced with test keys are delivered with "test": true. Point a staging endpoint at them, or ignore them in production.