Verifying webhooks
Standard Webhooks HMAC-SHA256 verification — headers, replay protection, key rotation, and the delivery retry/DLQ contract. Includes a Node verify sample.
Verifying webhooks
Batohi signs every delivery with Standard Webhooks.
Headers
| Header | Value |
|---|---|
content-type | application/json |
webhook-id | the delivery id (use for idempotent dedup) |
webhook-timestamp | unix seconds at delivery time |
webhook-signature | v1,<base64 HMAC-SHA256> |
Verification
The signature is HMAC-SHA256 over the canonical string ${id}.${timestamp}.${body},
where:
idis thewebhook-idheader,timestampis thewebhook-timestampheader (unix seconds, as a string),bodyis the exact raw request body — do not re-serialize the JSON, or key reorder will break the signature.
The signing key is the whsec_<base64> secret shown once when you create or
rotate an endpoint. Decode the base64 before using it as the HMAC key.
Replay protection
Reject any delivery whose |now - webhook-timestamp| exceeds your tolerance —
Batohi's verifier uses 5 minutes. There is no nonce; the deterministic
webhook-id is the dedup key.
Node verification sample
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret: string, body: string, headers: Record<string, string>): boolean {
const id = headers['webhook-id'];
const ts = headers['webhook-timestamp'];
const sig = headers['webhook-signature']; // "v1,<base64>"
if (!id || !ts || !sig) return false;
// Reject stale deliveries.
const ageSec = Math.abs(Date.now() / 1000 - Number(ts));
if (ageSec > 5 * 60) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64');
// Allow space-delimited multiple tokens (rotation overlap, per spec).
return sig
.split(' ')
.some((token) => token.startsWith('v1,') && safeEqual(token.slice(3), expected));
}
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}Key rotation
Rotate an endpoint's secret with POST /api/webhooks/[id]/rotate. The response
returns the new secret once; the old secret is invalidated immediately.
There is no overlap window and no key-id header — consumers verify with the
current secret and treat a failure as a revoked key.
Delivery contract
| Method | POST |
| Timeout | 10 seconds |
| Success | HTTP status in [200, 300) |
| Max attempts | 5 |
| Backoff | exponential, 2 ** attempts minutes (2, 4, 8, 16) |
| Dead-letter | after the final failure: processedAt set, lastError prefixed DEAD: |
| Audit | dead-lettered rows are retained, never deleted |
A 3xx, 4xx, 5xx, network error, or timeout is a failure that retries. A delivery
whose endpoint was deactivated after queueing is dropped (not retried), with
lastError = "endpoint inactive".
Configuring an endpoint
In the dashboard at /dashboard/webhooks, or via the management API:
| Method | Path |
|---|---|
| List | GET /api/webhooks |
| Create | POST /api/webhooks — body { url, eventTypes? }, returns the secret once |
| Delete | DELETE /api/webhooks/[id] |
| Pause / resume | PATCH /api/webhooks/[id] — body { active: boolean } |
| Rotate secret | POST /api/webhooks/[id]/rotate |
Subscription filter: eventTypes is an array of public event names; an empty
array means "subscribe to all". Validation: the URL must be https:, not
localhost / *.local, and not a private/loopback/metadata IP. Cap: 10
endpoints per user.