Public beta

Webhook signatures

Every webhook delivery is signed with HMAC-SHA256 using the secret returned when you registered the endpoint. Always verify the signature before trusting the payload.

Headers

  • X-Zippex-Signature — hex HMAC-SHA256 of `${timestamp}.${rawBody}` using your endpoint secret as the key.
  • X-Zippex-Timestamp — ISO-8601 timestamp the dispatcher used when computing the signature.
  • X-Zippex-Event-Id — unique event ID for de-duplication.

Verification algorithm

  1. Read X-Zippex-Timestamp and the raw request body.
  2. Compute HMAC-SHA256 over `${timestamp}.${rawBody}` using the endpoint secret as the key.
  3. Hex-encode the digest and constant-time compare with X-Zippex-Signature.
  4. Reject deliveries whose timestamp is more than 5 minutes old to defeat replay attacks.
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verify(req, rawBody, secret) {
  const signature = req.headers['x-zippex-signature'];
  const timestamp = req.headers['x-zippex-timestamp'];
  if (!signature || !timestamp) throw new Error('Missing signature headers');

  const sentAt = new Date(timestamp).getTime();
  if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > TOLERANCE_SECONDS * 1000) {
    throw new Error('Stale signature');
  }

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error('Bad signature');
  }
}

Use the raw body

Verify the signature against the unparsed request body. Re-serializing a parsed JSON object can change byte ordering or whitespace and break verification.

Retries

Failed deliveries (timeout or 5xx) are retried at 1 second, 30 seconds, and 5 minutes. Each retry carries the same X-Zippex-Event-Id so you can dedupe.