Appearance
Webhooks
Register your Webhook URL in the tenant dashboard's Settings screen. AgeRail sends a POST request to that registered Webhook URL after a verification reaches a terminal outcome. Session-create requests do not accept webhook_url; sending that removed field returns HTTP 422.
Verify the transport headers with the tenant webhook signing secret before parsing or acting on the JSON body.
Every delivery includes:
X-Webhook-Timestamp: Unix time in secondsX-Webhook-Signature-Version:v1X-Webhook-Signature: unpadded base64url HMAC-SHA256X-Webhook-Event:verification.completed
For v1, the canonical signing string is {timestamp}. followed immediately by the exact raw bytes received:
text
signed = timestamp.encode("ascii") + b"." + raw_bodyCompute base64url(HMAC-SHA256(tenant_secret, signed)) without = padding. Reject an unknown signature version, reject a stale timestamp outside a 300-second tolerance, and compare signatures in constant time. Read the raw bytes before JSON parsing; re-serializing JSON can change whitespace or key order and invalidate the signature.
The JSON body also echoes the composite redirect token in its signature field as {expires_ts}.{base64url_mac}. That token can expire before a webhook retry. The body signature is not the webhook-authenticity mechanism. Authenticate deliveries with the X-Webhook-Signature header.
Completion signal and optional estimate
By default, the verification response, webhook, and redirect contain only the signed result and its status, and no estimated age. If your tenant enables estimated age visibility, your backend can obtain the estimate through a separate authenticated operation only for sessions whose shoppers accepted the visibility disclosure. The estimate is stored only for those consented sessions and is deleted when its session record is deleted; session records currently have no fixed automatic deletion period.
When the document check is on for your tenant, the shopper's page can receive ROUTED_TO_FALLBACK before the session is decided. That page result is not final and no webhook is sent for it. The webhook arrives once, when the document check ends, with status VERIFIED or ROUTED_TO_FALLBACK; a webhook's ROUTED_TO_FALLBACK is final. A document check the shopper abandons ends the session as EXPIRED with no webhook, and the status lookup then reads EXPIRED. See the verification flow.
The webhook is the completion signal, not the estimate carrier. After verifying a delivery, take its session_id and call GET /api/v1/sessions/{session_id}/detected-age with your tenant X-API-Key. The operation returns not_consented, pending, not_estimated, or available; see the verification flow for the response contract and 404 behavior.
Payload
This test-mode fixture is used by the samples below. Live events have the same shape, with livemode set to true. In live mode billing_units is 1 for VERIFIED and 0 for SPOOF_DETECTED. For ROUTED_TO_FALLBACK it is 0 when the shopper's device ends the session before any check runs, and 1 otherwise. Counted checks are taken from the tenant's free checks for the current UTC month first, and a free check still reports billing_units as 1. Test-mode deliveries always use 0 because they are not billed. Possible status values are VERIFIED, ROUTED_TO_FALLBACK, and SPOOF_DETECTED.
json
{"billing_units":0,"completed_at":"2026-07-21T12:00:00+00:00","event":"verification.completed","is_adult":true,"livemode":false,"session_id":"018f2f43-7d8b-7a9c-b123-123456789abc","signature":"1753099500.Pq6hQ-MdeYbaE9dNGJexEAATftesSHN0k2OuGgRhcO8","status":"VERIFIED","target_age":18,"tenant_id":"018f2f43-7d8b-7a9c-b123-abcdef012345"}Non-normative readability view. Same payload, formatted for reading — verify signatures over the single-line raw body above, never over reformatted JSON.
json
{
"billing_units": 0,
"completed_at": "2026-07-21T12:00:00+00:00",
"event": "verification.completed",
"is_adult": true,
"livemode": false,
"session_id": "018f2f43-7d8b-7a9c-b123-123456789abc",
"signature": "1753099500.Pq6hQ-MdeYbaE9dNGJexEAATftesSHN0k2OuGgRhcO8",
"status": "VERIFIED",
"target_age": 18,
"tenant_id": "018f2f43-7d8b-7a9c-b123-abcdef012345"
}Shell fixture
This reproducible fixture verifies a received delivery using its raw body and transport headers. Shell equality is not a constant-time comparison, so use the Node.js or Python verifier below in production webhook handlers. Rotating tenants must use those verifiers with the time-valid secret candidates described below.
bash
SECRET='whsec_docs_fixture'
NOW_SECONDS='1753099200'
TOLERANCE_SECONDS=300
RAW_BODY='{"billing_units":0,"completed_at":"2026-07-21T12:00:00+00:00","event":"verification.completed","is_adult":true,"livemode":false,"session_id":"018f2f43-7d8b-7a9c-b123-123456789abc","signature":"1753099500.Pq6hQ-MdeYbaE9dNGJexEAATftesSHN0k2OuGgRhcO8","status":"VERIFIED","target_age":18,"tenant_id":"018f2f43-7d8b-7a9c-b123-abcdef012345"}'
X_WEBHOOK_TIMESTAMP='1753099200' # received X-Webhook-Timestamp
X_WEBHOOK_SIGNATURE_VERSION='v1' # received X-Webhook-Signature-Version
X_WEBHOOK_SIGNATURE='mMDicRY3qMjytsKvxjXdeuGg1zo38Ca7jegJ7S2FPMg' # received X-Webhook-Signature
X_WEBHOOK_EVENT='verification.completed' # received X-Webhook-Event
[ "$X_WEBHOOK_SIGNATURE_VERSION" = 'v1' ] || { echo 'unknown signature version' >&2; exit 1; }
[ "$X_WEBHOOK_EVENT" = 'verification.completed' ] || { echo 'unexpected webhook event' >&2; exit 1; }
case "$X_WEBHOOK_TIMESTAMP" in (*[!0-9]*|'') echo 'invalid timestamp' >&2; exit 1;; esac
AGE_SECONDS=$((NOW_SECONDS - X_WEBHOOK_TIMESTAMP))
if [ "$AGE_SECONDS" -lt 0 ]; then
AGE_SECONDS=$((-AGE_SECONDS))
fi
[ "$AGE_SECONDS" -le "$TOLERANCE_SECONDS" ] || { echo 'stale timestamp: exceeds 300 seconds' >&2; exit 1; }
EXPECTED_SIGNATURE=$(printf '%s.%s' "$X_WEBHOOK_TIMESTAMP" "$RAW_BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -binary \
| openssl base64 -A | tr '+/' '-_' | tr -d '=')
[ "$EXPECTED_SIGNATURE" = "$X_WEBHOOK_SIGNATURE" ] \
|| { echo 'invalid webhook signature' >&2; exit 1; }
case "$RAW_BODY" in
(*'"event":"verification.completed"'*) ;;
(*) echo 'unexpected webhook event' >&2; exit 1;;
esac
EVENT_KEY_COUNT=$(printf '%s' "$RAW_BODY" | grep -o '"event":' | wc -l | tr -d ' ')
[ "$EVENT_KEY_COUNT" = 1 ] || { echo 'unexpected webhook event' >&2; exit 1; }
echo 'verified webhook delivery'Keep the tenant webhook signing secret server-side. It appears above only because this is a local fixture.
Signing secret rotation
AgeRail keeps signing production deliveries with the current secret until you activate the new one. Before activating, install the new secret in your receiver alongside the old one, and give the old secret a provisional cutoff no later than 24 hours ahead. From activation onward, AgeRail signs every new delivery with the new secret. Keep the old secret only as a time-limited verification candidate until its configured deadline.
This safety cutoff is a fuse: if the post-activation update fails, the old secret expires early instead of remaining trusted indefinitely.
Immediately after activation, replace the provisional cutoff with the exact grace_ends_at returned by the API. It is a whole UTC second exactly 86,400 seconds after activation. Convert that canonical UTC instant to its Unix-second value for the helpers below without rounding. Exclude the old secret when now >= grace_ends_at; at that exact second, only the new secret is valid.
Read the clock once, use that value both to choose the time-valid candidates and to perform the 300-second freshness check, then pass [old, new] before the cutoff or [new] at and after it. The Node.js and Python helpers below implement that selection while preserving single-secret calls.
After the deadline, removing the old secret from configuration is cleanup only. The receiver already refuses it in code, so security does not depend on when the next deployment happens.
Emergency rotation has no grace period. Reconfigure receivers new-only immediately; until you do, the old secret remains usable anywhere it is still configured because AgeRail cannot revoke a credential held by a third party.
Node.js
Pass the request body to this function as a Buffer, before any JSON middleware parses it. Node exposes the X-Webhook-Signature-Version, X-Webhook-Timestamp, X-Webhook-Signature, and X-Webhook-Event names as lowercase keys.
js
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function timeValidWebhookSecrets(oldSecret, newSecret, graceEndsAt, nowSeconds) {
if (!Number.isSafeInteger(graceEndsAt) || graceEndsAt <= 0) return [newSecret]
return nowSeconds >= graceEndsAt ? [newSecret] : [oldSecret, newSecret]
}
export function verifyWebhook(headers, rawBody, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const version = headers['x-webhook-signature-version']
const timestamp = headers['x-webhook-timestamp']
const signature = headers['x-webhook-signature']
const event = headers['x-webhook-event']
if (version !== 'v1') throw new Error('unknown signature version')
if (event !== 'verification.completed') throw new Error('unexpected webhook event')
if (!timestamp || !/^\d+$/.test(timestamp)) throw new Error('invalid X-Webhook-Timestamp')
if (!signature || !/^[A-Za-z0-9_-]+$/.test(signature)) throw new Error('invalid X-Webhook-Signature')
const timestampSeconds = Number(timestamp)
if (!Number.isSafeInteger(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > TOLERANCE_SECONDS) {
throw new Error('stale timestamp: exceeds 300 seconds')
}
const signed = Buffer.concat([Buffer.from(`${timestamp}.`, 'ascii'), rawBody])
const providedBytes = Buffer.from(signature, 'ascii')
const secrets = typeof secret === 'string' ? [secret] : secret
let verified = false
for (const candidate of secrets) {
const expected = createHmac('sha256', candidate).update(signed).digest('base64url')
const expectedBytes = Buffer.from(expected, 'ascii')
if (expectedBytes.length !== providedBytes.length || !timingSafeEqual(expectedBytes, providedBytes)) {
continue
}
verified = true
}
if (!verified) throw new Error('invalid webhook signature')
const payload = JSON.parse(rawBody.toString('utf8'))
if (payload.event !== 'verification.completed') throw new Error('unexpected webhook event')
return payload
}Node's base64url digest encoding omits = padding. The length guard is required because timingSafeEqual throws when buffer lengths differ. For rotation, read nowSeconds once, pass it to both timeValidWebhookSecrets(oldSecret, newSecret, graceEndsAt, nowSeconds) and verifyWebhook, and keep the configured old slot tied to that deadline. After verifyWebhook returns, the caller can inspect payload.livemode without logging every delivery inside the verifier.
Python
Pass raw_body directly from the framework's raw request-body API. Do not pass a parsed object. Frameworks may normalize header names; if yours returns lowercase keys, convert them to the canonical names shown below before passing the plain dictionary.
python
import base64
import hashlib
import hmac
import json
import time
from collections.abc import Sequence
TOLERANCE_SECONDS = 300
def time_valid_webhook_secrets(
old_secret: str,
new_secret: str,
grace_ends_at: int,
now_seconds: int,
) -> list[str]:
if (
not isinstance(grace_ends_at, int)
or isinstance(grace_ends_at, bool)
or grace_ends_at <= 0
):
return [new_secret]
return [new_secret] if now_seconds >= grace_ends_at else [old_secret, new_secret]
def verify_webhook(
headers,
raw_body: bytes,
secret: str | Sequence[str],
now_seconds: int | None = None,
):
version = headers.get("X-Webhook-Signature-Version")
timestamp = headers.get("X-Webhook-Timestamp")
signature = headers.get("X-Webhook-Signature")
event = headers.get("X-Webhook-Event")
if version != "v1":
raise ValueError("unknown signature version")
if event != "verification.completed":
raise ValueError("unexpected webhook event")
if not timestamp or not timestamp.isascii() or not timestamp.isdecimal():
raise ValueError("invalid X-Webhook-Timestamp")
if not signature or not signature.isascii():
raise ValueError("invalid X-Webhook-Signature")
current = int(time.time()) if now_seconds is None else now_seconds
if abs(current - int(timestamp)) > TOLERANCE_SECONDS:
raise ValueError("stale timestamp: exceeds 300 seconds")
signed = timestamp.encode("ascii") + b"." + raw_body
secrets = (secret,) if isinstance(secret, str) else secret
verified = False
for candidate in secrets:
digest = hmac.new(candidate.encode("utf-8"), signed, hashlib.sha256).digest()
expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
verified = hmac.compare_digest(expected, signature) or verified
if not verified:
raise ValueError("invalid webhook signature")
payload = json.loads(raw_body)
if payload.get("event") != "verification.completed":
raise ValueError("unexpected webhook event")
return payloadFor rotation, read now_seconds once, pass it to both time_valid_webhook_secrets(old_secret, new_secret, grace_ends_at, now_seconds) and verify_webhook, and keep the configured old slot tied to that deadline. After verify_webhook returns, the caller can inspect payload["livemode"] without logging every delivery inside the verifier. Return a success response only after verification and application processing complete. Each attempt has a 10-second timeout, and AgeRail tries up to 3 times by default with exponential backoff. Duplicate deliveries can occur, so make processing idempotent by session_id.