Webhooks
LegitFlash webhooks
LegitFlash sends a signed HTTPS POST to your endpoint when a verification changes. Manage endpoints in the dashboard
under Webhooks (owners and admins). Webhooks never carry photos, videos or media URLs; use the dashboard (until the
purge date) to look at evidence.
Request
POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: TrustID-Webhooks/1
TrustId-Event: session.approved
TrustId-Delivery: 01JABCDEF... (ULID, identical on every retry of the same delivery)
TrustId-Signature: t=1793707200,v1=<hex>[,v1=<hex>]
Body: { "id": "evt_<ulid>", "type": "...", "created_at": "<ISO 8601 UTC>", "livemode": true, "data": { ... } }
livemode is false for sessions created with a test API token (they send the same events, driven by the simulator). data.metadata
echoes the key/value pairs you sent when creating the session (or null).
- Answer with any
2xxwithin 10 seconds. Anything else (or a timeout, or a redirect) is a failure. Redirects are not followed. - Only HTTPS on port 443 or 8443 and public hosts are accepted. Private, loopback and link-local addresses are refused.
Events
session.created, session.started, session.submitted (also after a resubmission, data.round > 1), session.approved,
session.rejected, session.resubmission_requested, session.decision_changed, session.expired, session.cancelled,
session.media_purged, session.erased (an erasure on request: data holds only session_id and erased_at, and the session no longer exists). A Send test event button sends webhook.test with a sample body.
Payload example (session.approved, full result)
{
"id": "evt_01JZ0000000000000000000000",
"type": "session.approved",
"created_at": "2026-11-03T12:00:00+00:00",
"livemode": true,
"data": {
"session_id": "01JZ0000000000000000000001",
"external_reference": "ORD-1042",
"flow_id": "01JZ0000000000000000000002",
"status": "approved",
"decision": "approved",
"reason_codes": [],
"round": 1,
"resubmission_items": null,
"age_over_18": true,
"age_over_21": true,
"age_over_min": null,
"submitted_at": "2026-11-03T11:50:00+00:00",
"decided_at": "2026-11-03T12:00:00+00:00",
"media_purge_at": "2026-12-03T12:00:00+00:00",
"metadata": { "order": "1042" },
"document": { "type": "id_card", "country": "ES", "expiry": "2031-05-01", "number_last4": "123A" },
"subject": { "given_names": "ANA", "surname": "GARCIA LOPEZ", "date_of_birth": "1990-04-02", "nationality": "ESP" }
}
}
Flows set to minimal result omit document and subject entirely: you get the outcome, reason codes and the
age_over_* booleans only. age_over_min follows the flow's minimum age (null when none is set). The document number is
never sent, only its last four characters. Reason codes are stable machine values.
Verifying the signature
v1 = hex( HMAC-SHA256( secret, "<t>.<raw request body>" ) ), using the raw bytes of the body, not a re-encoded JSON.
Reject requests whose t is more than 5 minutes from your clock. During the 24 hours after you rotate a secret the header
holds two v1= entries (new secret first); accept the request if either matches.
Test vector:
secret: whsec_test_secret
t: 1793707200
body: {"id":"evt_01JTESTVECTOR","type":"session.approved","created_at":"2026-11-03T12:00:00+00:00","data":{"session_id":"01JSESSION"}}
v1: 1d275c4434faa83ea8f6bd7c1c2b94d3aa50c531a403a41e3ebe9526d30634b0
PHP
function verify(string $header, string $rawBody, string $secret, int $tolerance = 300): bool
{
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') { $t = (int) $v; } elseif ($k === 'v1') { $sigs[] = $v; }
}
if ($t === null || $sigs === [] || abs(time() - $t) > $tolerance) { return false; }
$expected = hash_hmac('sha256', $t.'.'.$rawBody, $secret);
foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) { return true; } }
return false;
}
// $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_TRUSTID_SIGNATURE'];
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(header, rawBody, secret, tolerance = 300) {
const parts = header.split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || !sigs.length || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
// Express: use express.raw({ type: 'application/json' }) so rawBody is the exact bytes.
Python
import hashlib, hmac, time
def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
t, sigs = None, []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t" and v.isdigit():
t = int(v)
elif k == "v1":
sigs.append(v)
if t is None or not sigs or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
Retries
A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (eight attempts in total over about
45 hours), then marked failed. You can redeliver any delivery by hand from the delivery log; the body is the one frozen at
the time of the event, the signature timestamp is fresh. An endpoint that fails continuously for 3 days is disabled and the
team's owners and admins get an email. The log shows event, status, attempts, HTTP code and latency, never response bodies.
Idempotency and ordering
- Deduplicate by the
TrustId-Deliveryheader (or by the body'sid, which is also stable across endpoints). A delivery can arrive more than once. - Order is not guaranteed. Use
created_at(anddata.status) to decide which update is newer; asession.approvedcan arrive after a latersession.decision_changed. - Treat the webhook as a notification of change: it is safe to answer
2xxfirst and process asynchronously.
Secrets
Secrets look like whsec_ plus 64 hex characters and are shown once. Rotate secret creates a new one and keeps the old
one signing for 24 hours so you can deploy without dropping events.