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 un secret que la cuenta tiene, así que la entrega se puede verificar.
  • El webhook_url de 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.

EventoQué lo emite¿Se entrega hoy?
payment.completedUn pago sobre una Solicitud de Pago se completaSí
payment.failedNada lo emite hoyNo
payment_link.cancelledUna Solicitud de Pago se cancela, desde la API o desde el panelSí
payment_link.expiredCaduca la orden de cobro que se abrió en el checkoutSolo en ese caso
card_payment.completedUn cobro con tarjeta se completaSí
card_payment_link.cancelledUn cobro con tarjeta se cancela, desde la API o desde el panelSí

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

No 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

Hay 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": {}
  }
}
CampoQué es
idEl 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
eventUno de los seis valores de la tabla de arriba
createdCuándo se construyó el payload, en ISO 8601
api_versionHoy, el literal 2025-12-03
sourcesubscription 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.objectEl 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

{
  "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"
    }
  }
}
CampoQué es
idEl id de la Solicitud de Pago, no el del Pago Recibido. Es el mismo que acepta GET /v1/payment-links/{id}
payin_idEl id del Pago Recibido. Es el campo que identifica este pago en concreto
amountEl Monto declarado al crear la solicitud, como número JSON
currencyLa Moneda en la que paga el pagador
statuscompleted
customer_dataLo que el checkout recogió del pagador
created_atCuándo se creó el Pago Recibido
completed_atLa hora en que se construyó este payload
payment_methodEl medio de pago con el que se completó
descriptionLa descripción con la que se creó la solicitud

Cuatro cosas que no se leen del nombre del campo:

  • id no 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_at no 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.
  • amount y currency son 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_data no tiene esquema. Es lo que mandó el checkout, guardado tal cual. Las únicas claves que la plataforma usa de ahí dentro son first_name, last_name y email, 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

La 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"
    }
  }
}
CampoQué es
idEl id de la Solicitud de Pago
amountEl Monto declarado, como número JSON
currencyLa Moneda en la que habría pagado el pagador
statuscancelled o expired, según el evento
customer_dataLo que el checkout recogió, si alguien llegó a abrirlo; el arreglo vacío si nadie lo abrió
created_atCuándo se creó la Solicitud de Pago
completed_atCuándo se modificó por última vez la solicitud
payment_methodnull mientras nadie haya elegido medio de pago; en una caducidad tras un checkout abandonado, el medio que se eligió
descriptionLa descripción con la que se creó

Dos trampas propias de esta forma:

  • created_at no significa lo mismo que en payment.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_at nunca 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

El 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"
    }
  }
}
CampoQué es
idEl id del cobro con tarjeta, el mismo que acepta GET /v1/cards/payment-links/{id}
amount_usdEl Monto en USD, como número JSON. Un importe redondo llega sin decimales: 42, no 42.00
original_amountEl Monto tal como se pidió al crear el cobro
original_currencyLa Moneda en la que se pidió
statuscompleted o cancelled, según el evento
customer_nameEl nombre que se dio al crear el cobro
customer_emailEl correo que se dio al crear el cobro
descriptionLa descripción con la que se creó
created_atCuándo se creó el cobro
completed_atCuá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 POST con Content-Type: application/json y User-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.

IntentoCuándoCabecera X-Webhook-Retry
1En el momentoausente
25 minutos después del intento 12
315 minutos después del 23
41 hora después del 34
53 horas después del 45

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 4xx no son una excepción: un endpoint retirado que conteste 404, o un 410, recibe los cuatro reintentos completos igual que si hubiera contestado 500. 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—. El webhook_url de 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 event y data.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 event desconocido se responde con 200 y 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 status desconocido no se trata como fallo. Se trata como «todavía no resuelto»: ni cobrado ni perdido. Lo mismo vale para un code de error que el cliente no conozca, y está explicado en Errores.
  • Un campo nuevo en data.object se 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 secret de 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.

Did this page help you?