Concepts
Webhooks
At-least-once event delivery with HMAC-SHA256 signatures, exponential backoff, and replay.
Authio emits webhooks for every meaningful event in your project: org created, member invited/removed, session refreshed, SSO connection saved, SCIM user provisioned, and dozens more. Receivers get the payload plus a signature header so they can verify authenticity.
The signature
Every delivery includes these headers:
Authio-Signature: t=1715632800,v1=<hex>
Authio-Webhook-Id: whd_2eh3...
Authio-Webhook-Endpoint: whk_2eh2...
Authio-Event: organization.createdThe signature is computed as:
v1 = hex(hmac_sha256(secret, timestamp + "." + body))Reject requests where the timestamp is more than 5 minutes old (clock-skew protection) and where the HMAC doesn’t match.
What is the secret?
It depends on when the endpoint was created.
Endpoints created after May 13 2026 (envelope-encrypted)
The dashboard shows you the plaintext whsec_… once on creation. Authio stores it envelope-encrypted with ChaCha20-Poly1305 (24-byte XChaCha20-Poly1305 nonce, 16-byte Poly1305 tag) using a per-project derived key that lives only in the process memory of the management API and the webhooks worker. We use the plaintext to compute the HMAC, and you verify against the same plaintext.
Older endpoints (hash-signed legacy)
Before envelope encryption shipped, we only storedsha256(secret). Those endpoints continue to work: the worker signs with the hash itself, and receivers verify against the same hash they were shown at creation time. Migrate by rotating the endpoint (revoke + recreate) to get a proper plaintext secret.
webhook_endpoints.secret_envelope column — populated rows are envelope-encrypted, NULL rows are legacy hash-signed.Verifying in Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySignature(
rawBody: string,
header: string,
secret: string,
) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
);
const t = parts.t!;
const got = parts.v1!;
const want = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(got, "hex"), Buffer.from(want, "hex"))
&& Math.abs(Date.now() / 1000 - Number(t)) < 300;
}Retries
We retry non-2xx responses with exponential backoff: 1s, 5s, 30s, 2m, 10m, 1h, 6h, 24h (8 attempts). After the final failure the delivery is marked dead and a siblingwebhook.delivery.failed event fires so you can alert on it. Replay any delivery from the dashboard.
Retention
Delivery rows are pruned hourly:
- Succeeded deliveries: 30 days (configurable via
AUTHIO_WEBHOOK_RETENTION_SUCCEEDED_DAYS) - Failed/dead deliveries: 90 days (configurable via
AUTHIO_WEBHOOK_RETENTION_FAILED_DAYS)
Endpoints themselves are kept forever (until you revoke).