Authorization webhooks
Exact-200 acknowledgements, signed raw bodies and at-least-once delivery.
Delivery contract
Successful authorization atomically saves the relationship and queues
authorization.completed. Delivery is asynchronous and at least once.
A webhook failure does not undo authorization or delay the browser's OIDC redirect.
Return exactly HTTP 200 OK only after safely persisting the event or recognizing a previously persisted duplicate. Every other response is retried: 201, 202, 204, all redirects, 4xx (including 400/401/404/409/429), and 5xx. Network failures and timeouts also retry. Do not use 409 to acknowledge a duplicate.
Each attempt has a 10-second timeout. There are at most eight attempts, with delays after attempts 1–7 of 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, and 24 hours. These are scheduled backoffs, not an exact delivery-time guarantee. Attempt eight failing marks the event terminally failed.
Headers and payload
| Header | Meaning |
|---|---|
X-Versine-Event-Id | Stable event ID; also inside the signed body |
X-Versine-Timestamp | Unix seconds for this attempt |
X-Versine-Signature | v1=<hex HMAC-SHA256> |
Content-Type | application/json |
The TypeScript contract in packages/contracts is:
type AuthorizationWebhook = {
id: string;
type: "authorization.completed";
createdAt: string;
project: { id: string; name: string };
user: { id: string; firstName: string; lastName: string; email: string };
authorization: {
id: string;
legalVersion: number;
terms: { url: string; acceptedAt: string } | null;
privacy: { url: string; acceptedAt: string } | null;
};
};Only configured/accepted legal documents have acceptance objects. Raw codes, credentials, tokens, device IDs and onboarding values are not included. Treat the identity and legal record as personal data; restrict its retention and access.
Executable verification example
Store VERSINE_WEBHOOK_SECRET server-side. Verify the timestamp plus . plus
the exact raw body bytes, before parsing or reserializing. The example below
is also apps/docs/examples/verify-webhook.ts.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(input: {
rawBody: Buffer;
timestamp: string;
signature: string;
eventId: string;
secret: string;
nowSeconds?: number;
}): Record<string, unknown> & { id: string; type: "authorization.completed" } {
const { rawBody, timestamp, signature, eventId, secret } = input;
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
const seconds = Number(timestamp);
if (
!secret ||
!/^\d+$/.test(timestamp) ||
!Number.isSafeInteger(seconds) ||
Math.abs(now - seconds) > 300
)
throw new Error("Invalid webhook");
const match = /^v1=([a-f0-9]{64})$/.exec(signature);
if (!match) throw new Error("Invalid webhook");
const expected = createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest();
if (!timingSafeEqual(expected, Buffer.from(match[1]!, "hex")))
throw new Error("Invalid webhook");
const body: unknown = JSON.parse(rawBody.toString("utf8"));
if (
!body ||
typeof body !== "object" ||
!("id" in body) ||
body.id !== eventId ||
!eventId ||
!("type" in body) ||
body.type !== "authorization.completed"
)
throw new Error("Invalid webhook");
return body as Record<string, unknown> & {
id: string;
type: "authorization.completed";
};
}Use your server framework's raw-body parser for this route and pass each exact header. Reject missing/duplicate headers and oversized bodies before this function. Its return type validates the event envelope, not every nested field: apply your application's payload schema before processing.
Persist the validated event and an outbox/work item in one database transaction with a unique constraint on event ID. Only the worker performs downstream side effects, with its own idempotency keys. Return 200 after that transaction commits. An in-memory Set is not durable deduplication and cannot handle process restarts.
The five-minute timestamp tolerance limits replay age. It does not replace event-ID deduplication: retries have the same body/ID but fresh timestamps and signatures.
Secrets and recovery
Reveal the webhook secret on demand in Console's integration screen. Keep it separate from the OIDC client secret. Secrets are encrypted with project-bound context, never in initial HTML.
There is no self-service rotation/replay UI yet. Coordinate rotation with an operator: queued deliveries use the currently stored secret. A consumer may need a short, explicitly bounded overlap accepting old/new keys while in-flight attempts finish. Do not store keys in browser storage.
Inspect delivery-attempt history for response/error codes and terminal failures. Fix the endpoint and coordinate recovery; do not create duplicate business actions by assigning a new event ID or manually altering delivery rows.