Webhook signature verification, explained
How signed webhook verification works and why you should never trust an unsigned payload — with a real HMAC verification example.
PostGate signs <timestamp>.<raw-body> with HMAC-SHA256 using your webhook's signing secret and sends the result as X-PostGate-Signature: t=<timestamp>,v1=<hex digest>. To verify: capture the exact raw body before any parsing, reject timestamps outside a small tolerance window, recompute the HMAC over the same string, and compare digests with a constant-time comparison like crypto.timingSafeEqual.
Anyone who knows (or guesses) your webhook URL can POST a fake post.published or profile.reauth_required event to it. Without verification, your consumer has no way to tell that payload apart from a real one — and depending on what your handler does with an event, that's a direct path to a forged status update or a spoofed authorization-failure notice. Signature verification is the control that closes that gap: it proves a payload was produced by PostGate, with the exact bytes it was signed with, and hasn't been replayed from days ago.
PostGate handles OAuth, publishing and delivery evidence for every network through one API. Start free or read the docs.
What is PostGate’s exact signing scheme?
PostGate signs <timestamp>.<raw-body> — the Unix timestamp of signing, a literal dot, then the exact raw request body bytes — using HMAC-SHA256 with your webhook's signing secret. The result is sent in the X-PostGate-Signature header as two comma-separated key/value pairs, t (the timestamp used in the signed string) and v1 (the hex-encoded HMAC digest). The event's stable ID is sent separately as X-PostGate-Event-Id. See Webhooks for the canonical reference — the header shape below is copied directly from there.
t=1785254400,v1=4f7c...9a2eWhy does it have to be the raw body, not the parsed one?
The signature covers the exact bytes PostGate sent, not your framework's re-serialized version of the parsed JSON. Key ordering, whitespace, and number formatting can all change silently when a body is parsed and re-stringified — and any of those changes will make an otherwise-valid signature fail to verify. This is the most common cause of "verification never works" bug reports for any HMAC-signed webhook, not just PostGate's: some framework middleware (Express's default JSON body parser included) consumes and parses the body before your handler ever sees it, discarding the raw bytes you need. Capture the raw body explicitly before any parsing happens.
A complete, working verification example (Node.js)
This uses Node's built-in crypto module — no dependencies — and Express with the raw body captured via express.raw() so nothing re-serializes the payload before you verify it.
const crypto = require('crypto');
const express = require('express');
const WEBHOOK_SECRET = process.env.POSTGATE_WEBHOOK_SECRET; // shown once at creation — see /docs/webhooks
const MAX_TIMESTAMP_SKEW_SECONDS = 300; // reject anything older than 5 minutes to block replay
function verifyPostGateSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader) return { valid: false, reason: 'missing signature header' };
// Parse "t=1785254400,v1=4f7c...9a2e"
const parts = Object.fromEntries(
signatureHeader.split(',').map((pair) => {
const [key, value] = pair.split('=');
return [key, value];
})
);
const { t: timestamp, v1: signature } = parts;
if (!timestamp || !signature) return { valid: false, reason: 'malformed signature header' };
// Reject stale timestamps before doing any crypto work
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > MAX_TIMESTAMP_SKEW_SECONDS) return { valid: false, reason: 'timestamp outside tolerance' };
// Recompute HMAC-SHA256 over "<timestamp>.<raw-body>"
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
// Constant-time comparison — never use === on secrets or digests
const expectedBuf = Buffer.from(expected, 'hex');
const providedBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length !== providedBuf.length) return { valid: false, reason: 'signature length mismatch' };
if (!crypto.timingSafeEqual(expectedBuf, providedBuf)) return { valid: false, reason: 'signature mismatch' };
return { valid: true };
}
const app = express();
// Capture the exact raw bytes — do NOT use express.json() on this route
app.post('/webhooks/postgate', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8'); // Buffer -> exact string that was signed
const result = verifyPostGateSignature(rawBody, req.header('X-PostGate-Signature'), WEBHOOK_SECRET);
if (!result.valid) {
console.warn('Webhook signature rejected:', result.reason);
return res.status(400).send('invalid signature');
}
const event = JSON.parse(rawBody);
const eventId = req.header('X-PostGate-Event-Id'); // matches event.id, dedupe on this
// ack fast; do real processing after this responds — see webhook-retries-best-practices
res.status(200).send('ok');
void handleEvent(event, eventId);
});What mistakes break verification in production?
- Reading a body-parsing middleware's re-serialized JSON instead of the raw bytes — the single most common cause of "valid signatures always fail."
- Using
===orBuffer.equals()to compare digests instead of a constant-time comparison likecrypto.timingSafeEqual— a naive string comparison leaks timing information an attacker can use to guess the correct signature byte by byte. - Skipping the timestamp check. Verifying the HMAC alone proves the payload wasn't tampered with; it doesn't prove it's not a captured request being replayed months later. Reject anything outside a small tolerance window before you even compute the digest.
- Hardcoding a header name or algorithm you haven't confirmed. If you're integrating with a provider other than PostGate, verify its exact header name, hash algorithm, and signed-string format in that provider's own docs before adapting the code above — signing schemes that look similar (Stripe-style
t=...,v1=...headers are common) are not always byte-for-byte identical.
Frequently asked questions
Why does my webhook signature verification always fail?
The single most common cause is reading a body-parsing middleware’s re-serialized JSON instead of the raw bytes PostGate signed. Key ordering, whitespace, and number formatting can all change silently when a body is parsed and re-stringified, and any of those changes makes a valid signature fail. Express’s default JSON body parser consumes the raw body before your handler sees it — capture it explicitly with express.raw() before any parsing happens.
What is in the X-PostGate-Signature header?
Two comma-separated key/value pairs: t, the Unix timestamp used in the signed string, and v1, the hex-encoded HMAC-SHA256 digest computed over <timestamp>.<raw-body> with your webhook’s signing secret. The event’s stable ID is sent separately in the X-PostGate-Event-Id header. Example shape: t=1785254400,v1=4f7c...9a2e.
Why do I need a timestamp check if the HMAC already matches?
Verifying the HMAC alone proves the payload wasn’t tampered with; it doesn’t prove it isn’t a captured request being replayed months later. Reject anything outside a small tolerance window — the example code uses 5 minutes — before you even compute the digest, so stale replays are dropped cheaply.
Why use crypto.timingSafeEqual instead of === to compare signatures?
A naive comparison like === or Buffer.equals() leaks timing information: it returns faster the earlier the first mismatched byte appears, which an attacker can use to guess the correct signature byte by byte. A constant-time comparison such as crypto.timingSafeEqual takes the same time regardless of where the digests differ, closing that side channel.
Can I reuse this verification code for another provider's webhooks?
Only after checking that provider’s own docs for its exact header name, hash algorithm, and signed-string format. Signing schemes that look similar — Stripe-style t=...,v1=... headers are common — are not always byte-for-byte identical, so verify the specifics before adapting the code rather than hardcoding assumptions.
