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).
Configurar tu webhook
Registrás tu webhook URL vía POST /my-integration/url (urlType: "webhook"), o desde el portal de integración.
- 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.
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.
- 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.
- 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.
- Si no hay sede y tenés exactamente UNA URL de webhook a nivel integración, el evento va ahí.
- 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.
- 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.
| Evento | Qué es | ¿Activo por defecto? |
|---|---|---|
payment_request.payment_received | Entró un pago que se imputó a una solicitud. | Sí |
payment_request.expired | Una solicitud llegó a su fecha de vencimiento sin saldarse. | Sí |
payment.unmatched | Entró 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.
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.
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_received | Llegó un pago. |
payment_request.expired | Un cobro con vencimiento datetime (fecha con hora) expiró. |
payment.unmatched | Entró 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. |
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*.
Mismo esqueleto que el anterior, pero como no entró plata no viaja thisPaymentAmount ni ningún reconciledPayer*: no hay pago que atribuir. Acá el cobro tiene un solo vencimiento, así que tampoco hay campos upcoming*.
Forma propia, no la de payment_request.*. No lleva referenceId arriba (cuando se lo pudo atribuir va anidado en paymentRequest) ni ninguno de los campos de montos del cobro, porque no cuelga de una solicitud. Este ejemplo es la forma SIN atribuir; la atribuida está más abajo, en la sección de pagos sin imputar.
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.
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.*.
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.
| Forma | Motivos | ¿Trae paymentRequest? |
|---|---|---|
| Atribuido: supimos de qué solicitud era | expired, already_paid, canceled | Sí |
| Sin atribuir: no pudimos nombrarla | no_cuil_match, no_pending, ambiguous | No |
| reason | Qué pasó | Qué podés hacer |
|---|---|---|
expired | La solicitud ya estaba vencida cuando entró el pago. | Decidir si aceptás el pago tarde o devolvés. |
already_paid | La solicitud ya estaba saldada. Suele ser un pago duplicado. | Hay plata ajena en la cuenta: devolver. |
canceled | La solicitud estaba cancelada. | Devolver, o reactivar de tu lado. |
no_cuil_match | En un CVU compartido, el CUIL del pagador no figura como pagador de ninguna solicitud. | Registrar al pagador. Es el único motivo NO definitivo. |
no_pending | No se encontró ninguna solicitud asociada a ese CVU. | Revisar. |
ambiguous | El matcher encontró más de un candidato y no adivina. | Nada de tu lado: es una anomalía nuestra. |
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.
| Evento | Qué significa |
|---|---|
payment_request.expired | La 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.
| Header | Para qué sirve |
|---|---|
validation-token | El 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-Id | Identificador 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. |
- Al registrar tu URL, configurás un validationToken (mínimo 16 caracteres) vía POST /my-integration/url.
- Chytapay incluye ese valor en el header validation-token de cada request que envía a esa URL.
- Tu endpoint compara el header recibido con el token que configuraste. Si no coincide, rechazá el request (HTTP 401 o 403).
- Si no configuraste un validationToken, el header se envía vacío (""). En ese caso, respondé igualmente para evitar reintentos.
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.
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.
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.
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 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.
- payment_request.payment_received: creá el cobro y simulá un pago sobre su CVU.
- 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.
- payment.unmatched con reason canceled. Creá el cobro, cancelalo, y simulá el pago sobre el mismo CVU.
- payment.unmatched con reason already_paid. Creá el cobro, saldalo con un pago simulado, y simulá un segundo pago sobre el mismo CVU.
- 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.