A webhook is an HTTP request a service sends to your server to tell you something happened, such as a payment succeeded or a document was signed. Providers sign these requests with a shared secret (a key known only to the provider and your server) so you can confirm the request came from them and that nobody altered it in transit. The signature check is the main security boundary for your webhook endpoint, but a simple implementation rejects legitimate retries: deliveries the provider resends because your server did not answer with a success status (2xx) in time.
The fix is to keep verification and retry handling separate. Verify the signature over the exact request bytes and accept a timestamp within about five minutes of the current time. Compare signatures in constant time, a timing-safe comparison that does not reveal partial matches. Then check the event ID for duplicates. A retry is a new delivery of the same event, so it carries a valid signature and often a fresh timestamp. The timestamp check limits replay attacks, in which an attacker sends a captured request again; the event ID is what stops the same event from being processed twice.
The timestamp check rejects old deliveries. Deduplication prevents an accepted delivery from creating another job within the retention window.
What a webhook signature proves
Most webhook providers use an HMAC, which stands for hash-based message authentication code. The provider computes a hash of the request body plus a timestamp using the shared secret and sends the result in a header; your server recomputes the hash from the bytes it received and compares the two values. If they match, the request came from someone holding the secret and the body was not modified in transit.
A valid signature proves the request came from someone holding the shared secret and that the bytes were not modified. It does not authorize the sender to do anything beyond what your handler decides to do with the event, and it does not prove the event is new. An attacker can send a captured signed request again within that five-minute window, so replay protection comes from accepting only recent timestamps plus checking the event ID for duplicates.
Signature verification is also the right replacement for IP whitelisting, where you allow only a list of provider IP addresses to reach your server. Affirm recommends against IP whitelists for webhooks because provider IPs change as their infrastructure grows, which makes legitimate calls fail. A few providers use a simpler model. Documenso includes the secret in a header, and verification is a constant-time comparison against the stored secret. Its documentation notes that verification is skipped when no secret is configured, which is not recommended for production.
Verify the raw body before parsing
The signature is computed over the exact bytes the provider sent. If you parse the JSON into an object and then turn it back into a string, whitespace and key order change the bytes, and the signature no longer matches. The common causes of signature failure are a timestamp that differs from the current time by more than the tolerance, an incorrect webhook secret or one that was replaced (rotated), and the request body being changed before verification, according to a Stripe webhook retry guide. Capture the raw request body before any JSON parsing middleware touches it, and verify the signature over those bytes.
Limit the request body size before accepting it. Verify the signed bytes before parsing JSON or running business logic. The route below uses express.raw with a 256 KB limit. Choose a limit that fits your provider’s documented payload size.
Check the timestamp and compare signatures in constant time
The timestamp is part of the signed content, so an attacker cannot change it without invalidating the signature. Reject requests whose timestamp is more than about five minutes from the current time. Svix recommends a tolerance of about five minutes before or after now, and TrueLoyal’s verification guide rejects timestamps more than five minutes from the current time. This limits how long a captured request can be replayed.
The check depends on your server clock. If the clock drifts more than five minutes, valid webhooks fail the timestamp check, so keep NTP (Network Time Protocol) enabled to keep your server clock accurate.
Compare signatures with a constant-time function. A standard string equality check can leak information through timing: the comparison stops at the first mismatched byte, and an attacker who can measure response times can reconstruct the signature. Use a constant-time comparison function, which always takes the same time regardless of where the mismatch occurs. Provider SDKs (software development kits) implement this for you.
A verification example
This Node.js and Express example uses Standard Webhooks. Spaces separate signature entries. Each entry contains a version and a Base64 signature, separated by a comma. The signed bytes are the event ID, a dot, the delivery timestamp, another dot, then the unchanged request body. Other providers use different formats. Use their SDK when available.
const crypto = require('crypto');
const express = require('express');
const app = express();
// Set WEBHOOK_SECRETS to the provider's signing secret. During
// rotation, separate the old and new secrets with a comma.
const secrets = process.env.WEBHOOK_SECRETS.split(',').map(value =>
Buffer.from(value.trim().replace(/^whsec_/, ''), 'base64')
);
if (secrets.some(secret => secret.length === 0)) {
throw new Error('Configure a webhook signing secret');
}
function verifyStandardWebhook(body, headers, now = Math.floor(Date.now() / 1000)) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!Buffer.isBuffer(body) || typeof id !== 'string' || !id ||
typeof timestamp !== 'string' || !/^\d+$/.test(timestamp) ||
typeof signatures !== 'string') {
throw new Error('Missing or malformed webhook headers');
}
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > 300) {
throw new Error('Timestamp outside tolerance');
}
// Spaces separate signatures; the comma separates version and value.
for (const secret of secrets) {
const expected = crypto.createHmac('sha256', secret)
.update(`${id}.${timestamp}.`).update(body).digest();
for (const entry of signatures.trim().split(/\s+/)) {
const match = /^v1,([A-Za-z0-9+/]{43}=)$/.exec(entry);
if (!match) continue;
const received = Buffer.from(match[1], 'base64');
if (received.length === expected.length &&
crypto.timingSafeEqual(received, expected)) return id;
}
}
throw new Error('Invalid signature');
}
// Mount this route before any express.json() middleware.
app.post('/webhooks', express.raw({ type: 'application/json', limit: '256kb' }),
async (req, res) => {
let eventId;
let payload;
try {
eventId = verifyStandardWebhook(req.body, req.headers);
payload = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).send('Invalid webhook');
}
try {
// Application integration: atomically insert a unique event ID
// and a durable job in ONE database transaction. A committed
// duplicate is a no-op; a failed transaction must throw.
await enqueueOnceDurably(eventId, payload);
return res.status(200).send('Accepted');
} catch {
return res.status(503).send('Try again');
}
});
Mount this route before JSON parsing middleware. It rejects malformed headers and checks the raw bytes before parsing the payload. WEBHOOK_SECRETS holds the configured signing secrets. enqueueOnceDurably is an application integration you must supply: insert the unique event ID and its job in one database transaction. Return success only after that transaction commits. A committed duplicate can return success. A failed transaction must remain retryable.
The worker must make its business changes idempotent. For external API calls, use the event ID as an idempotency key where supported. A unique queue record alone cannot make an external side effect happen exactly once.
If the provider offers an SDK, prefer it, because the SDK handles the provider-specific header format, timestamp checking, and constant-time comparison for you.
Why webhook retries still verify
Senders retry on timeouts and on any response that is not a 2xx success status, and most providers give an endpoint 5 to 30 seconds before marking a delivery failed, so a slow handler produces duplicate deliveries rather than lost ones, per Svix’s receiving best practices. The retry schedule depends on the provider. Svix publishes 8 attempts over 24 hours, Stripe retries for up to three days in live mode, and Affirm does not currently resend webhook events at all.
A retry is a new delivery of the same event, and the provider re-signs it. PaySG regenerates the signature and timestamp on each retry. The retry therefore passes the same verification as the original delivery: a valid signature over the new delivery’s bytes and a timestamp within tolerance of the current time.
The timestamp check compares against the time the delivery arrives, not the time the event originally occurred. A retry that arrives hours later with a fresh timestamp passes. If a provider did not regenerate the timestamp, a delayed retry could fall outside the tolerance window and be rejected; that is the documented tradeoff of replay protection. Apply the provider’s documented tolerance and do not loosen it to accommodate slow retries, because checking the event ID for duplicates handles events that were already processed.
Deduplicate on the event ID
Assume at-least-once delivery: the provider may send the same event more than once, and only your server can tell that a repeated event is the same event. Store the unique event ID and check it before handling the event. Svix recommends storing the unique identifier to track whether a message has already been processed, which prevents handling the same message twice due to a retry or a replay.
Use a unique event ID to prevent concurrent deliveries from creating separate jobs. Commit the ID and the durable job together. Marking an event as processed before its work is safely recorded can lose the event if the next step fails. Keep the deduplication record for at least the provider’s retry window. Stripe also documents duplicate events that may need a business key beyond the delivery ID.
Accept multiple signatures during rotation
During secret rotation, a sender may include signatures made with both keys. Svix documents a space-delimited signature list. The example checks each supported entry against each configured secret. It accepts a delivery when one matches.
Add the new secret to the receiver before the sender switches keys. Remove the old secret after the provider’s overlap period. This example verifies only v1 HMAC-SHA256 signatures. An unsupported version cannot pass verification on its own.
Key Takeaways
- Verify the raw request bytes before any JSON parsing: turning parsed JSON back into a string changes whitespace and key order, which breaks the signature.
- Reject timestamps more than five minutes from the current time: this limits replay attacks, and keeping NTP enabled stops your server clock from drifting far enough to reject valid webhooks.
- Use constant-time comparison, preferably through the provider’s SDK: a standard string equality check can leak signature bytes through timing.
- Treat retries as new deliveries: verify each one normally and check the event ID for duplicates so at-least-once delivery does not become double processing.
- Accept any matching signature entry during secret rotation: ignore non-
v1schemes to prevent downgrade attacks.