Verifying signatures
Every delivery is signed with your endpoint’s secret. Verify the signature before you act on an event; anyone who knows your URL could otherwise post fake events.
Two headers carry what you need:
X-Webhook-Timestamp: when the attempt was made, in seconds since the Unix epoch. It is renewed on every retry.X-Webhook-Signature:v1=followed by the hex HMAC-SHA256 of"<X-Webhook-Timestamp>.<raw request body>", keyed with the endpoint’swhsec_...secret.
With an SDK
Section titled “With an SDK”The SDKs verify the signature and the timestamp and return the parsed event.
import { constructWebhookEvent, WebhookSignatureError } from '@clearance-rest/sdk';
// Express: read the body as text on this route, for example express.text({ type: 'application/json' }).app.post('/hooks/clearance', async (req, res) => { try { const event = await constructWebhookEvent(req.body, req.headers, process.env.WEBHOOK_SECRET!); // 300 s tolerance if (event.type === 'number.renewal_failed') { console.log(event.data.reason); } res.sendStatus(200); } catch (error) { if (error instanceof WebhookSignatureError) return res.sendStatus(400); throw error; }});constructWebhookEvent takes the raw body as a string, and the headers as a Headers object or a plain
object. verifyWebhookSignature(raw, headers, secret, { toleranceSec }) returns a boolean instead of throwing. Both
use Web Crypto and run on Node, Cloudflare Workers and in browsers. They are also available as
client.webhooks.constructEvent and client.webhooks.verifySignature.
use ClearanceRest\Exceptions\WebhookSignatureException;use ClearanceRest\Services\Webhooks;
$raw = file_get_contents('php://input');
try { $event = Webhooks::constructEvent($raw, getallheaders(), $secret); // 300 s tolerance} catch (WebhookSignatureException) { http_response_code(400); exit;}
if ($event['type'] === 'number.renewal_failed') { error_log($event['data']['reason']);}http_response_code(200);Webhooks::verifySignature($raw, $headers, $secret, toleranceSec: 300) returns a boolean instead of throwing. Both are
static and make no network request.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret: string, rawBody: string, headers: Record<string, string>): boolean { const timestamp = headers['x-webhook-timestamp']; const signature = headers['x-webhook-signature'] ?? ''; if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b);}function verify(string $secret, string $rawBody, array $headers): bool{ $headers = array_change_key_case($headers, CASE_LOWER); $timestamp = $headers['x-webhook-timestamp'] ?? ''; $signature = $headers['x-webhook-signature'] ?? ''; if ($timestamp === '' || abs(time() - (int) $timestamp) > 300) { return false; }
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature);}Respond with a 2xx as soon as the signature checks out, and handle the event afterwards. Deliveries are at least
once, so deduplicate on event.id.
Rotating the secret
Section titled “Rotating the secret”POST /webhooks/endpoints/{id}/rotate-secret returns a new secret and
invalidates the old one at once, so deliveries fail verification until your receiver uses the new secret. Update your
receiver right after rotating; failed deliveries are retried for about a day, and you can
retry any that ran out.