Webhooks
Un webhook es una petición POST que Aloha manda a una dirección propia cuando algo cambia en la cuenta: un cobro que se completa, una Solicitud de Pago que se cancela. Es lo que sustituye a preguntar cada tanto por el Estado, y la forma de enterarse en el instante en que ocurre.
Hay dos destinos posibles y no son equivalentes
- Un endpoint registrado, creado con
POST /v1/webhooks. Recibe los eventos a los que está suscrito para toda la cuenta, no solo los de las Solicitudes de Pago creadas con la clave que lo registró ni los de quien lo registró: también los que nacen del panel. Se firma con unsecretque la cuenta tiene, así que la entrega se puede verificar. - El
webhook_urlde una Solicitud de Pago concreta, indicado al crearla. Recibe únicamente los eventos de esa solicitud. Lleva firma, pero hoy esa firma no se puede comprobar: se calcula con una clave interna de la plataforma que la cuenta no tiene ni puede obtener. Está explicado en Firmar y verificar webhooks, y es la razón por la que una integración que necesite comprobar el origen de lo que recibe se apoya en un endpoint registrado.
Las dos vías conviven. Si una Solicitud de Pago lleva webhook_url y además hay un endpoint suscrito al evento, el aviso sale a los dos, en dos entregas distintas e independientes entre sí.
Los dos eventos de cobro con tarjeta salen solo a endpoints registrados: un cobro con tarjeta no acepta webhook_url propio.
Registrar, listar, consultar, actualizar, borrar y rotar el secreto de un endpoint son seis operaciones de la API, y su contrato —cuerpos, campos y rechazos— vive en la referencia. Lo que hay aquí es lo otro: qué se recibe, cuándo y qué hacer con ello.
Los seis eventos
events acepta exactamente estos seis valores, al crear y al actualizar un endpoint. Cualquier otro se rechaza con 422 antes de escribir nada.
| Evento | Qué lo emite | ¿Se entrega hoy? |
|---|---|---|
payment.completed | Un pago sobre una Solicitud de Pago se completa | Sí |
payment.failed | Nada lo emite hoy | No |
payment_link.cancelled | Una Solicitud de Pago se cancela, desde la API o desde el panel | Sí |
payment_link.expired | Caduca la orden de cobro que se abrió en el checkout | Solo en ese caso |
card_payment.completed | Un cobro con tarjeta se completa | Sí |
card_payment_link.cancelled | Un cobro con tarjeta se cancela, desde la API o desde el panel | Sí |
Son seis, no cinco. card_payment_link.cancelled se acepta como suscripción y se emite de verdad —desde la cancelación por API y desde la del panel—, aunque durante mucho tiempo no estuvo documentado.
Suscribirse a un evento no es lo mismo que recibirlo. La suscripción se escribe y la API contesta 201 para los seis, incluidos los dos de la sección siguiente.
Dos de los seis no se pueden dar por sentados
Uno no se entrega hoy. El otro puede entregarse, pero solo en un caso —y no en el que casi todo el mundo espera—, y ni siquiera ahí está garantizado.
payment.failed: nada lo emite
payment.failed: nada lo emiteNo hay hoy en la plataforma ningún punto que emita este evento. La suscripción se acepta, la API contesta 201, la lista lo devuelve entre los eventos del endpoint, y no se entrega nada. Una integración que apoye su manejo de fallos aquí funciona en apariencia hasta el día en que un pago real falla, y ese día el fallo pasa en silencio.
Para saber que un cobro no prosperó se consulta la Solicitud de Pago con GET /v1/payment-links/{id} y se lee su Estado.
payment_link.expired: llega, pero no se puede contar con él
payment_link.expired: llega, pero no se puede contar con élHay dos maneras de que una Solicitud de Pago acabe caducada, y solo una de las dos puede producir aviso.
La caducidad de la propia solicitud no emite nada. El barrido que marca caducadas las solicitudes vencidas escribe el Estado con una actualización en bloque que no dispara ningún evento. Por esa vía no hay entrega, tenga la solicitud fecha de caducidad o no.
La caducidad de la orden del checkout sí, pero no siempre llega. Cuando un pagador abre el checkout y elige un medio de pago se abre una orden de cobro con su propio plazo. Si el pagador la abandona y ese plazo se agota, la caducidad se informa de vuelta, la Solicitud de Pago pasa a expired y el evento se entrega. Ahora bien, al abrirse esa orden su plazo queda escrito también en la propia Solicitud, y el mismo barrido corre cada cinco minutos contra las solicitudes activas con el plazo vencido. Si el barrido llega primero, marca expired sin emitir nada, y el informe que llega después se descarta por duplicado y tampoco emite. Gana el que llegue antes.
La consecuencia práctica: este evento cubre el checkout abandonado y nada más, y ese caso lo cubre sin garantía. Una Solicitud de Pago que nadie llegó a abrir puede quedarse sin pagar para siempre sin producir un solo aviso. Quien reserve existencias contra una solicitud tiene que liberarlas con un plazo propio: este evento es una ayuda que puede no llegar.
La forma de todo payload
El cuerpo es siempre JSON y siempre tiene la misma envoltura: cinco claves y un data.object con el recurso.
{
"id": "wh_evt_550e8400-e29b-41d4-a716-446655440000",
"event": "payment.completed",
"created": "2026-03-23T10:30:45+00:00",
"api_version": "2025-12-03",
"source": "subscription",
"data": {
"object": {}
}
}| Campo | Qué es |
|---|---|
id | El identificador de este evento, wh_evt_ seguido de un UUID. Se genera al construir el payload: se conserva entre los reintentos de una misma entrega y cambia de un destino a otro |
event | Uno de los seis valores de la tabla de arriba |
created | Cuándo se construyó el payload, en ISO 8601 |
api_version | Hoy, el literal 2025-12-03 |
source | subscription si la entrega va a un endpoint registrado, payment_link si va al webhook_url de una Solicitud de Pago. No hay más valores |
data.object | El recurso, con la forma que describen las secciones siguientes |
Las fechas llevan desfase numérico, no Z. Todas las de estos payloads se serializan como 2026-03-23T10:30:45+00:00. Un cliente que compare cadenas contra un formato terminado en Z no encaja ninguna.
El payload de cada evento
Son tres formas distintas, no una. Los dos eventos de tarjeta comparten una, los dos de ciclo de vida de la Solicitud de Pago comparten otra, y payment.completed tiene la suya.
payment.completed
payment.completed{
"id": "wh_evt_550e8400-e29b-41d4-a716-446655440000",
"event": "payment.completed",
"created": "2026-03-23T10:30:45+00:00",
"api_version": "2025-12-03",
"source": "subscription",
"data": {
"object": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payin_id": "1f0c1d2e-3a4b-4c5d-8e9f-0a1b2c3d4e5f",
"amount": 150000,
"currency": "COP",
"status": "completed",
"customer_data": {
"first_name": "Ana",
"last_name": "Rojas",
"email": "[email protected]"
},
"created_at": "2026-03-23T10:29:12+00:00",
"completed_at": "2026-03-23T10:30:45+00:00",
"payment_method": "unknown",
"description": "Orden 1234"
}
}
}| Campo | Qué es |
|---|---|
id | El id de la Solicitud de Pago, no el del Pago Recibido. Es el mismo que acepta GET /v1/payment-links/{id} |
payin_id | El id del Pago Recibido. Es el campo que identifica este pago en concreto |
amount | El Monto declarado al crear la solicitud, como número JSON |
currency | La Moneda en la que paga el pagador |
status | completed |
customer_data | Lo que el checkout recogió del pagador |
created_at | Cuándo se creó el Pago Recibido |
completed_at | La hora en que se construyó este payload |
payment_method | El medio de pago con el que se completó |
description | La descripción con la que se creó la solicitud |
Cuatro cosas que no se leen del nombre del campo:
idno es el id del pago. Quien lo guarde como identificador de la transacción está guardando el de la Solicitud de Pago, que es un recurso distinto y de vida más larga. Para el pago estápayin_id.completed_atno es una marca guardada de cuándo se completó el cobro, sino el instante en que se generó el aviso. En un reintento el valor no cambia, porque el payload se conserva tal cual, pero tampoco se acerca más a la verdad. Si hace falta la hora real, se consulta el recurso.amountycurrencyson lo declarado, no necesariamente lo cobrado. Cuando la Comisión se le suma al pagador por encima, lo que se le carga de verdad no es este número. Y la Moneda de la Billetera en la que se acredita el dinero no aparece en este payload.customer_datano tiene esquema. Es lo que mandó el checkout, guardado tal cual. Las únicas claves que la plataforma usa de ahí dentro sonfirst_name,last_nameyemail, y nada garantiza que estén ni que no lleguen otras. Y cuando no hay datos llega como arreglo vacío[], no como objeto vacío: el mismo campo cambia de tipo JSON. En un lenguaje con tipos, eso se declara como valor desconocido y se comprueba antes de usarlo.
payment_method merece su propia línea: es null hasta que alguien elige un medio de pago en el checkout —lo que ocurre al abrir el cobro, no al pagarlo— y, a partir de ahí, o el identificador del medio elegido, o el literal unknown cuando el checkout no informó ninguno. No es una lista cerrada y no conviene ramificar sobre ella.
payment_link.cancelled y payment_link.expired
payment_link.cancelled y payment_link.expiredLa misma forma que el anterior menos payin_id: nueve campos, no diez. No hay Pago Recibido que nombrar, porque no hubo pago.
{
"id": "wh_evt_6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"event": "payment_link.cancelled",
"created": "2026-03-23T11:02:00+00:00",
"api_version": "2025-12-03",
"source": "subscription",
"data": {
"object": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"amount": 150000,
"currency": "COP",
"status": "cancelled",
"customer_data": [],
"created_at": "2026-03-23T09:58:41+00:00",
"completed_at": "2026-03-23T11:02:00+00:00",
"payment_method": null,
"description": "Orden 1234"
}
}
}| Campo | Qué es |
|---|---|
id | El id de la Solicitud de Pago |
amount | El Monto declarado, como número JSON |
currency | La Moneda en la que habría pagado el pagador |
status | cancelled o expired, según el evento |
customer_data | Lo que el checkout recogió, si alguien llegó a abrirlo; el arreglo vacío si nadie lo abrió |
created_at | Cuándo se creó la Solicitud de Pago |
completed_at | Cuándo se modificó por última vez la solicitud |
payment_method | null mientras nadie haya elegido medio de pago; en una caducidad tras un checkout abandonado, el medio que se eligió |
description | La descripción con la que se creó |
Dos trampas propias de esta forma:
created_atno significa lo mismo que enpayment.completed. Allí es la fecha del Pago Recibido; aquí, la de la Solicitud de Pago. Un cliente que trate los dos eventos con el mismo lector está mezclando dos relojes.completed_atnunca viene vacío, en un evento que por definición no completó nada. Lleva la fecha de la última modificación del recurso, que en la práctica es la de la cancelación o la de la caducidad. El nombre del campo no describe su contenido.
card_payment.completed y card_payment_link.cancelled
card_payment.completed y card_payment_link.cancelledEl carril de tarjeta tiene su propia forma: diez campos, de los que solo id, status, description, created_at y completed_at se llaman igual que en los payloads de arriba. El Monto y los datos del pagador viajan con otros nombres.
{
"id": "wh_evt_3fa85f64-5717-4562-b3fc-2c963f66afa6",
"event": "card_payment.completed",
"created": "2026-03-23T12:14:07+00:00",
"api_version": "2025-12-03",
"source": "subscription",
"data": {
"object": {
"id": "9b2c4d6e-8f10-4a2b-9c3d-5e7f8a9b0c1d",
"amount_usd": 42.5,
"original_amount": 42.5,
"original_currency": "USD",
"status": "completed",
"customer_name": "Ana Rojas",
"customer_email": "[email protected]",
"description": "Orden 1234",
"created_at": "2026-03-23T12:10:55+00:00",
"completed_at": "2026-03-23T12:14:03+00:00"
}
}
}| Campo | Qué es |
|---|---|
id | El id del cobro con tarjeta, el mismo que acepta GET /v1/cards/payment-links/{id} |
amount_usd | El Monto en USD, como número JSON. Un importe redondo llega sin decimales: 42, no 42.00 |
original_amount | El Monto tal como se pidió al crear el cobro |
original_currency | La Moneda en la que se pidió |
status | completed o cancelled, según el evento |
customer_name | El nombre que se dio al crear el cobro |
customer_email | El correo que se dio al crear el cobro |
description | La descripción con la que se creó |
created_at | Cuándo se creó el cobro |
completed_at | Cuándo se completó, o null si no se completó |
Este payload no trae nada de la tarjeta. Ni marca, ni últimos cuatro dígitos, ni si se pagó desde una billetera del teléfono. Tampoco trae la URL del cobro, ni la Tasa de Cambio, ni la dirección de redirección. Un manejador que lea cualquiera de esos campos no encuentra nada y, si además lo imprime en un recibo, lo imprime vacío. Lo que haya de eso se consulta sobre el recurso.
De las fechas de los tres payloads, completed_at es la única que puede llegar como null, y llega así justo en el evento en el que más se nota: la cancelación.
Entrega
- La petición es un
POSTconContent-Type: application/jsonyUser-Agent: AlohaPay-Webhooks/1.0. Las cabeceras de firma están en Firmar y verificar webhooks. - El tiempo de espera es de 10 segundos. Pasado ese plazo la entrega cuenta como fallida, aunque el servidor acabe procesándola.
- Se da por entregada con cualquier código 2xx. El cuerpo de la respuesta no se interpreta.
- De ahí la regla de implementación que más incidentes evita: contestar primero y trabajar después. Se valida la firma, se deja el evento en una cola propia, se contesta
200, y el trabajo de verdad —marcar la orden, mandar correos, tocar inventario— ocurre fuera de la petición. Un manejador que haga todo eso antes de contestar tarde o temprano supera los 10 segundos y provoca reintentos de algo que ya se procesó. - El orden de llegada no está garantizado. Cada entrega viaja por su cuenta.
Una advertencia sobre el momento: payment.completed no siempre llega a los pocos segundos del pago. En un cobro en USD el aviso se emite cuando el banco liquida el movimiento, que puede ser días después de que el pagador termine. Y un cobro que después se devuelve deja la Solicitud de Pago en un Estado distinto sin emitir ningún evento propio: no hay aviso de la devolución, así que quien necesite enterarse tiene que consultar el recurso.
Reintentos
Si la entrega no termina en 2xx, se reintenta.
| Intento | Cuándo | Cabecera X-Webhook-Retry |
|---|---|---|
| 1 | En el momento | ausente |
| 2 | 5 minutos después del intento 1 | 2 |
| 3 | 15 minutos después del 2 | 3 |
| 4 | 1 hora después del 3 | 4 |
| 5 | 3 horas después del 4 | 5 |
Cinco intentos en total, repartidos en unas cuatro horas y veinte minutos desde el primero. Ese número no se puede cambiar desde esta API. Tras el quinto la entrega deja de reintentarse.
Lo que conviene saber de esa tabla:
- Se reintenta cualquier respuesta fuera del rango 2xx, y también un error de red o un tiempo de espera agotado. Los
4xxno son una excepción: un endpoint retirado que conteste404, o un410, recibe los cuatro reintentos completos igual que si hubiera contestado500. Para dejar de recibir entregas nuevas se desactiva o se borra el endpoint, no se contesta con un código de cliente —y eso no alcanza a lo que ya está en curso, ver el último punto—. Elwebhook_urlde una Solicitud de Pago no tiene ni esa salida: no se puede desactivar ni borrar, porque no es un endpoint. - Las esperas son mínimos, no citas. Los tramos de una y de tres horas los despierta un barrido que corre cada minuto, así que un reintento puede salir algo más tarde de lo que dice la tabla, nunca antes.
- Un reintento cuya hora venció hace más de 24 horas ya no vuelve a encolarse. A partir de ahí no se producen entregas nuevas de ese evento: una caída larga del lado receptor no termina en una descarga tardía de todo lo acumulado, sino en silencio. Recuperar lo que se perdió en ese hueco se hace consultando los recursos, no esperando.
- Ni desactivar ni borrar un endpoint detiene lo que ya está en curso. Un reintento ya programado sigue su ciclo aunque el endpoint quede inactivo en medio. Y si el endpoint se borra, esos reintentos siguen saliendo igual, porque cada entrega guarda su propia URL precisamente para ese caso —pero llegan firmados con una clave que la cuenta no puede comprobar, ya que no queda endpoint del que leer el secreto—. El síntoma es un receptor que, de pronto y sin haber cambiado nada, empieza a rechazar entregas por firma inválida. Está en Firmar y verificar webhooks.
Un mismo evento puede llegar más de una vez
Un reintento es exactamente eso: el mismo evento otra vez. El payload se reenvía tal cual, con el mismo id, la misma cabecera X-Webhook-Id y una X-Webhook-Retry que lo delata. Esa parte se deduplica bien guardando el id del evento y descartando lo que ya se vio.
Lo que no cubre el id es el otro caso: un mismo hecho puede salir a dos destinos a la vez —un endpoint registrado y el webhook_url de la solicitud, o dos endpoints suscritos al mismo evento—, y cada destino recibe su propio id. Son dos avisos del mismo hecho con identificadores distintos. Deduplicar solo por id no los cruza.
Para eso la clave es el recurso:
- En
payment.completed,payin_id— identifica el Pago Recibido concreto. - En los demás, el par
eventydata.object.id.
Y una regla que ahorra incidentes: procesar dos veces tiene que ser inofensivo, y un evento ya procesado se contesta igual con 200. Contestar un error porque "ya estaba hecho" solo consigue que Aloha lo reintente.
Un evento que no se reconoce no es un fallo
Esta es la regla que mantiene viva una integración a lo largo de las versiones, y la más barata de aplicar:
- Un
eventdesconocido se responde con200y se registra. Puede ser un evento nuevo al que la cuenta quedó suscrita, o uno que esta integración todavía no maneja. Ninguna de las dos cosas es un problema del emisor, y contestar un error solo provoca cinco entregas en lugar de una. - Un
statusdesconocido no se trata como fallo. Se trata como «todavía no resuelto»: ni cobrado ni perdido. Lo mismo vale para uncodede error que el cliente no conozca, y está explicado en Errores. - Un campo nuevo en
data.objectse ignora sin más. Los payloads pueden ganar campos, y un cliente estricto que rechace lo que no espera se rompe con el primero que llegue.
La forma más común de romper esto es un switch sin rama por defecto, o un esquema que rechaza claves adicionales. Las dos convierten una ampliación normal del producto en una caída de la integración.
Seguir por aquí
- Firmar y verificar webhooks — las cabeceras, el algoritmo, el código y qué hacer al rotar el secreto.
- Inicio rápido — registrar el primer endpoint y guardar el
secretde la respuesta que lo entrega. - Errores — qué significa cada rechazo de las operaciones de webhook y qué hacer con él.
- Ambientes y Sandbox — por qué los endpoints de Sandbox y los de Producción son dos juegos distintos.
- Glosario — qué significa cada término que aparece en esta página.
Updated 26 days ago
