Webhooks

Belfort te avisa cuando hay veredicto en vez de que tú preguntes. Las entregas van firmadas con Standard Webhooks, que es un formato con librerías ya escritas — no escribas el verificador a mano.

Cómo funcionan

Registras una URL y los eventos que te interesan. Cuando ocurre uno, hacemos un POST con el evento firmado. Si tu servidor responde 2xx, entregado; si no, reintentamos con espera creciente.

Tu endpoint debe responder rápido y antes de procesar: encola el evento y responde 200. Un endpoint que tarda es un endpoint al que dejaremos de entregar.

El cuerpo del evento

{
  "apiVersion": "2026-07-30",
  "id": "evt_01j9x8k2m3n4p5q6r7s8t9",
  "type": "verification.approved",
  "data": { … }
}

El id es estable entre reintentos: es con lo que deduplicas. Sin él, un reintento nuestro sería indistinguible de un evento nuevo y lo procesarías dos veces.

Verificar la firma

Cada entrega trae tres cabeceras:

webhook-idEl id del evento. Estable entre reintentos.
webhook-timestampUnix en segundos. Va dentro de lo firmado, así que nadie puede reproducir una entrega vieja cambiándole la fecha.
webhook-signaturev1,<base64> — una firma, o varias separadas por espacio durante una rotación.

Se firma {id}.{timestamp}.{cuerpo} con HMAC-SHA256. La clave son los bytes que resultan de decodificar en base64url la parte del secreto que va después de whsec_ — no la cadena literal.

Con una librería (recomendado)

# Python
from standardwebhooks import Webhook
Webhook(secreto).verify(cuerpo_crudo, dict(request.headers))
// Node
import { Webhook } from "standardwebhooks";
new Webhook(secreto).verify(cuerpoCrudo, headers);

A mano, si no queda otra

import base64, hashlib, hmac, time

def verificar(secreto, headers, cuerpo: bytes, tolerancia=300):
    id_ = headers["webhook-id"]
    ts  = int(headers["webhook-timestamp"])
    if abs(int(time.time()) - ts) > tolerancia:
        return False                      # anti-replay: la ventana NO es opcional
    crudo = secreto.removeprefix("whsec_")
    clave = base64.urlsafe_b64decode(crudo + "=" * (-len(crudo) % 4))
    firma = base64.b64encode(
        hmac.new(clave, b"%s.%d.%s" % (id_.encode(), ts, cuerpo), hashlib.sha256).digest()
    ).decode()
    return any(
        hmac.compare_digest(f"v1,{firma}", c)     # comparación en tiempo constante
        for c in headers["webhook-signature"].split(" ")
    )

Los tres errores que se cometen escribiéndolo a mano son siempre los mismos: comparar con == en vez de en tiempo constante, olvidar la ventana del timestamp, y firmar sobre el cuerpo parseado en vez del crudo. Ese último es el más traicionero: cualquier middleware que reserialice el JSON cambia un espacio y la firma deja de coincidir, sin que nada parezca roto.

Rotar el secreto

Rotar no es una interrupción. Durante 24 horas cada entrega va firmada dos veces: con el secreto nuevo y con el anterior, separadas por un espacio en la misma cabecera. Tu receptor valida con el que tenga desplegado y no se pierde ningún evento mientras completas el cambio.

Por eso webhook-signature puede traer varias firmas y tu verificador tiene que aceptar cualquiera de ellas. Un verificador que solo mire la primera funciona hasta el día que rotes.

Catálogo de eventos

verification.approvedLa verificación pasó.
verification.declinedLa verificación no pasó.
verification.review_requiredEl motor no decide solo: el caso entró en la cola de revisión.
verification.more_info_requestedHace falta que la persona aporte algo más.
verification.expiredLa sesión caducó sin completarse.
case.escalatedUn caso de revisión subió de nivel.
belfort.testEl evento de prueba. Se dispara al registrar el endpoint.

No todos los eventos traen veredicto. verification.expired, case.escalated y belfort.test no llevan informe, y un handler que dé por hecho que sí falla en el primero de todos: belfort.test llega en cuanto registras el endpoint, así que un handler así rompe el onboarding en el paso uno.

El catálogo vivo está en GET /v1/developer/webhooks/event-types. Esta tabla puede quedarse corta; ese endpoint no.

Entrega y reintentos

  • Reintentamos con espera creciente ante fallos transitorios. Un 4xx tuyo se considera permanente y no se reintenta: si respondes 400 a un evento válido, lo pierdes.
  • Deduplica por webhook-id. La entrega es "al menos una vez": un timeout en tu lado tras haber procesado produce un reintento legítimo.
  • Puedes ver cada intento con su código y su latencia en la bitácora de entregas, y reenviar una concreta.
  • Los entornos están separados: un endpoint registrado con una key de prueba solo recibe eventos de prueba.

Buenas prácticas

  • Verifica la firma antes de mirar el cuerpo. Un cuerpo sin verificar es entrada de un desconocido.
  • Responde 2xx y procesa después, en una cola tuya.
  • Guarda el secreto donde guardas las credenciales, no en el repositorio.
  • Usa verify-signature para depurar un verificador que rechaza entregas legítimas — casi siempre es que algo reescribió el cuerpo antes de que lo vieras.