Webhooks de pago
La pasarela notifica a tu servidor cada cambio relevante de un pago con una petición POST firmada. Es la forma de conocer el resultado de un cobro.
Registrar un webhook
POST /api/merchant-webhooks
curl -s -X POST "$API/api/merchant-webhooks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "merchant_webhook",
"attributes": {
"url": "https://mi-servidor.example.com/webhooks/pagos",
"description": "Pedidos",
"events": ["payment.*"]
}
}
}'
El secreto de firma (whsec_…) viene una sola vez en meta.secret de la respuesta; nunca como atributo ni en consultas posteriores. Guárdalo en tu gestor de secretos.
| Atributo | Descripción |
|---|---|
url | Dirección a la que se envían los eventos. Debe ser pública y responder 2xx. |
events | Qué eventos recibir. Lista vacía o "*" = todos; "payment.*" = por prefijo; un valor exacto ("payment.approved") = solo ese. |
headers | Cabeceras adicionales que se añaden a cada envío (no pisan las estándar). |
description | Texto libre. |
Otras operaciones: GET /api/merchant-webhooks (lista), GET /api/merchant-webhooks/{id}, PATCH /api/merchant-webhooks/{id} (actualizar) y PATCH /api/merchant-webhooks/{id}/rotate-secret (secreto nuevo, el anterior deja de servir).
Eventos
Tipo (type) | Cuándo |
|---|---|
payment.approved | El pago fue aprobado. |
payment.rejected | El pago fue rechazado o falló. |
payment.in_review | El pago está en revisión o pendiente de confirmación. |
payment.refunded | El pago fue reembolsado. |
payment.chargeback | Se recibió un contracargo. |
Catálogo completo y estados asociados en referencia de eventos.
Qué recibes
Cabeceras:
| Cabecera | Contenido |
|---|---|
content-type | application/json; charset=utf-8 |
x-payment-event-id | Identificador del evento. Es el mismo id del cuerpo y el que entra en la firma. |
x-payment-event-type | El type del evento. |
x-payment-timestamp | Instante del envío, en segundos desde epoch. Cambia en cada reintento. |
x-payment-signature | v1,<firma en Base64>. Ver verificar la firma. |
Cuerpo (capturado de una entrega real; los identificadores son de prueba):
{
"id": "d892001f-126f-480e-9edf-5f771391bdc0",
"type": "payment.approved",
"version": "1.0",
"source": "payment_engine",
"time": "2026-10-22T13:49:07.3876890Z",
"data": {
"event": {
"id": "14bd060c-a973-4efc-bb0d-17028e8d46a2",
"direction": "outgoing",
"provider": "payment_engine",
"event_type": "payment.approved",
"status": "queued",
"attempt_count": 0
},
"link": {
"id": "c429a113-7d39-4a6b-a5eb-201b8f77ee21",
"token": "BiwZwQOiYAEt93bM13zzXJ8GWeODavbs",
"concept": "Pedido 1042",
"description": "Compra en línea — pedido 1042",
"reference": "pedido-1042",
"currency": "COP",
"amount": "50000",
"state": "paid",
"expires_at": "2030-01-01T00:00:00.0000000Z",
"confirmation_url": null,
"thank_you_url": null,
"merchant_id": "00000000-0000-0000-0000-0000000000a1"
},
"payment": {
"id": "107576e1-ca2a-4ba8-82fb-b9c3eeb7ebbc",
"provider": "crezcamos",
"rail": "bre_b",
"provider_payment_id": "app-BiwZwQOiYAEt93bM13zzXJ8GWeODavbs",
"provider_transaction_id": null,
"provider_reference": null,
"status": "paid",
"status_detail": "APPROVED",
"amount": "50000",
"currency": "COP",
"approval_code": null,
"response_code": null,
"failure_reason": "APPROVED",
"metadata": {}
},
"metadata": {}
}
}
data.eventdescribe el evento saliente (directionsiempreoutgoing,statuses el de la cola en el momento de armar el mensaje); lo que importa para tu lógica sontype,data.paymentydata.link.payment.rail:card,pse,cuentas_crezcamosobre_b;payment.provider:akua(tarjeta, PSE) ocrezcamos(Cuenta Crezcamos, Bre-B).payment.status: ver estados.status_detailaporta el motivo cuando existe (USER_DECLINED,INSUFFICIENT_FUNDS,EXPIRED…).- Los importes son texto (
"50000"). failure_reasonno es fiable en pagos aprobados: en los pagos confirmados por un proveedor de rail puede repetir el estado (arriba,"APPROVED"). Úsalo solo cuandopayment.statusseafailed.
Entrega, reintentos y duplicados
- Éxito = cualquier respuesta
2xx. Responde rápido (el envío espera hasta 15 s) y procesa de forma asíncrona. - Si no respondes
2xx, la pasarela reintenta hasta 5 veces en total, con una espera fija de unos 10 s entre intentos (valor actual; puede cambiar). Agotados los intentos, el evento pasa a una cola de eventos fallidos. - El mensaje y el cuerpo no cambian entre reintentos; sí cambian
x-payment-timestampy la firma. - Entrega al menos una vez. Puedes recibir el mismo evento más de una vez: deduplica por
id(x-payment-event-id). - Sin garantía de orden. Un
payment.in_reviewpuede llegar después de unpayment.approved. Tu lógica debe ser idempotente y no retroceder un pedido ya aprobado por un evento tardío. - La pasarela protege su propio estado: un evento de "en revisión" tardío no degrada un pago ya cobrado.
- Verifica la firma con el cuerpo sin reserializar.
- Si ya procesaste ese
id, responde200sin hacer nada. - Guarda el evento y responde
200. - Procesa después. Para decisiones de negocio (despachar un pedido), confía en
payment.status == "paid".
No hay una API para consultar las entregas de un webhook ni para reenviar un evento fallido, y no se publica un catálogo de cabeceras por versión. Ver huecos conocidos.