Verify webhook signatures
Check the signature and timestamp on every webhook delivery, in Node.js, Python, PHP or any language, and send yourself a signed test event.
Anyone can post to your endpoint. Check the signature on every delivery before you believe it. The signature proves that the delivery came from your deployment and is recent.
Before you start#
- The endpoint's Signing secret,
whsec_…, stored on your server asPIEDAP_WEBHOOK_SECRET. It is shown once, when the endpoint is created. - A receiver route. See Receive webhooks.
Check a delivery#
Each delivery carries two headers:
X-Piedap-Timestamp: 1790000000
X-Piedap-Signature: sha256=a5e5e6ac6418826ab09e3bca…852ea219fe0e2c5bThe value after sha256= is 64 hex characters, shortened here. The SDK helpers do the whole check:
import {
SIGNATURE_HEADER,
TIMESTAMP_HEADER,
verifyWebhook,
} from '@piedap/consent/server';
const ok = verifyWebhook({
payload: rawBody, // Buffer or string, exactly as received
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, # bytes, exactly as received
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;
}In any other language, do the same steps:
- 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, with the signing secret as the key.
- Write the result as lower-case hex.
- Compare it with the value after
sha256=inX-Piedap-Signature, using a constant-time comparison. - Answer
401when either check fails.
A genuine delivery passes. A changed body, an old timestamp or the wrong secret fails.
Keep the raw body#
The signature covers the exact bytes that were sent. If your framework parses the JSON and you turn it back into text, spaces and key order can change. A genuine delivery then fails the check. Read the body raw for this one route:
- Express:
express.raw({ type: '*/*' })on the webhook route, placed before any JSON parser. - Flask:
request.get_data(). - Django:
request.body.
The timestamp is part of what is signed. A delivery captured today cannot be replayed next week, because its timestamp is too old and cannot be changed without breaking the signature. Each retry is signed again with a fresh timestamp. Keep your server's clock in step.
Send yourself a test event#
To try a receiver without waiting for a real event, sign a body the same way the service does.
BODY='{"id":"7d0a4c55-9a51-4f1c-8b9e-2d8e6f0b3a17",'
BODY+='"type":"consent.withdrawn",'
BODY+='"createdOn":"2026-09-24T10:12:03.000Z",'
BODY+='"projectId":"sample-project","test":true,'
BODY+='"data":{"consentId":"cns_sample01","status":"WITHDRAWN"}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$PIEDAP_WEBHOOK_SECRET" -r \
| cut -d' ' -f1)
curl -i -X POST http://localhost:4817/webhooks/piedap \
-H "Content-Type: application/json" \
-H "X-Piedap-Event: consent.withdrawn" \
-H "X-Piedap-Timestamp: $TS" \
-H "X-Piedap-Signature: sha256=$SIG" \
--data-binary "$BODY"Your receiver answers 200. Change one character of BODY after signing, or set TS ten minutes back, and it answers 401.