Signing and verification
Verify the exact raw body before parsing or dispatching any webhook. Keep signing secrets in server-side secret storage and rotate them without exposing old or new secret material to clients.
Verification inputs
A versioned outbound Event Hook delivery currently carries a webhook/subscription identifier, Unix timestamp, event name, and v1= HMAC signature. The current worker signs the exact UTF-8 body with ${timestamp}.${webhookId}.${rawBody}. Do not normalize JSON, change whitespace, decode/re-encode, or parse before verification. Other surfaces may define different headers or algorithms.
POST /receiver HTTP/1.1
Content-Type: application/json
X-Qanivo-Webhook-Timestamp: <YOUR_UNIX_TIMESTAMP>
X-Qanivo-Webhook-Event: event_hook.delivery
X-Qanivo-Webhook-Signature: v1=<YOUR_SIGNATURE>
X-Qanivo-Webhook-Id: <YOUR_SUBSCRIPTION_ID>
{"eventType":"message.queued","data":{"reference":"<YOUR_RESOURCE_REFERENCE>"}}
The following receiver is illustrative pseudocode; implement the durable replay/idempotency store in your server framework.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyRawWebhook(rawBody: Buffer, headers: Record<string, string | undefined>) {
const signingSecret = process.env.QANIVO_WEBHOOK_SECRET;
const timestamp = headers['x-qanivo-webhook-timestamp'];
const webhookId = headers['x-qanivo-webhook-id'];
const provided = headers['x-qanivo-webhook-signature'];
if (!signingSecret || !timestamp || !webhookId || !provided) throw new Error('verification context missing');
if (!isFreshTimestamp(timestamp)) throw new Error('stale delivery');
const expected = createHmac('sha256', signingSecret)
.update(`${timestamp}.${webhookId}.`)
.update(rawBody)
.digest('hex');
const actual = Buffer.from(provided.replace(/^v1=/, ''), 'hex');
const expectedBytes = Buffer.from(expected, 'hex');
if (actual.length !== expectedBytes.length || !timingSafeEqual(actual, expectedBytes)) {
throw new Error('signature rejected');
}
rememberDelivery(webhookId, timestamp, rawBody);
return JSON.parse(rawBody.toString('utf8'));
}
Fail closed on missing keys, stale timestamps, repeated delivery/body identities, malformed signatures, unsupported versions, oversized bodies, invalid schema, or mismatched scope. These checks apply when the subscription is configured for signing; an explicitly unsigned subscription has a different trust policy and should be allowed only where the owning policy permits it. The helper names are illustrative pseudocode for the receiver’s durable replay/idempotency store. Provider Webhooks use their provider-specific authenticator; do not reuse this outbound Qanivo example for a provider ingress.