Skip to Content
WebhooksSignature verification

Signature verification

Every webhook delivery includes two headers used together:

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

The 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

lib/verify-infraio.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):

  1. Dedup on X-Delivery in 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-Delivery is stable across every retry of a delivery; the payload carries no event_id field.)
  2. 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

  1. Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
  2. A new secret is generated and shown exactly once. Copy it before closing the dialog.
  3. 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: 1729536000

A 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

SymptomLikely cause
Always returns false in devBody was JSON-parsed before HMAC. Read raw bytes first.
Worked yesterday, fails todayYou 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 newA 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 comparisonMake sure you compare unix-seconds against unix-seconds. Date.now() in JS is milliseconds — divide by 1000.
Works locally, fails on prodA proxy (Cloudflare, nginx) is decompressing, re-encoding, or stripping a trailing newline. Inspect the bytes your handler sees.
Test ping says 401 / signature mismatchYour verifier is signing body only (pre-2026 scheme). Update to sign timestamp + "." + body.
Header missing entirelyThe endpoint is registered for a different environment. Test-mode endpoints only receive environment=test events.