Skip to content

Verifying webhook signatures

Every webhook carries an X-Spay-Signature header:

X-Spay-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>

To verify a webhook:

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Split the header on , and read t and v1. Reject the webhook if the header is missing, t isn’t a whole number, or v1 isn’t 64 lowercase hex characters.
  3. Reject it if t is more than 5 minutes from your clock. Every delivery and retry is signed when it’s sent, so a genuine webhook is always inside that window, and a captured one can’t be replayed later.
  4. Compute the HMAC-SHA256 of t, ., and the raw body, keyed with the UTF-8 bytes of your webhook signing secret.
  5. Compare it with v1 in constant time.
  6. Answer a rejected webhook with a non-2xx status. Deduplicate accepted ones by eventId: a retry resends the same eventId and body with a new t.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifySpaySignature(header, rawBody, secret) {
if (typeof header !== 'string') return false;
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
if (!/^[0-9]{1,18}$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest();
return timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}

To check your code, sign a body yourself with openssl and compare:

Terminal window
{ printf '%s.' "$T"; cat body.json; } | openssl dgst -sha256 -hmac "$SPAY_WEBHOOK_SECRET" | sed 's/^.*= //'