Docs · Reference
Webhooks
Server-to-server delivery of a session’s outcome — signed, and the record of truth for your integration. Register an endpoint from your dashboard or via POST /v1/webhook-endpoints.
Event types
| Field | Type | Description |
|---|---|---|
verification_session.completed | event | A session reached a terminal outcome — data.status is passed or failed. |
verification_session.expired | event | A session's 30-minute window elapsed before it reached a terminal outcome. |
A new endpoint subscribes to both by default; pass events at creation to narrow it.
Payload shape
{
"id": "evt_5f3a2b1c9d8e7f6a5b4c3d2e",
"type": "verification_session.completed",
"created": "2026-07-12T04:00:31.000Z",
"data": {
"id": "vs_01ARZ3NDEKTSV4RRFFQ69G5FAV",
"status": "passed",
"required_age": 18,
"method": "face_estimation",
"confidence": "high",
"mode": "test",
"metadata": {}
}
}| Field | Type | Description |
|---|---|---|
id | string | Deterministic per (session, event type, endpoint) — a retried or duplicated delivery of the same logical event always carries the same id. Dedupe on it if your handler isn’t naturally idempotent. |
type | string | One of the event types above. |
created | string | ISO 8601 timestamp of this delivery attempt. |
data.id | string | The verification session id (vs_...). |
data.status | string | The session's status at delivery time — passed, failed, or expired. |
data.required_age | 16 | 18 | As set at session creation. |
data.method | string | null | Which method produced the outcome. null for an expired session. |
data.confidence | string | null | high, medium, or low. null for an expired session. |
data.mode | "test" | "live" | Which key created the session. |
data.metadata | object | The metadata you passed at session creation, echoed back verbatim. |
The AgeAssure-Signature header
Every delivery carries:
AgeAssure-Signature: t=1752300031,v1=5b3f6c9e2a1d... (hex hmac-sha256)
AgeAssure-Event: verification_session.completed
Content-Type: application/jsont is the delivery’s unix timestamp in seconds. v1 is the hex-encoded HMAC-SHA256 of {t}.{raw request body} — the timestamp, a literal dot, then the exact raw JSON body, signed with the whsec_... secret you were shown once when you created the endpoint.
Recompute the signature yourself and compare with a constant-time equality check; reject anything where t is further than five minutes from “now” (replay protection). This is exactly the check AgeAssure’s own API runs on inbound provider webhooks — same construction, same tolerance.
import crypto from "node:crypto";
// header: "t=<unix seconds>,v1=<hex hmac-sha256>"
export function verifyAgeAssureSignature(rawBody, header, secret, toleranceSeconds = 300) {
const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header ?? "");
if (!match) return false;
const [, timestamp, signature] = match;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > toleranceSeconds) {
return false; // too old — possible replay
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}// In your route handler, verify against the RAW request body (before any
// JSON.parse/body-parser reformatting) — re-serialising the parsed object
// will not byte-for-byte match what was signed.
const ok = verifyAgeAssureSignature(rawBody, req.headers["ageassure-signature"], endpointSecret);import hashlib
import hmac
import re
import time
def verify_ageassure_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
match = re.match(r"^t=(\d+),v1=([0-9a-f]+)$", header or "")
if not match:
return False
timestamp, signature = match.group(1), match.group(2)
if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
return False # too old — possible replay
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)Retries and failure
A delivery counts as successful on any 2xx response within a 10-second timeout — anything else (non-2xx, timeout, DNS/network failure) is retried automatically, up to 5 attempts. After that it lands in a dead-letter queue and stops retrying; re-deliver manually from the dashboard’s Webhooks page.
Retries resend to every endpoint subscribed to that event, including ones that already succeeded on an earlier attempt for the same batch — the deterministic id above is what makes that safe to dedupe on. Return a 2xx quickly (queue the heavy work) rather than doing slow processing inline in the handler.