#

Webhooks

Chytapay envía un POST a tu webhook URL cada vez que cambia el estado de un payment request. Así podés actualizar tu sistema en tiempo real sin polling.

  • Pago parcial recibido (stateType: partial_payment)
  • Pago total recibido (stateType: total_payment)
  • Vencimiento con hora (datetime) al expirar sin cobro completo (stateType: overdue o partial_overdue). Los vencimientos por día solo cambian el estado, sin webhook.
  • Plata que entró a un CVU de cobro tuyo y no se pudo imputar a ninguna solicitud (evento payment.unmatched).
Los payment requests en estado DRAFT no emiten webhooks, y la cancelación (que iniciás vos) tampoco dispara webhook. Los eventos payment_request.* se disparan únicamente ante una transición de estado real: un pago o un vencimiento con hora. payment.unmatched es el único que no cuelga de una solicitud: lo dispara la plata que entra, no un cambio de estado.
#

Configurar tu webhook

Registrás tu webhook URL vía POST /my-integration/url (urlType: "webhook"), o desde el portal de integración.

Cada evento se entrega a tu webhook. Podés definir un webhook por cobro (campo webhookUrl al crearlo) o, si operás varias sedes, un webhook por sede.
  • Iniciá sesión como admin (POST /integration/admin/login) y usá el idToken.
  • Opcionalmente agregá un validationToken (mínimo 16 caracteres). Lo recibirás en el header validation-token de cada request para verificar el origen.
Cómo se elige el destino

Cada evento se entrega a UN solo destino. No hay fan-out: nunca mandamos el mismo evento a varias URLs a la vez. Las reglas se evalúan en orden y gana la primera que aplica.

  1. Si el cobro se creó con su propia webhookUrl, el evento va ahí y a ningún otro lado. Esa URL recibe el header validation-token vacío (""), porque el token de validación es de la integración y no del cobro.
  2. Si no, y la cuenta de comercio está vinculada a una sede (tenant), el evento va al webhook de esa sede. Si la sede está vinculada pero su webhook quedó vacío, el evento queda retenido: no cae a la URL de la integración, porque la vinculación es explícita y no la adivinamos.
  3. Si no hay sede y tenés exactamente UNA URL de webhook a nivel integración, el evento va ahí.
  4. Si tenés más de una URL a nivel integración y ninguna sede que desempate, el evento queda retenido y no se entrega a ninguna. Para destrabarlo, dejá una sola URL activa o asignale un tenant a cada una.
  5. Si no tenés ninguna URL configurada, el evento queda retenido.
#

Elegir qué eventos recibís

La entrega se filtra por evento: te mandamos únicamente los que tengas activados. Una integración nueva nace con dos activados. payment.unmatched nace apagado a propósito, así que si lo implementaste y no te llega nada, lo más probable es que falte activarlo.

EventoQué es¿Activo por defecto?
payment_request.payment_receivedEntró un pago que se imputó a una solicitud.
payment_request.expiredUna solicitud llegó a su fecha de vencimiento sin saldarse.
payment.unmatchedEntró plata a un CVU de cobro y no se pudo imputar a ninguna solicitud.No, lo activás vos

Se cambia en el Portal, en la pantalla Avisos. La lista es UNA sola por integración: aplica a todas las URLs que tengas configuradas, la de la integración y la de cada sede. No se puede suscribir una URL a un evento y otra URL a otro.

Si destildás todo, tu integración deja de recibir webhooks por completo, incluidos los eventos que agreguemos más adelante.
Abrir Avisos en el Portal →
#

Payload del webhook

El cuerpo del POST contiene el estado completo del payment request al momento del evento. El único identificador que vincula el webhook con tu sistema es referenceId. El payload no incluye ningún account id.

referenceId es la única clave de matching. Validá siempre que el referenceId exista en tu sistema antes de procesar el evento.
Campo event

event es el discriminador de tipo de evento, al estilo CloudEvents/Stripe. Enrutás según event; el estado del recurso viaja en stateType.

payment_request.payment_receivedLlegó un pago.
payment_request.expiredUn cobro con vencimiento datetime (fecha con hora) expiró.
payment.unmatchedEntró plata a un CVU de cobro y no se pudo imputar. No lleva el prefijo payment_request. porque no cuelga de una solicitud. Ver la sección de abajo.
El payload de cada evento

Las tres formas difieren lo suficiente como para que compararlas scrolleando se vuelva incómodo. Elegí el evento y vas a ver su payload entero.

Pago parcial con un próximo vencimiento. Es el único de los tres que trae thisPaymentAmount y los campos reconciledPayer*.

json: pago parcial con próximo vencimiento
{
  "event": "payment_request.payment_received",
  "referenceId": "factura-002",
  "metadata": { "order_id": "abc-123", "sede": "acme" },
  "requestedAmount": 1000,
  "paidAmount": 400,
  "thisPaymentAmount": 400,
  "remainingAmount": 600,
  "activeDueDateIndex": 1,
  "activeDueDate": "2026-01-15T02:59:59.999Z",
  "activeDueDateAmount": 1000,
  "totalDueDates": 2,
  "reconciledPayerName": "María García",
  "reconciledPayerCbu": "0000031000010000000001",
  "reconciledPayerCuil": 27304050604,
  "upcomingDueDateIndex": 2,
  "upcomingDueDate": "2026-01-30T02:59:59.999Z",
  "upcomingDueDateAmount": 1100,
  "upcomingAmountToPay": 600,
  "stateType": "partial_payment"
}
Campos opcionales

thisPaymentAmount viene solo en eventos de pago (ausente en overdue/cancelado). reconciledPayerName / reconciledPayerCbu / reconciledPayerCuil traen los datos del pagador real cuando se lo pudo identificar. reconciledPayerCuil viene vacío cuando el pagador coincide con el comercio (autopago). Los campos upcoming* solo están cuando hay un próximo vencimiento y stateType ≠ total_payment. metadata refleja idéntico lo que enviaste al crear el cobro (string→string); la key está ausente cuando el cobro se creó sin metadata.

reconciledPayerCbu puede traer un CBU o un CVU, según desde dónde haya transferido el pagador. Es el mismo dato que payerCbuCvu en payment.unmatched.

Para el schema completo con todos los campos y sus tipos exactos, consultá la API Reference interactiva (endpoint POST /payment-request → sección de webhooks).

#

Pagos que no se pudieron imputar (payment.unmatched)

Cuando entra una transferencia a un CVU de cobro tuyo y el matching no la puede imputar a ninguna solicitud, la plata queda acreditada igual y sin este aviso no te enterarías. El evento sale en segundos, para todos los motivos, y va a la misma URL que el resto de los webhooks (la de la sede o la de la integración), con el mismo header validation-token. Este payload tiene forma propia: no es el de payment_request.*.

Nace sin suscriptores a propósito: la suscripción es su control de despliegue. Si tu handler ya está listo, activalo en Avisos, o no te va a llegar nada.
Un evento, dos formas

Siempre es payment.unmatched y el reason SIEMPRE viaja. Lo que a veces falta es la atribución, es decir, poder decirte de qué solicitud era esa plata. Tenés que programar para las dos formas.

FormaMotivos¿Trae paymentRequest?
Atribuido: supimos de qué solicitud eraexpired, already_paid, canceled
Sin atribuir: no pudimos nombrarlano_cuil_match, no_pending, ambiguousNo
json: pago sin imputar, forma atribuida
{
  "event": "payment.unmatched",
  "reason": "expired",
  "amount": 15000.00,
  "operationDate": "2026-08-11T17:58:00.000Z",
  "payerCuil": 20279573642,
  "payerName": "Juan Muñoz",
  "payerCbuCvu": "0000003100010000000001",
  "creditCvu": "0000053600000042260024",

  // Solo cuando se pudo determinar a qué solicitud correspondía.
  "paymentRequest": {
    "referenceId": "RESERVA-778",
    "dueDate": "2026-08-11T17:52:00.000Z"
  }
}
El discriminador de "está atribuido" es la presencia del OBJETO paymentRequest, no la del referenceId adentro. Si el objeto no está, no viaja en null: está ausente.
referenceId puede faltar incluso DENTRO de paymentRequest: sabemos cuál es la solicitud, pero nunca nos pasaste una referencia propia al crearla. En ese caso el campo se omite y dueDate viaja igual.
payerCbuCvu puede traer un CBU o un CVU, según desde dónde haya transferido el pagador. Mismo formato de 22 dígitos en los dos casos.
Los motivos
reasonQué pasóQué podés hacer
expiredLa solicitud ya estaba vencida cuando entró el pago.Decidir si aceptás el pago tarde o devolvés.
already_paidLa solicitud ya estaba saldada. Suele ser un pago duplicado.Hay plata ajena en la cuenta: devolver.
canceledLa solicitud estaba cancelada.Devolver, o reactivar de tu lado.
no_cuil_matchEn un CVU compartido, el CUIL del pagador no figura como pagador de ninguna solicitud.Registrar al pagador. Es el único motivo NO definitivo.
no_pendingNo se encontró ninguna solicitud asociada a ese CVU.Revisar.
ambiguousEl matcher encontró más de un candidato y no adivina.Nada de tu lado: es una anomalía nuestra.
Ojo: dos cosas distintas se llaman "expired"

Para una solicitud con vencimiento con hora podés recibir dos webhooks distintos con la palabra expired y significados diferentes. Se distinguen por el campo event, no por la palabra.

EventoQué significa
payment_request.expiredLa solicitud venció sin saldarse. No entró plata.
payment.unmatched con reason: "expired"Entró plata que no se pudo imputar PORQUE la solicitud ya había vencido.
#

Seguridad: validation-token

El header validation-token es el mecanismo para verificar que el webhook proviene de Chytapay y no de un tercero.

Headers de cada request
HeaderPara qué sirve
validation-tokenEl token que configuraste al registrar la URL, para verificar el origen del request. Viaja vacío ("") si no configuraste ninguno, y también cuando el cobro trae su propia webhookUrl.
X-Chytapay-Webhook-IdIdentificador del envío, estable entre los reintentos del MISMO evento y distinto entre eventos. Es la clave de deduplicación: guardalo y descartá el evento si ya lo procesaste.
  1. Al registrar tu URL, configurás un validationToken (mínimo 16 caracteres) vía POST /my-integration/url.
  2. Chytapay incluye ese valor en el header validation-token de cada request que envía a esa URL.
  3. Tu endpoint compara el header recibido con el token que configuraste. Si no coincide, rechazá el request (HTTP 401 o 403).
  4. Si no configuraste un validationToken, el header se envía vacío (""). En ese caso, respondé igualmente para evitar reintentos.
Para mayor robustez: además del validation-token, verificá que el referenceId exista en tu sistema y que el monto tenga sentido para ese pago.
#

Seguridad: IP de salida

Todos nuestros webhooks salen desde una única IP pública, y es fija: es una IP elástica reservada para nuestra infraestructura, así que no cambia sola.

IP de salida de los webhooks
18.231.238.36
Es la misma IP en test y en producción: la regla que validás en test sigue andando cuando salís en vivo.

Si tu webhook URL está detrás de un WAF, un firewall corporativo o Cloudflare, permití esta IP para los POST a esa URL o, si tu WAF lo permite, excluí esa ruta de la protección anti-bot. Un desafío anti-bot (JavaScript challenge, CAPTCHA, Managed Challenge de Cloudflare) no lo puede resolver un servidor, así que sin alguna de esas excepciones la entrega falla siempre.

La IP te dice de dónde viene el request, no que sea legítimo. Elijas la excepción que elijas, seguí validando el header validation-token.

Cómo reconocer este problema

Nosotros vemos el POST a tu URL fallando y vos no ves nada en el log de tu aplicación, porque tu borde bloquea o desafía el request antes de que llegue a tu código. Si no te llegan los webhooks y tu aplicación nunca registró el request, revisá el log de eventos de tu WAF, firewall o Cloudflare buscando requests bloqueados desde esta IP antes de depurar tu handler.

#

Entrega, reintentos e idempotencia

Los eventos se entregan a través de una cola: hacemos hasta 3 intentos por evento, espaciados unos 4 minutos. Un intento cuenta como fallido si tu endpoint no responde 2xx, o si no responde dentro de los 5 segundos. Agotados los 3 intentos, el evento pasa a una dead-letter queue nuestra que nos dispara una alerta; recuperarlo desde ahí es una operación manual de nuestro lado, no vuelve solo.

Los reintentos implican que un mismo evento puede llegarte más de una vez, incluso habiéndolo procesado bien (por ejemplo, si tu respuesta tardó más de 5 segundos). Tu handler TIENE que ser idempotente. Lo más simple es responder 2xx apenas recibís y hacer el trabajo pesado aparte.
Deduplicá por el header X-Chytapay-Webhook-Id: es el mismo valor en los 3 intentos de un evento y distinto entre eventos. No uses referenceId + stateType como clave: dos pagos parciales distintos de la misma solicitud comparten los dos campos, así que descartarías el segundo pago como si fuera un duplicado.
Estados posibles (stateType)
total_payment
Pago completado: el monto total fue cobrado
partial_payment
Pago parcial: se pagó pero todavía queda saldo
overdue
Vencido: el vencimiento pasó sin cobro total
partial_overdue
Parcial vencido: se pagó parte pero el vencimiento ya pasó
#

Probar tu endpoint con un pago simulado

Desde el portal de integración podés disparar un pago simulado contra uno de tus payment requests, sin mover dinero real. Ese pago genera el webhook real hacia tu webhook, siempre que tengas el evento activado en Avisos: la respuesta del simulador te dice en webhookWillFire si estás suscrito, y en webhookEvent qué evento produce ese pago.

El simulador existe SOLO en test. En producción no está.
Cómo reproducir cada evento

El simulador acepta el CVU de cualquier solicitud tuya, incluso una que ya venció, se canceló o se pagó. Eso es lo que hace reproducible payment.unmatched, donde por definición la solicitud ya no está viva. La receta es siempre la misma: llevá el cobro al estado que querés y recién ahí simulá el pago sobre su CVU.

  1. payment_request.payment_received: creá el cobro y simulá un pago sobre su CVU.
  2. payment.unmatched con reason expired. Creá un cobro con vencimiento con hora, dejalo vencer, y recién ahí simulá el pago sobre el mismo CVU.
  3. payment.unmatched con reason canceled. Creá el cobro, cancelalo, y simulá el pago sobre el mismo CVU.
  4. payment.unmatched con reason already_paid. Creá el cobro, saldalo con un pago simulado, y simulá un segundo pago sobre el mismo CVU.
  5. payment.unmatched con reason no_cuil_match. Sobre un CVU compartido, simulá el pago con un CUIL de origen que no sea el del pagador declarado en el cobro.
  • No se puede simular sobre el CVU de otra integración: devuelve 403, y el mensaje no distingue "ese CVU no es tuyo" de "ese CVU no existe" para no volverse un oráculo de CVUs ajenos.
  • El CUIL de origen es obligatorio y tiene que tener 11 dígitos: para provocar no_cuil_match usá uno distinto al del pagador, no uno vacío.
Abrir portal de integración →