Docs · Concepts
Webhooks
A signed POST to your endpoint the moment a verification reaches a terminal state, so you never have to poll.
The payload
If a session was created with webhook_url, Valitel posts this to
it once the session completes or fails:
{
"type": "verification.completed",
"verification": {
"id": "vrf_thijcEEk2nArzmg0Plu6Dw",
"status": "completed",
"template": "age_over_18",
"claims": { "age_over_18": true },
"credentials": [{ "...": "same shape as GET /v1/verifications/:id" }],
"created_at": "2026-08-14T16:21:06.825Z",
"expires_at": "2026-08-14T16:26:06.825Z",
"responded_at": "2026-08-14T16:21:10.335Z"
},
"sent_at": "2026-08-14T16:21:10.363Z"
}type is verification.completed or
verification.failed, and verification is exactly what
GET /v1/verifications/:id would have returned.
webhook_url must resolve to a public address. Private, loopback and
link-local destinations are refused — with 422 invalid_webhook_url when
that is detectable at creation time, and otherwise at delivery time. Redirects are
not followed.
The signature
Every delivery carries an HMAC over the exact bytes of the body, timestamped against replay:
X-Valitel-Signature: t=1786724470,v1=e79142f7b148b852219403c1ef27a39347c5307009d263e3218a9ffccc474a25The hex is HMAC-SHA256(webhook_secret, "<t>.<raw body>"),
where t is the unix-seconds timestamp from the same header. Your secret
(whsec_…) is on the dashboard's Settings page.
Verifying a delivery
This is the reference implementation, mirroring the one the delivery worker
signs with. It compares in constant time, checks the timestamp against a tolerance,
and — the part that matters during a rotation — accepts the body if
any v1= entry matches.
import { createHmac, timingSafeEqual } from "node:crypto";
const DEFAULT_TOLERANCE_SECONDS = 300;
function parseHeader(header) {
const parsed = { t: undefined, v1: [] };
for (const segment of header.split(",")) {
const eq = segment.indexOf("=");
if (eq === -1) continue;
const name = segment.slice(0, eq).trim();
const value = segment.slice(eq + 1).trim();
if (name === "t") parsed.t = value;
else if (name === "v1") parsed.v1.push(value);
}
return parsed;
}
export function verifyWebhookSignature(secret, header, rawBody, opts = {}) {
if (header === undefined || header.length === 0) {
return { valid: false, reason: "missing x-valitel-signature header" };
}
const { t, v1 } = parseHeader(header);
if (t === undefined || v1.length === 0) {
return { valid: false, reason: "malformed x-valitel-signature header" };
}
const timestamp = Number.parseInt(t, 10);
if (!Number.isFinite(timestamp)) return { valid: false, reason: "malformed timestamp" };
const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
if (Math.abs(now - timestamp) > tolerance) {
return { valid: false, reason: "timestamp outside tolerance" }; // replay defense
}
const expectedHex = createHmac("sha256", secret)
.update(t + "." + rawBody, "utf8")
.digest("hex");
const expected = Buffer.from(expectedHex, "hex");
// Every candidate is compared even once one has matched, so the time this
// takes says nothing about which of the two secrets was the right one.
let matched = false;
for (const candidate of v1) {
if (!/^[0-9a-f]+$/i.test(candidate) || candidate.length !== expectedHex.length) continue;
const provided = Buffer.from(candidate, "hex");
if (provided.length === expected.length && timingSafeEqual(expected, provided)) matched = true;
}
return matched ? { valid: true } : { valid: false, reason: "signature mismatch" };
}The tolerance rejects a stale or replayed delivery even when the signature on it is genuine.
Rotating the secret
Rotate from the dashboard's Settings page. The new value is
displayed once and never again. For 24 hours after a rotation the old secret keeps
signing alongside the new one, and every delivery in that window carries
two signatures — repeated v1= entries in one header,
new first:
X-Valitel-Signature: t=1755264070,v1=<hmac new secret>,v1=<hmac old secret>Deploy the new secret at your leisure inside the window; nothing is dropped while
both are live. Once the window closes, only the new secret signs. Outside a rotation
the header carries exactly one v1=, byte for byte what it always did —
so a verifier written as a loop needs no special case.
Delivery and retries
Deliveries are queued and retried — six attempts by default, with backoff —
and every attempt is written to a per-tenant delivery log you can read on the
dashboard. Answer 2xx quickly and do the slow part afterwards; a
webhook handler that blocks is a webhook handler that gets retried.