Saltar al contenido principal

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.

AtributoDescripción
urlDirección a la que se envían los eventos. Debe ser pública y responder 2xx.
eventsQué eventos recibir. Lista vacía o "*" = todos; "payment.*" = por prefijo; un valor exacto ("payment.approved") = solo ese.
headersCabeceras adicionales que se añaden a cada envío (no pisan las estándar).
descriptionTexto 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.approvedEl pago fue aprobado.
payment.rejectedEl pago fue rechazado o falló.
payment.in_reviewEl pago está en revisión o pendiente de confirmación.
payment.refundedEl pago fue reembolsado.
payment.chargebackSe recibió un contracargo.

Catálogo completo y estados asociados en referencia de eventos.

Qué recibes​

Cabeceras:

CabeceraContenido
content-typeapplication/json; charset=utf-8
x-payment-event-idIdentificador del evento. Es el mismo id del cuerpo y el que entra en la firma.
x-payment-event-typeEl type del evento.
x-payment-timestampInstante del envío, en segundos desde epoch. Cambia en cada reintento.
x-payment-signaturev1,<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.event describe el evento saliente (direction siempre outgoing, status es el de la cola en el momento de armar el mensaje); lo que importa para tu lógica son type, data.payment y data.link.
  • payment.rail: card, pse, cuentas_crezcamos o bre_b; payment.provider: akua (tarjeta, PSE) o crezcamos (Cuenta Crezcamos, Bre-B).
  • payment.status: ver estados. status_detail aporta el motivo cuando existe (USER_DECLINED, INSUFFICIENT_FUNDS, EXPIRED…).
  • Los importes son texto ("50000").
  • failure_reason no es fiable en pagos aprobados: en los pagos confirmados por un proveedor de rail puede repetir el estado (arriba, "APPROVED"). Úsalo solo cuando payment.status sea failed.

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-timestamp y 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_review puede llegar después de un payment.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.
Receta de un receptor robusto
  1. Verifica la firma con el cuerpo sin reserializar.
  2. Si ya procesaste ese id, responde 200 sin hacer nada.
  3. Guarda el evento y responde 200.
  4. Procesa después. Para decisiones de negocio (despachar un pedido), confía en payment.status == "paid".
Lo que aún no existe

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.