Signature verification
Every webhook delivery includes two headers used together:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000The hex string after sha256= is HMAC-SHA256(secret, timestamp + "." + raw_body).
The dot is a literal byte; the timestamp is unix-seconds as ASCII.
Why verify
Webhook URLs leak. They show up in proxy logs, screenshots, browser
history, partner support tickets. Without a signature check, anyone
who learns your URL can POST a fake payment.settled event and
trick you into fulfilling unpaid orders. Verification cryptographically
proves the request came from InfraIO.
Including the timestamp inside the signed payload also gives you replay protection: an attacker who captures a delivery can’t re-send it later without the signature becoming detectably stale.
The algorithm
signed_payload = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)Then check the timestamp is recent (typical tolerance: ±5 minutes).
Always pass the raw request body bytes. Frameworks often parse
JSON before your handler runs; the re-stringified version may differ
from what we sent (key ordering, whitespace, number formatting), and
the HMAC won’t match. In Next.js App Router use
await req.text() before JSON.parse. In Express, mount
express.raw({ type: 'application/json' }) on the webhook route only.
Implementations
Node / TS
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyInfraIo({
body,
signature,
timestamp,
secret,
}: {
body: string; // raw text — NOT parsed JSON
signature: string; // value of the X-Signature header
timestamp: string; // value of the X-Timestamp header (unix seconds)
secret: string; // whsec_…
}): boolean {
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
return false; // too old or too far in the future
}
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Replay protection
The timestamp inside the signed payload is the first line of defence — an attacker who captures a delivery can’t re-send it after your tolerance window expires.
Belt-and-braces (recommended for high-value events like
payment.settled):
- Dedup on
X-Deliveryin a table with a unique constraint. Replays inside the tolerance window become no-ops — your handler returns 200 without doing work twice. This is the same idempotency you want for legitimate retries. (X-Deliveryis stable across every retry of a delivery; the payload carries noevent_idfield.) - Use the smallest tolerance your clock drift allows. ±5 minutes is the recommended default and matches what most NTP-synced fleets can sustain. Tighter is fine; below ±30 seconds you’ll start rejecting legitimate deliveries on networks with slow upstream NTP.
Rotating a secret
- Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
- A new secret is generated and shown exactly once. Copy it before closing the dialog.
- Update your env var and redeploy your verifier within 24 hours.
Grace window (dual-sign)
For the 24 hours after a rotation, every delivery carries two signatures:
X-Signature: sha256=<hmac(new_secret, ts + "." + body)>
X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp: 1729536000A verifier running the previous secret matches X-Signature-Prev;
a verifier running the new secret matches X-Signature. Either
header passing is enough — your handler can accept the delivery during
the migration without holding the deploy.
After the grace window closes, only X-Signature is sent. The previous
secret stops being accepted and any verifier still configured with it
will start rejecting deliveries — so finish your rollout inside the
24-hour budget.
Suggested receiver pattern
// Accept either signature during a rotation grace window.
const sig = req.headers["x-signature"] ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
|| (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));You can drop the X-Signature-Prev branch as soon as the grace window
on your endpoint has expired and you’ve removed PREV_SECRET from
your env.
Emergency revocation
If a secret leaked publicly and you need to invalidate the previous secret immediately — i.e. you don’t want the 24-hour overlap to keep a known-bad key alive — rotate twice. The first rotation moves the leaked secret into the prev slot; the second rotation pushes it out of the prev slot (replacing it with the still-new key) so the leaked value is no longer accepted.
Testing your wiring
In the dashboard, open Developers → Webhooks and click Send Test on the endpoint you want to verify. We sign and POST a synthetic envelope to the URL synchronously, then show the HTTP status, latency, and a 512-byte snippet of your response. Payload shape:
{
"event_id": "<uuid>",
"event_type": "webhook.test.ping",
"created_at": "2026-05-17T12:00:00Z",
"test": true,
"data": {
"merchant_id": "<your-merchant-id>",
"webhook_id": "<endpoint-id>",
"message": "Test ping from the merchant dashboard..."
}
}The test ping uses the same signing scheme as production deliveries, so a green check from this button confirms your verifier accepts real events too. Test pings bypass the RMQ retry pipeline — if you want to exercise retries, trigger a real event through the relevant API flow.
Common failures
| Symptom | Likely cause |
|---|---|
| Always returns false in dev | Body was JSON-parsed before HMAC. Read raw bytes first. |
| Worked yesterday, fails today | You rotated the secret but the env var on this server still has the old one. Redeploy with the new secret. |
| Fails for old events, works for new | A delivery was queued before rotation; the signature uses the old secret and your verifier no longer accepts it. Wait for the retry to drop it or replay via dashboard. |
| Off-by-one on timestamp comparison | Make sure you compare unix-seconds against unix-seconds. Date.now() in JS is milliseconds — divide by 1000. |
| Works locally, fails on prod | A proxy (Cloudflare, nginx) is decompressing, re-encoding, or stripping a trailing newline. Inspect the bytes your handler sees. |
| Test ping says 401 / signature mismatch | Your verifier is signing body only (pre-2026 scheme). Update to sign timestamp + "." + body. |
| Header missing entirely | The endpoint is registered for a different environment. Test-mode endpoints only receive environment=test events. |