<!-- Source: https://docs.infraio.xyz/en/webhooks/signature-verification -->
<!-- Last updated: 2026-10-04 -->

# Signature verification

Every webhook delivery includes two headers used together:

```http
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 can leak through proxy logs, screenshots, browser
history, and 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 proves the
request came from InfraIO Pay.

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).

> **Warning:**
>
> 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**

```ts filename="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);
}
```

**Go**

```go
package infraio

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

// VerifyWebhook returns true iff sig matches
// HMAC-SHA256(secret, timestamp + "." + body) AND the timestamp
// is within the tolerance window.
func VerifyWebhook(body []byte, sig, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    skew := time.Now().Unix() - ts
    if skew < 0 {
        skew = -skew
    }
    if skew > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) == 1
}
```

**Python**

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    """body: raw bytes. signature: 'sha256=<hex>'. timestamp: unix seconds."""
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

**Ruby**

```ruby
require 'openssl'

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body, signature, timestamp, secret)
  ts = Integer(timestamp) rescue (return false)
  return false if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS

  signed   = "#{timestamp}.#{body}"
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
  Rack::Utils.secure_compare(signature.to_s, expected)
end
```

## 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.

For extra safety (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:

```http
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

```ts
// 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:

```json
{
  "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 aren't retried. 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. Sign `timestamp + "." + body` instead. |
| Header missing entirely | The endpoint is registered for a different environment. Test-mode endpoints only receive `environment=test` events. |
