Webhook format
The envelope every webhook delivery shares, its headers, how the signature is computed and checked, the retry schedule and the list of event types.
Every webhook delivery is a signed POST with a JSON body of the same outer shape. To build a receiver, read Receive webhooks.
Envelope#
{
"id": "4f1c2b9e-7d0a-4c55-9a51-2d8e6f0b3a17",
"type": "consent.withdrawn",
"createdOn": "2026-09-24T10:12:03.412Z",
"projectId": "c2a6f0de-51b3-4f0a-9d7e-8b1f3a6c4e90",
"test": false,
"data": {}
}| Field | Type | Description |
|---|---|---|
id |
UUID | A unique id for the event. It is the same on every retry. Use it to drop duplicates. |
type |
string | The event type. It is also sent in the X-Piedap-Event header. |
createdOn |
time | When the event was queued. |
projectId |
UUID | Your project. |
test |
boolean | true when the event is about a record written with a test key. false for live records and for events that are not about a record. |
data |
object | The event's own fields. See the event's entry. |
Event types#
An endpoint receives only the event types ticked on it in the console. See Webhook endpoints.
| Type | Sent when |
|---|---|
consent.granted |
A person's first answer on a form is recorded, whatever the answer. |
consent.updated |
An existing record changes. |
consent.withdrawn |
A record is withdrawn. |
consent.expired |
A record reaches the end of its term. |
consent.imported |
An import from a file finished or was stopped. One event for the whole file, none for each record. |
subject.erased |
A record is erased on request. |
subject.erasure_notice |
A person is 72 hours from being erased for inactivity. |
subject.erased_inactive |
A person was erased for inactivity. |
subject_request.received |
Someone submitted the hosted rights request form. |
subject_request.due_soon |
An open request is within five days of its deadline, or past it. |
subject_request.completed |
A request was closed. |
subject.rectification_requested |
A person confirmed a correction request, or your team logged one. |
form.published |
A new version of a form was published. |
region.rule.changed |
A region rule was created, changed or deleted. |
evidence.anchored |
A day's evidence was sealed under one signed root. |
scan.changes_detected |
A cookie scan found something the last scan did not. |
breach.report_due |
A breach report is due within 12 hours. |
breach.people_notice |
The notice to the people a breach affects was sent to your systems to pass on. |
legal_hold.placed |
A legal hold was placed. |
legal_hold.released |
A legal hold was released. |
usage.threshold_reached |
The month's records crossed 80% or 100% of your limit. |
propagation.overdue |
Processor tasks passed their due date with nothing reported. Once a day for each processor. |
campaign.batch_issued |
A batch of notice links is ready to download. |
campaign.withdrawn |
A person withdrew on a notice link, and it was applied. |
campaign.anchored |
A day of notice answers and batches was anchored. Keep a copy. |
ruleset.changed |
The DPDP dates or rules the checks use changed since the project last acknowledged them. |
policy.published |
A privacy policy or notice was published. Lists the linked forms that show an earlier version. |
project.closing |
An owner scheduled the project's deletion, or cancelled it. |
project.deleted |
The project's data was deleted. |
consent_manager.notification_failed |
A Consent Manager could not be told, after the last retry, that a person changed or withdrew consent given through it. |
Headers#
| Header | Value |
|---|---|
X-Piedap-Event |
The event type. |
X-Piedap-Timestamp |
When this attempt was sent, in Unix seconds. |
X-Piedap-Signature |
sha256= followed by the signature in lower-case hex. |
User-Agent |
PIEDAP-Consent-Webhook/1.0 |
Content-Type |
application/json |
X-Piedap-Event: consent.withdrawn
X-Piedap-Timestamp: 1790000000
X-Piedap-Signature: sha256=a5e5e6ac6418826ab09e3bcaa2ffcfdb6d9b1b6ccb75f…Signature#
The signature is the HMAC-SHA256 of the timestamp, a full stop and the raw body. The key is the endpoint's signing secret, whsec_…, shown once when the endpoint is created.
HMAC-SHA256(secret, "<X-Piedap-Timestamp>.<raw body>")To check a delivery:
- Read the raw body as bytes, before any JSON parser touches it.
- Read
X-Piedap-Timestamp. Refuse the delivery if it is more than 300 seconds from your clock, in either direction. - Compute the HMAC-SHA256 of the timestamp, a full stop and the raw body, keyed with the signing secret. Write it as lower-case hex.
- Compare it with the value after
sha256=inX-Piedap-Signature, using a constant-time comparison.
import {
SIGNATURE_HEADER,
TIMESTAMP_HEADER,
verifyWebhook,
} from '@piedap/consent/server';
const ok = verifyWebhook({
payload: rawBody,
signature: req.get(SIGNATURE_HEADER),
timestamp: req.get(TIMESTAMP_HEADER),
secret: process.env.PIEDAP_WEBHOOK_SECRET,
});import os
from piedap_consent import (
SIGNATURE_HEADER,
TIMESTAMP_HEADER,
verify_webhook,
)
ok = verify_webhook(
raw_body,
request.headers.get(SIGNATURE_HEADER),
request.headers.get(TIMESTAMP_HEADER),
secret=os.environ["PIEDAP_WEBHOOK_SECRET"],
)$payload = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_PIEDAP_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_PIEDAP_SIGNATURE'] ?? '';
$secret = getenv('PIEDAP_WEBHOOK_SECRET');
$fresh = ctype_digit($timestamp)
&& abs(time() - (int) $timestamp) <= 300;
$expected = 'sha256='
. hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
if (!$fresh || !hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}rawBody and raw_body are the body exactly as received. Each retry is signed again with a fresh timestamp, so a late retry still passes. The reasons behind each step are in Verify signatures.
Response and retries#
A delivery counts as made when your endpoint answers with a 2xx status within eight seconds. Anything else is a failure and is tried again: a redirect, another status, a timeout or a connection error.
| Attempt | Sent |
|---|---|
| 1 | At once |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 6 hours later |
| 7 | 12 hours later |
| Rule | Detail |
|---|---|
| Attempts | Seven, over about 21 hours. After the seventh failure the event is kept as failed for 30 days, and can be replayed. |
| Endpoint turned off | After ten events in a row have each failed all seven attempts. One successful delivery resets the count. |
| While an endpoint is off | Its events are kept as failed for 30 days, and can be replayed once it is on again. |
| Delivery log | The console keeps each attempt, with your server's answer, for 30 days. |
Turn the endpoint back on yourself
An endpoint that was turned off stays off. Turn it on in the console once your server is fixed, then replay its failed events. See Replay failed webhooks.
Duplicates and order#
| Behaviour | What to do |
|---|---|
The same event can arrive more than once. Every attempt carries the same id. |
Store the ids you have handled and skip an event you have seen. |
| Events can arrive out of order. A retry of an older event can follow a newer event for the same record. | Compare data.collectedOn and data.withdrawnOn with what you last stored, and keep the newer state. |
subject.erased can arrive before the consent.withdrawn for the same record. |
Treat subject.erased and subject.erased_inactive as final. |