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-id | El id del evento. Estable entre reintentos. |
webhook-timestamp | Unix en segundos. Va dentro de lo firmado, así que nadie puede reproducir una entrega vieja cambiándole la fecha. |
webhook-signature | v1,<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.approved | La verificación pasó. |
verification.declined | La verificación no pasó. |
verification.review_required | El motor no decide solo: el caso entró en la cola de revisión. |
verification.more_info_requested | Hace falta que la persona aporte algo más. |
verification.expired | La sesión caducó sin completarse. |
case.escalated | Un caso de revisión subió de nivel. |
belfort.test | El 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
4xxtuyo 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.