Webhooks
Webhooks de LegitFlash
LegitFlash envía un POST HTTPS firmado a tu endpoint cuando cambia una verificación. Gestiona los endpoints en el panel,
en Webhooks (propietarios y administradores). Los webhooks nunca incluyen fotos, vídeos ni URL de medios; usa el panel
(hasta la fecha de purga) para ver las evidencias.
Petición
POST /tu/ruta HTTP/1.1
Content-Type: application/json
User-Agent: TrustID-Webhooks/1
TrustId-Event: session.approved
TrustId-Delivery: 01JABCDEF... (ULID, idéntico en cada reintento de la misma entrega)
TrustId-Signature: t=1793707200,v1=<hex>[,v1=<hex>]
Cuerpo: { "id": "evt_<ulid>", "type": "...", "created_at": "<ISO 8601 UTC>", "livemode": true, "data": { ... } }
livemode es false en las sesiones creadas con un token de API de prueba (envían los mismos eventos, dirigidos por el
simulador). data.metadata devuelve los pares clave/valor que enviaste al crear la sesión (o null).
- Responde con cualquier
2xxen menos de 10 segundos. Cualquier otra cosa (o un timeout, o una redirección) es un fallo. Las redirecciones no se siguen. - Solo se aceptan hosts públicos con HTTPS en el puerto 443 u 8443. Se rechazan direcciones privadas, de loopback y link-local.
Eventos
session.created, session.started, session.submitted (también tras un reenvío, data.round > 1), session.approved,
session.rejected, session.resubmission_requested, session.decision_changed, session.expired, session.cancelled,
session.media_purged, session.erased (borrado a petición: data solo contiene session_id y erased_at, y la sesión ya no existe). El botón Enviar evento de prueba envía webhook.test con un cuerpo de ejemplo.
Ejemplo de payload (session.approved, resultado completo)
{
"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" }
}
}
Los flujos con resultado mínimo omiten document y subject: solo recibes el resultado, los códigos de motivo y los
booleanos age_over_*. age_over_min sigue la edad mínima del flujo (null si no hay). El número de documento nunca se
envía, solo sus cuatro últimos caracteres. Los códigos de motivo son valores estables para máquinas.
Verificar la firma
v1 = hex( HMAC-SHA256( secreto, "<t>.<cuerpo crudo de la petición>" ) ), usando los bytes crudos del cuerpo, no un
JSON recodificado. Rechaza las peticiones cuyo t se aleje más de 5 minutos de tu reloj. Durante las 24 horas posteriores
a rotar un secreto, la cabecera lleva dos entradas v1= (primero el secreto nuevo); acepta la petición si cualquiera coincide.
Vector de prueba:
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: usa express.raw({ type: 'application/json' }) para que rawBody sean los bytes exactos.
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)
Reintentos
Una entrega fallida se reintenta tras 1 min, 5 min, 30 min, 2 h, 6 h, 12 h y 24 h (ocho intentos en total durante unas
45 horas) y después se marca como failed. Puedes reenviar cualquier entrega a mano desde el registro de entregas; el cuerpo
es el congelado en el momento del evento y la marca de tiempo de la firma es nueva. Un endpoint que falla de forma continua
durante 3 días se desactiva y los propietarios y administradores del equipo reciben un email. El registro muestra evento,
estado, intentos, código HTTP y latencia, nunca cuerpos de respuesta.
Idempotencia y orden
- Deduplica por la cabecera
TrustId-Delivery(o por eliddel cuerpo, también estable entre endpoints). Una entrega puede llegar más de una vez. - El orden no está garantizado. Usa
created_at(ydata.status) para decidir qué actualización es más reciente; unsession.approvedpuede llegar después de unsession.decision_changedposterior. - Trata el webhook como una notificación de cambio: es seguro responder
2xxprimero y procesar de forma asíncrona.
Secretos
Los secretos tienen la forma whsec_ más 64 caracteres hexadecimales y se muestran una sola vez. Rotar secreto crea uno
nuevo y mantiene el anterior firmando durante 24 horas para que puedas desplegar sin perder eventos.