LegitFlash

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

Copied
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 2xx within 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)

Copied
{
  "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:

Copied
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

Copied
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

Copied
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

Copied
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-Delivery header (or by the body's id, which is also stable across endpoints). A delivery can arrive more than once.
  • Order is not guaranteed. Use created_at (and data.status) to decide which update is newer; a session.approved can arrive after a later session.decision_changed.
  • Treat the webhook as a notification of change: it is safe to answer 2xx first 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.