Skip to content
Ask the docs

Find answers across the QairoPay docs.

Type a question and we'll synthesize an answer from the docs with citations back to the source pages.

Signing and verification

Every webhook delivery includes a signature header:

QairoPay-Signature: t=1716115200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The signature is an HMAC-SHA256 of ${timestamp}.${rawBody} keyed by your endpoint’s signing secret. Verifying it gives you two guarantees:

  1. Authenticity — the request actually came from QairoPay (only QairoPay and you know the secret).
  2. 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:

  1. Parses the t=…,v1=… header (multiple v1= entries are supported during a secret rotation window).
  2. Recomputes the HMAC-SHA256 over ${timestamp}.${rawBody} via Web Crypto and constant-time compares.
  3. 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:

  1. Generate a new secret in the dashboard (or POST /v1/webhook_endpoints/{id}/rotate_secret).

  2. 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>
  3. Update your verifier to accept either secret. The SDK accepts an array of secrets — { secrets: [oldSecret, newSecret] }.

  4. 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).