Signing and verification
Every webhook delivery includes a signature header:
QairoPay-Signature: t=1716115200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdThe signature is an HMAC-SHA256 of ${timestamp}.${rawBody} keyed by your endpoint’s signing secret. Verifying it gives you two guarantees:
- Authenticity — the request actually came from QairoPay (only QairoPay and you know the secret).
- Freshness — the request is recent (the timestamp is in the signed payload, so replays past your tolerance window fail).
Verify every payload before doing anything with it. Don’t trust the URL, don’t trust the IP, don’t trust the body. Verify the signature.
TypeScript
import { QairoPay, WebhookSignatureError, type WebhookEvent } from "@qairopay/sdk";
export async function handleWebhook(req: Request) { const rawBody = await req.text(); const sigHeader = req.headers.get("QairoPay-Signature");
let event: WebhookEvent; try { // constructEvent is async — Web Crypto's HMAC primitives return Promises. event = await QairoPay.webhooks.constructEvent( rawBody, sigHeader, process.env.QAIROPAY_WEBHOOK_SECRET!, ); } catch (err) { if (err instanceof WebhookSignatureError) { // err.reason is 'invalid_signature' | 'timestamp_out_of_tolerance' | 'malformed_header' console.warn(`Webhook rejected: ${err.reason}`); return new Response("Invalid signature", { status: 400 }); } throw err; }
// event is fully typed by event.type. switch (event.type) { case "pass.installed": await onPassInstalled(event.data.pass); break; case "pass.revoked": await onPassRevoked(event.data.pass); break; // Unknown event types fall through here — forward-compat with new // event types added in future SDK releases. default: console.log("Unhandled event type:", event.type); }
return new Response("ok");}constructEvent does three things:
- Parses the
t=…,v1=…header (multiplev1=entries are supported during a secret rotation window). - Recomputes the HMAC-SHA256 over
${timestamp}.${rawBody}via Web Crypto and constant-time compares. - Checks the timestamp is within the tolerance window (default 300 seconds; pass
{ toleranceSeconds: 0 }to disable).
If any check fails it throws WebhookSignatureError with a reason discriminator. Always distinguish that from your own application errors with instanceof WebhookSignatureError — see the example above.
Rotating signing secrets
Pass an array of secrets to accept signatures from either the old or new secret during a rotation window:
const event = await QairoPay.webhooks.constructEvent( rawBody, sigHeader, [process.env.OLD_WEBHOOK_SECRET!, process.env.NEW_WEBHOOK_SECRET!],);The SDK accepts the payload if the signature matches either secret. Once you have confirmed all in-flight deliveries are signed with the new secret, drop the old one from the array.
Manual verification
If you’re not using the SDK, the algorithm in pseudo-code:
1. Parse header into timestamp `t` and v1 hex `sig`.2. Compute expected = HMAC-SHA256(secret, `${t}.${rawBody}`).3. Constant-time compare `sig` and `expected`. If unequal → reject.4. Compare current Unix time to `t`. If `|now - t| > tolerance_seconds` → reject.A reference implementation in Go, Python, and Ruby is on the SDKs page.
Tolerance window
Default tolerance is 300 seconds (5 minutes). The window protects against replay attacks: an attacker who captures a signed payload cannot replay it indefinitely.
If your endpoint is in a network with high clock skew (rare), tune the window via:
QairoPay.webhooks.constructEvent(body, sig, secret, { toleranceSeconds: 600 });Don’t go higher than 600 seconds. If you find yourself wanting to, fix the clock skew instead.
Secret rotation
Endpoints can have two active signing secrets at once during rotation. To rotate:
-
Generate a new secret in the dashboard (or
POST /v1/webhook_endpoints/{id}/rotate_secret). -
The endpoint now signs every outgoing event with both the old and the new secret, separated by commas in the header:
QairoPay-Signature: t=...,v1=<sig-with-old>,v1=<sig-with-new> -
Update your verifier to accept either secret. The SDK accepts an array of secrets —
{ secrets: [oldSecret, newSecret] }. -
Once your fleet has the new secret, revoke the old secret in the dashboard. From then on, only the new secret signs outgoing events.
This gives you zero-downtime secret rotation.
What gets signed
The signature covers ${timestamp}.${rawBody}. The rawBody is the exact bytes we sent, including whitespace and the order of object keys. If your framework parses the body before exposing it, you must capture the raw bytes for verification — see your framework’s docs (Express: express.raw(), Fastify: addContentTypeParser('*', { parseAs: 'buffer' }, ...), Workers: await request.text() works directly).