Webhooks
Cómo recibir los eventos de pago en tu backend: suscripción, firma, reintentos y deduplicación.
Al terminar tenés un endpoint que recibe los eventos de Chytapay, verifica la firma, descarta duplicados y responde a tiempo. Necesitás una integración en Sandbox: si no la tenés, empezá por Primeros pasos.
Hay tres eventos:
| Evento | Cuándo sale |
|---|---|
payment_ |
Entró un pago y se imputó a un cobro, parcial o total. |
payment_ |
Venció sin saldarse un cobro con fecha y hora. |
payment. |
Entró plata a un CVU de cobro tuyo y no se imputó a ningún cobro. |
Crear, cancelar, dejar en borrador o vencer por día (sin hora) un cobro no genera eventos.
1Configurá la URL de webhook
Tiene que ser HTTPS y alcanzable desde internet (se rechazan las direcciones privadas o internas). Cargala en Webhooks del Portal, pestaña Destino. El token de validación es opcional, de al menos 16 caracteres: consultá para qué sirve.
Al registrarla, Chytapay manda un POST de prueba con {"test": true, "timestamp": "..."} y el header X-Chytapay-Webhook-Test: true. Respondé 2xx y no lo proceses.
Ver a qué URL llega cada evento
A qué URL llega cada evento
Cada evento va a una sola URL, la de la primera regla que aplica:
- El
webhookUrldel cobro, si se creó con uno (recibevalidation-tokenvacío). Solo parapayment_request.*:payment.unmatchedno lo usa. - La URL de la sede de la cuenta de comercio. Si la sede no tiene, no se entrega.
- Sin sede, la URL de webhook de la integración. Si no hay, no se entrega (y con más de una, en integraciones anteriores, tampoco).
Un evento sin destino se descarta y no se reintenta, aunque corrijas la configuración. Consultá cómo asignar una URL por sede en Sedes.
2Elegí qué eventos recibís
Por defecto recibís los dos payment_request.*. Los eventos refund.succeeded y refund.failed de las devoluciones también vienen apagados.
Atención: payment.unmatched es opt-in. Nace apagado. Un pago sin imputar que entra mientras está apagado no se manda nunca, ni siquiera cuando lo activás.
Activalo en Webhooks del Portal, pestaña Eventos.
La lista es una por integración y aplica a todos los destinos: la URL de la integración, la de cada sede y el webhookUrl de cada cobro. Una lista vacía es válida (no recibís nada). Un evento fuera del catálogo da 422.
3Verificá la firma
Nota: La firma llega casi siempre. Si falta o es inválida, respondé 401 para que Chytapay reintente. validation-token es independiente y puede estar vacío; verificá la firma en todos los destinos.
X-Chytapay-Signature prueba que el evento es de Chytapay y que nadie cambió el cuerpo:
- Copiá el secreto de firma (
whsec_..., completo, uno por integración y válido para todos los destinos) de Webhooks, pestaña Destino del Portal y guardalo, por ejemplo enCHYTAPAY_WEBHOOK_SECRET. - Leé el cuerpo crudo, antes de parsear: si lo volvés a serializar, la firma no coincide.
- Separá el header
t=<unix>,v1=<hex>ent(segundos Unix) yv1(HMAC en hexadecimal). - Rechazá el evento si
tdifiere en más de 300 segundos de tu hora. Cada reintento trae su propiot. - Calculá el HMAC-SHA256 de
{t}.{cuerpo crudo}con el secreto y comparalo conv1en tiempo constante. - Si falta el header o no coincide, respondé 401. Chytapay lo cuenta como intento fallido y lo reintenta.
En tu backend, verificá, deduplicá y respondé:
Node
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.CHYTAPAY_WEBHOOK_SECRET; // el valor completo, con "whsec_"
const TOLERANCE_S = 300;
const processed = new Set(); // en producción, una tabla con índice único
export function verifySignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
if (typeof header !== 'string') return false;
const parts = header.split(',').map((part) => part.split('='));
const t = Number(parts.find(([key]) => key === 't')?.[1]);
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
if (!Number.isInteger(t) || signatures.length === 0) return false;
if (Math.abs(now - t) > TOLERANCE_S) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/chytapay') {
res.writeHead(404).end();
return;
}
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks); // el cuerpo crudo, sin parsear
if (!verifySignature(rawBody, req.headers['x-chytapay-signature'], SECRET)) {
res.writeHead(401).end();
return;
}
const webhookId = req.headers['x-chytapay-webhook-id'];
if (processed.has(webhookId)) {
res.writeHead(200).end(); // duplicado: ya lo procesaste
return;
}
processed.add(webhookId);
const evento = JSON.parse(rawBody.toString('utf8'));
res.writeHead(200).end(); // respondé antes de 5 s
setImmediate(() => procesar(evento));
});
}).listen(3000);
function procesar(evento) {
if (evento.event === 'payment.unmatched') {
console.log('pago sin imputar', evento.reason, evento.paymentRequest?.referenceId);
return;
}
// payment_request.payment_received o payment_request.expired
console.log(evento.referenceId, evento.stateType); // total_payment, partial_payment, overdue...
}Python
# pip install flask
import hashlib
import hmac
import os
import time
from flask import Flask, request
SECRET = os.environ["CHYTAPAY_WEBHOOK_SECRET"].encode() # el valor completo, con "whsec_"
TOLERANCE_S = 300
processed = set() # en producción, una tabla con índice único
app = Flask(__name__)
def verify_signature(raw_body: bytes, header: str | None, secret: bytes, now: int | None = None) -> bool:
if not header:
return False
parts = [part.partition("=") for part in header.split(",")]
t = next((value for key, _, value in parts if key == "t"), "")
signatures = [value for key, _, value in parts if key == "v1"]
if not t.isdigit() or not signatures:
return False
now = int(time.time()) if now is None else now
if abs(now - int(t)) > TOLERANCE_S:
return False
expected = hmac.new(secret, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), signature.encode()) for signature in signatures)
@app.post("/webhooks/chytapay")
def chytapay_webhook():
raw_body = request.get_data() # el cuerpo crudo, antes de parsear
if not verify_signature(raw_body, request.headers.get("X-Chytapay-Signature"), SECRET):
return "", 401
webhook_id = request.headers.get("X-Chytapay-Webhook-Id")
if webhook_id in processed:
return "", 200 # duplicado: ya lo procesaste
processed.add(webhook_id)
evento = request.get_json()
if evento["event"] == "payment.unmatched":
print("pago sin imputar", evento["reason"], evento.get("paymentRequest", {}).get("referenceId"))
else:
# payment_request.payment_received o payment_request.expired
print(evento["referenceId"], evento["stateType"]) # total_payment, partial_payment, overdue...
# Si el trabajo es pesado, encolalo: la respuesta tiene que salir antes de 5 s.
return "", 200Resultado esperado: firma correcta, 200; otra firma o sin firma, 401; el mismo X-Chytapay-Webhook-Id otra vez, 200 sin procesar.
Validation-token
El header validation-token trae el validationToken que cargaste con la URL (cada sede tiene el suyo), o "" si no cargaste ninguno o si el evento va al webhookUrl de un cobro. Compararlo sigue siendo válido, pero la firma cubre todos los destinos.
IP de salida
Todos los webhooks salen de 18.231.238.36, la misma en Sandbox y Producción.
Si tu URL está detrás de un WAF, un firewall o Cloudflare (incluido Bot Fight Mode), permití esa IP. Un desafío anti-bot no lo resuelve un servidor, así que sin la excepción cada entrega falla.
El síntoma: nosotros vemos la entrega fallar y tu aplicación no registra nada, porque el borde corta el request antes de que llegue a tu código. Revisá el log de eventos de tu WAF antes de depurar el handler.
Atención: La IP no reemplaza validar la firma. Permitirla solo abre el paso; quién envía lo sigue probando la firma.
4Respondé a tiempo y deduplicá
Un intento sale bien con 2xx en menos de 5 segundos; cualquier otra cosa es un fallo. Respondé apenas verifiques la firma y procesá después.
Los 8 intentos de entrega de un evento, todos con el mismo X-Chytapay-Webhook-Id.
- Si falla, Chytapay reintenta a 1 minuto, 5 minutos, 15 minutos, 30 minutos, 1 hora, 3 horas y 6 horas del intento anterior, con jitter de ±20%.
- Si falla el octavo, el evento no se vuelve a enviar.
Tras los 8 intentos (unas 11 horas) no hay endpoint para reenviarlo: consultá el cobro con GET /payment-request/{referenceId}. Cada intento se ve en Webhooks, pestaña Historial.
Deduplicá por X-Chytapay-Webhook-Id
Un evento puede llegar más de una vez, por ejemplo si lo procesaste pero tardaste más de 5 segundos. Guardá el X-Chytapay-Webhook-Id con un índice único y descartá los repetidos.
- Es igual en todos los reintentos de un evento.
- Es distinto entre eventos, incluso del mismo cobro o de la misma transferencia (
payment_receivedyunmatched). - Depende de la URL: si la cambiás con reintentos pendientes, llegan a la nueva con otro id.
Atención: No deduplices por referenceId + stateType. Dos pagos parciales del mismo cobro los comparten: descartarías el segundo pago.
Orden
El orden no está garantizado, sobre todo con reintentos. Cada intento arma el cuerpo con el estado del cobro en ese momento: tomá stateType como el estado actual, no el orden de llegada.
Payload
Cada evento es un POST con cuerpo JSON y estos headers:
| Header | Qué trae |
|---|---|
Content- |
application/ |
X- |
t=<unix>,v1=<hex> (verificarla). |
X- |
El id del evento, igual en sus reintentos (deduplicar). |
validation- |
Tu token, o vacío (validation-token). |
Todos traen event (ruteá primero por él, porque payment.unmatched tiene otra forma) y livemode (true solo en un evento real de producción).
Un pago parcial con dos vencimientos, un cobro con fecha y hora vencido sin pagos y un pago sin imputar sin atribuir:
payment_request.payment_received
{
"referenceId": "factura-1001",
"event": "payment_request.payment_received",
"livemode": false,
"requestedAmount": 25000,
"paidAmount": 10000,
"thisPaymentAmount": 10000,
"reconciledPayerCuil": 20000000001,
"reconciledPayerName": "Pagador de Prueba",
"reconciledPayerCbu": "0000000000000000000001",
"remainingAmount": 15000,
"activeDueDateIndex": 1,
"activeDueDate": "2026-10-15T02:59:59.999Z",
"activeDueDateAmount": 25000,
"totalDueDates": 2,
"stateType": "partial_payment",
"upcomingDueDateIndex": 2,
"upcomingDueDate": "2026-10-30T02:59:59.999Z",
"upcomingDueDateAmount": 27500,
"upcomingAmountToPay": 17500,
"metadata": { "order_id": "abc-123" }
}payment_request.expired
{
"referenceId": "reserva-2002",
"event": "payment_request.expired",
"livemode": false,
"requestedAmount": 15000,
"paidAmount": 0,
"remainingAmount": 15000,
"activeDueDateIndex": 1,
"activeDueDate": "2026-10-01T18:30:00.000Z",
"activeDueDateAmount": 15000,
"totalDueDates": 1,
"stateType": "overdue"
}payment.unmatched
{
"event": "payment.unmatched",
"livemode": false,
"reason": "no_pending",
"amount": 1234.56,
"operationDate": "2026-10-01T15:04:05.000Z",
"payerCuil": 20000000001,
"payerName": "Pagador de Prueba",
"payerCbuCvu": "0000000000000000000001",
"creditCvu": "0000000000000000000002",
"merchantUserId": "00000000-0000-4000-8000-000000000001",
"merchantTaxId": "30712345671",
"merchantName": "Comercio de Prueba"
}Ver los campos de payment_request.*
Campos de payment_request.*
Los dos eventos payment_request.* tienen la misma forma y reflejan el cobro al momento del envío. Vienen siempre, salvo donde se indica:
| Campo | Qué es |
|---|---|
referenceId |
El que mandaste al crear el cobro. |
state |
El estado del cobro (tabla de abajo). |
requested |
El monto original, sin recargo. |
paid |
Lo pagado hasta ahora. |
this |
El pago que disparó el evento. Solo en payment_. |
remaining |
0 en total_; en otro estado, active, nunca negativo. |
active |
El vencimiento activo: 1 o 2. |
active |
Su fecha, en ISO UTC. |
active |
Su monto, con recargo si corresponde. |
total |
Cuántos vencimientos tiene. |
upcoming, upcoming, upcoming, upcoming |
El próximo vencimiento, su monto y lo que faltaría pagar. Si hay uno y el cobro no está saldado. |
reconciled, reconciled |
Titular y cuenta de origen (CBU o CVU). En pagos, si se conoce. |
reconciled |
CUIL/CUIT de la cuenta que transfirió, si se conoce, en ambos modos; se omite si es el del comercio. |
metadata |
Tal cual la mandaste, si la mandaste. |
Montos en pesos con dos decimales. Un vencimiento por día sale como el fin de ese día en hora argentina, en UTC. Los opcionales se omiten, no llegan en null.
state |
Qué significa |
|---|---|
partial_ |
Se pagó una parte y queda saldo. |
total_ |
Pagado; si ya entró plata, un saldo menor a $1 también cuenta como pagado. |
overdue |
Venció sin pagos. |
partial_ |
Venció con una parte pagada. |
refunded, partially_ |
Se devolvió todo o una parte. No generan un evento payment_: los ves en el cobro y en refund.. |
pending, draft, canceled |
Estados válidos que no generan eventos. |
No existe PAID: el pago total es total_payment. Consultá el ciclo de vida del cobro.
Pagos sin imputar: payment.unmatched
La plata queda acreditada en la cuenta de comercio igual: sin este evento no te enterás. Hay que activarlo.
Dos formas del mismo evento
reason viaja siempre. El objeto paymentRequest (a qué cobro correspondía) viene solo si se pudo atribuir:
reason |
¿Trae payment? |
Qué pasó |
|---|---|---|
expired |
Sí | El cobro ya había vencido. |
already_ |
Sí | Entró plata al CVU de un cobro saldado hace menos de 24 h (en modo CUIL, del mismo pagador). No se compara el monto: no prueba que el pago sea de ese cobro. |
canceled |
Sí | El cobro estaba cancelado. |
refunded |
Sí | El cobro ya estaba devuelto, total o en parte, y no admite más plata. |
partial_ |
Sí | Tu integración tiene la devolución automática por pago de menos: el pago era menor al total y se devolvió entero. |
no_ |
No | En un CVU compartido, el CUIL de quien pagó no es el de ningún pagador. Pasa cuando paga un tercero. |
no_ |
No | No hay un cobro abierto ni uno cerrado atribuible en las últimas 24 h en ese CVU. |
ambiguous |
No | Más de un cobro candidato. No depende de tu integración. |
Ver la forma atribuida y sus campos
{
"event": "payment.unmatched",
"livemode": false,
"reason": "expired",
"amount": 1234.56,
"operationDate": "2026-10-01T15:04:05.000Z",
"payerCuil": 20000000001,
"payerName": "Pagador de Prueba",
"payerCbuCvu": "0000000000000000000001",
"creditCvu": "0000000000000000000002",
"merchantUserId": "3f8a1c52-7d4e-4b19-9a60-2c5e8d1f0b47",
"merchantTaxId": "30712345671",
"merchantName": "Comercio de Prueba",
"tenantKey": "sucursal-centro",
"paymentRequest": {
"referenceId": "reserva-2002",
"dueDate": "2026-10-01T18:30:00.000Z"
}
}- Mirá si existe
paymentRequestpara saber si está atribuido. Si no, no viene (no llega ennull). referenceIdva adentro depaymentRequest, nunca en la raíz: así un handler que no miraeventno marca pagado un cobro. Puede faltar si el cobro se creó sinreferenceId;dueDateviaja siempre.amountes la plata que entró,payerCbuCvula cuenta de origen (CBU o CVU) ycreditCvuel CVU de cobro.payerCuil,payerName,payerCbuCvuycreditCvuse omiten si no se conocen.- Para identificar al comercio que recibió la transferencia usá
merchantTaxId(el CUIT/CUIL del titular de la cuenta, sin guiones) omerchantUserId. Son los mismos valores que devuelveGET /mecomoaccountHolderTaxIdymerchantUserId.merchantNamees su nombre de fantasía (fantasyNameen/me), para mostrarlo. Los tres viajan siempre. tenantKeyes la clave de la sede y solo viaja si el comercio está en una; si no, se omite (no llega ennull). No identifica al comercio: no lo uses para eso.
Nota: Corregir el pagador puede imputar un no_cuil_match o un no_pending. Si con PATCH /payment-request/{referenceId}/customer cargás el documento de quien transfirió, la plata que ya entró al CVU de ese cobro se imputa y llega payment_request.payment_received. Las condiciones están en corregir el pagador. Por eso no vuelvas a cobrar ni devuelvas de forma automática: si no se imputa, la gestión va por soporte de Chytapay. Consultá cómo evitar el pagador tercero.
Dos eventos distintos con "expired"
Distinguilos por event: payment_request.expired es un cobro que venció sin que entrara plata; payment.unmatched con reason: "expired" es plata que entró cuando el cobro ya había vencido.
5Probá tu endpoint en Sandbox
En Sandbox generás eventos sin mover plata; en Producción estas herramientas no existen.
Webhook de prueba
En Webhooks, pestaña Destino, Probar el webhook manda un payment.unmatched ficticio, firmado como uno real, para probar tu parser con cada reason. Si no llega, revisá que payment.unmatched esté activado y que haya una URL cargada. Trae un merchantUserId de ejemplo, y tenantKey solo si elegís una sede. Cada prueba trae un X-Chytapay-Webhook-Id nuevo y livemode: false.
Simulador de pagos
Una transferencia ficticia que pasa por la imputación real y dispara el evento real. En Simulador, poné el CVU de un cobro tuyo, el monto y el CUIL de quien paga.
Ver cómo reproducir cada evento
El simulador acepta el CVU de cualquier cobro tuyo, aunque esté vencido, cancelado o pagado:
| Para recibir | Hacé esto |
|---|---|
payment_ |
Un pago sobre un cobro pendiente. |
expired |
Un pago sobre un cobro con fecha y hora ya vencido. |
canceled |
Un pago sobre un cobro cancelado. |
already_ |
Un pago al CVU de un cobro saldado hace menos de 24 h (en modo CUIL, del mismo pagador). Cualquier monto sirve. |
no_ |
En un cobro por CUIL, un pago con otro CUIL de origen. |