Webhooks
Te avisamos de todo lo asíncrono con un POST a tu webhookUrl. El webhook es
la fuente de verdad del desenlace, no la respuesta de POST /payouts.
Eventos
Sección titulada «Eventos»eventType |
Cuándo |
|---|---|
payment.settled |
La dispersión quedó firme: el beneficiario tiene sus Bs |
payment.failed |
La dispersión no se ejecutó. El asiento ya está revertido |
payment.reversed |
El banco devolvió una dispersión ya liquidada. Los fondos vuelven a tu saldo |
Sale una sola vez por dispersión.
El cuerpo
Sección titulada «El cuerpo»{ "id": "40545", "eventType": "payment.settled", "attempt": 1, "payload": { "paymentId": "89920401-26da-4f69-b57d-bc20e0366ce2", "asset": "BS", "amount": "5000000", "bankReference": "012345678901", "method": "pago_movil" } }| Campo | Qué es |
|---|---|
id |
Id del evento. Deduplica por esto |
eventType |
El tipo de la tabla de arriba |
payload |
Detalle del hecho. Ver el aviso de abajo |
attempt |
Número de intento de entrega. 1 la primera vez |
Cabeceras
Sección titulada «Cabeceras»POST /webhooks/dispersion HTTP/1.1content-type: application/jsonx-signature: sha256=<HMAC-SHA256(tu whsec, "<x-timestamp>.<cuerpo crudo>")>x-timestamp: 1785270095123x-event-type: payment.settledx-event-id: 40545x-delivery-attempt: 1Verificar la firma
Sección titulada «Verificar la firma»const ts = req.headers['x-timestamp'];const raw = req.body; // los BYTES exactos, no el objeto reserializado
const esperado = crypto.createHmac('sha256', WHSEC) .update(`${ts}.${raw}`) .digest('hex');
const firma = String(req.headers['x-signature']).replace('sha256=', '');const valida = crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(firma));
// Anti-replay: rechaza si el timestamp tiene más de 5 minutosconst fresco = Math.abs(Date.now() - Number(ts)) < 300_000;
if (!valida || !fresco) return res.sendStatus(401);| Algoritmo | HMAC-SHA256 sobre "<x-timestamp>.<cuerpo crudo>", en hexadecimal |
| Secreto | Uno por programa |
| Ventana anti-replay | ±5 minutos. La aplica tu receptor: nosotros firmamos con el timestamp, tú decides rechazar lo viejo |
| Comparación | En tiempo constante (timingSafeEqual), nunca con === |
La rotación del secreto la hacemos nosotros. Guárdalo con el mismo cuidado que la API key.
Reglas del receptor
Sección titulada «Reglas del receptor»-
Verifica la firma y la frescura antes de mirar el cuerpo.
-
Deduplica por
id. La entrega es at-least-once: el mismo evento puede llegar dos veces y tu handler tiene que ser idempotente. Elidtambién viaja enx-event-id. -
Responde
2xxrápido y procesa en segundo plano. Cualquier respuesta que no sea 2xx cuenta como fallo, y también un timeout: cortamos a los 5 segundos. -
Lee el estado completo con
GET /v1/payments/{id}antes de cerrar la operación en tu contabilidad.
Si tu receptor se cae
Sección titulada «Si tu receptor se cae»Reintentamos con espera creciente, hasta 12 intentos:
| Intento | Espera desde el anterior |
|---|---|
| 2 | 5 s |
| 3 | 15 s |
| 4 | 45 s |
| 5 | 2 min 15 s |
| 6 | 6 min 45 s |
| 7 | 20 min 15 s |
| 8 | ~1 h |
| 9 | ~3 h |
| 10 – 12 | 6 h |
Agotados los intentos, el evento pasa a status: "dead" — tu cola de fallidos.
No se pierde ni se marca como entregado: es consultable y reentregable.
# Lo que no llegócurl -s "$BASE/v1/events?status=dead" -H "x-api-key: $KEY"
# Intento por intento, con su código HTTP y su latenciacurl -s "$BASE/v1/events/40545" -H "x-api-key: $KEY"
# Reentregar uno, o toda la colacurl -s -X POST "$BASE/v1/events/40545/retry" -H "x-api-key: $KEY"curl -s -X POST "$BASE/v1/events/retry-dead" -H "x-api-key: $KEY"{ "id": "40545", "event_type": "payment.settled", "aggregate_type": "payment", "aggregate_id": "89920401-26da-4f69-b57d-bc20e0366ce2", "payload": { "paymentId": "89920401-26da-4f69-b57d-bc20e0366ce2", "asset": "BS", "amount": "5000000", "bankReference": "012345678901", "method": "pago_movil" }, "status": "published", "attempts": 3, "last_error": null, "next_attempt_at": null, "published_at": "2026-08-31T19:06:52.004Z", "dead_at": null, "created_at": "2026-08-31T19:06:44.120Z", "deliveries": [ { "attempt": 1, "status_code": 500, "ok": false, "error": "el receptor respondió 500", "duration_ms": 42 }, { "attempt": 2, "status_code": null, "ok": false, "error": "timeout", "duration_ms": 5001 }, { "attempt": 3, "status_code": 200, "ok": true, "error": null, "duration_ms": 87 } ]}POST /v1/events/retry-dead responde { "requeued": 7 }.
Listar eventos
Sección titulada «Listar eventos»curl -s "$BASE/v1/events?status=dead&eventType=payment.failed&limit=50" -H "x-api-key: $KEY"| Parámetro | Valores |
|---|---|
status |
pending · published · dead |
eventType |
payment.settled · payment.failed · payment.reversed |
limit · cursor |
Máximo 100. Aquí el cursor es el id numérico del evento |
El listado no incluye payload; para verlo, pide el evento por su id.
Probar tus reintentos
Sección titulada «Probar tus reintentos»POST /sandbox/webhooks/failing es un receptor que devuelve 500, y
GET /sandbox/webhooks el sink con lo que te enviamos. Ver entorno de
pruebas.