LegitFlash

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

Copiado
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 2xx en 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)

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

Copiado
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

Copiado
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

Copiado
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

Copiado
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 el id del cuerpo, también estable entre endpoints). Una entrega puede llegar más de una vez.
  • El orden no está garantizado. Usa created_at (y data.status) para decidir qué actualización es más reciente; un session.approved puede llegar después de un session.decision_changed posterior.
  • Trata el webhook como una notificación de cambio: es seguro responder 2xx primero 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.