Verify webhook signatures
Validate raw request bodies, timestamp freshness and stable event identities.
Request headers
- X-Form-Event-Id: stable event identifier used for deduplication.
- X-Form-Timestamp: delivery attempt timestamp in Unix seconds.
- X-Form-Signature: versioned v1=<hex-hmac-sha256> signature.
- A 2xx response acknowledges a delivery; unsupported 4xx errors are generally final.
X-Form-Event-Id: submission.created:YOUR_SUBMISSION_ID
X-Form-Timestamp: 1760000000
X-Form-Signature: v1=YOUR_64_CHARACTER_HEX_DIGESTSigned bytes and comparison
Compute HMAC-SHA256 using the webhook signing secret and the exact bytes of <timestamp>.<raw-request-body>. Verify before parsing or changing the request body, and compare signatures in constant time.
Reject stale timestamps within a tolerance appropriate for your service, and deduplicate using X-Form-Event-Id because retries can repeat the same event.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody, timestamp, signature, secret) {
if (!/^v1=[a-f0-9]{64}$/.test(signature ?? '')) return false;
const now = Math.floor(Date.now() / 1000);
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > 300) return false;
const expected = createHmac('sha256', secret)
.update(String(timestamp) + '.')
.update(rawBody)
.digest();
const actual = Buffer.from(signature.slice(3), 'hex');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
// rawBody must be a Buffer containing the unmodified HTTP request bytes.Retries and replay
A signed delivery may be retried when the destination times out or responds with eligible retryable errors. The stored payload and event ID remain stable, but the timestamp and signature are computed for the attempt.
Manual replay can resend a previously successful event. Keep your receiver idempotent and inspect delivery history for the outcome.
Webhook overview