Webhook Signature Verification
Verify HMAC-signed email webhooks before processing delivery, bounce, complaint, and inbound email events.
TL;DR
Verify webhook signatures against the raw request body before parsing JSON. Use constant-time comparison, reject stale timestamps, and make the handler idempotent because providers retry webhooks.
What you will learn
- Verify webhook authenticity safely
- Avoid JSON reserialization bugs
- Combine signature checks with idempotent processing
What signatures prove
A webhook signature proves that the request body was produced by a party with the shared signing secret. It does not prove that your application has not already processed the event.
Use signatures for authenticity and integrity. Use idempotency for duplicate safety.
Verification sequence
- Read the raw request body.
- Read the timestamp and signature headers.
- Reject timestamps outside your tolerance window.
- Build the signed payload exactly as documented.
- Compute HMAC-SHA256 with the webhook signing secret.
- Compare expected and received signatures in constant time.
- Only then parse JSON and process the event.
Do not log the signing secret. Do not put the secret in client-side code.
Node example
import crypto from "node:crypto";
function verifyPostscaleWebhook({
rawBody,
timestamp,
signature,
secret,
}: {
rawBody: Buffer;
timestamp: string;
signature: string;
secret: string;
}) {
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
return false;
}
const signedPayload = Buffer.concat([
Buffer.from(timestamp),
Buffer.from("."),
rawBody,
]);
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex")
);
}
Adjust header names and signed payload format to match your provider's documentation.
Handler checklist
- Keep the raw body available in your framework.
- Disable automatic body parsing for the webhook route if needed.
- Reject missing signatures.
- Reject stale timestamps.
- Compare in constant time.
- Store event IDs before side effects.
- Return a 2xx response only after durable processing or durable enqueue.
Use webhook retries and idempotency for the processing side.
Common failures
| Failure | Cause | Fix |
|---|---|---|
| Signature mismatch | Verifying parsed JSON | Verify raw bytes |
| Valid old request replayed | No timestamp check | Enforce a short tolerance window |
| Duplicate status updates | Retried webhook | Store event IDs |
| Lost event after crash | Side effect before durable write | Enqueue or write first |
References
Frequently asked questions
- Should I verify the parsed JSON body?
- No. Verify the exact raw request body. JSON parsing and reserialization can change whitespace or field order and break the signature.
- Why do I need idempotency if signatures are valid?
- A valid webhook can still be delivered more than once because retries are normal. Store event IDs and ignore duplicates.
Related guides
Put the guide into production
Postscale brings sending, inbound processing, DMARC reporting, and masked addresses behind one API so the operational pieces stay connected.