Developers

Verify signatures

Prove every request came from Zaher and wasn't modified.

Your endpoint URL is public, so anyone could send it a fake "order paid" request. Every Zaher request is signed with your endpoint's signing secret (whsec_…) — always verify it before trusting the body.

The headers

HeaderValue
X-Zaher-Signaturev1=<hex> — during a secret rotation, two values: v1=<new>, v1=<old>
X-Zaher-TimestampUnix seconds when the request was signed
X-Zaher-Event-IdThe event id (same as the body id)
X-Zaher-Event-TypeThe event type (same as the body type)

The algorithm

  1. Read the raw request body exactly as received. Do not parse and re-serialize the JSON first — any change in whitespace or key order breaks the signature.
  2. Build the signed content: {X-Zaher-Timestamp}.{raw body}.
  3. Compute HMAC-SHA256(secret, signed content) and hex-encode it.
  4. Compare it, in constant time, with each v1= value in X-Zaher-Signature. Accept if any matches.
  5. Reject the request if the timestamp is more than 5 minutes from now. This stops replay attacks — the timestamp is part of the signed content, so it can't be changed without breaking the signature.

Verification code

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyZaherSignature(
  rawBody: string,
  signatureHeader: string | undefined,
  timestampHeader: string | undefined,
  secret: string
): boolean {
  const timestamp = Number(timestampHeader);
  if (!signatureHeader || !Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

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

  return signatureHeader.split(",").some((part) => {
    const [version, hex] = part.trim().split("=");
    const received = Buffer.from(hex ?? "", "hex");
    return (
      version === "v1" &&
      received.length === expected.length &&
      timingSafeEqual(received, expected)
    );
  });
}

Getting the raw body

  • Next.js route handlers: await request.text()
  • Express: express.raw({ type: "application/json" }) on the webhook route
  • Flask: request.get_data() · Django: request.body
  • PHP: file_get_contents('php://input')

Rotating your secret

If a secret may have leaked, open the endpoint page and click Rotate secret. The new secret is active immediately and the old one keeps signing for 24 hours — during that window each request carries both signatures, so you can deploy the new secret without missing events.

On this page