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
| Header | Value |
|---|---|
X-Zaher-Signature | v1=<hex> — during a secret rotation, two values: v1=<new>, v1=<old> |
X-Zaher-Timestamp | Unix seconds when the request was signed |
X-Zaher-Event-Id | The event id (same as the body id) |
X-Zaher-Event-Type | The event type (same as the body type) |
The algorithm
- 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.
- Build the signed content:
{X-Zaher-Timestamp}.{raw body}. - Compute
HMAC-SHA256(secret, signed content)and hex-encode it. - Compare it, in constant time, with each
v1=value inX-Zaher-Signature. Accept if any matches. - 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.