Saltar al contenido
ESENIr al Portal

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_request.payment_received Entró un pago y se imputó a un cobro, parcial o total.
payment_request.expired Venció sin saldarse un cobro con fecha y hora.
payment.unmatched 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:

  1. El webhookUrl del cobro, si se creó con uno (recibe validation-token vacío). Solo para payment_request.*: payment.unmatched no lo usa.
  2. La URL de la sede de la cuenta de comercio. Si la sede no tiene, no se entrega.
  3. 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:

  1. 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 en CHYTAPAY_WEBHOOK_SECRET.
  2. Leé el cuerpo crudo, antes de parsear: si lo volvés a serializar, la firma no coincide.
  3. Separá el header t=<unix>,v1=<hex> en t (segundos Unix) y v1 (HMAC en hexadecimal).
  4. Rechazá el evento si t difiere en más de 300 segundos de tu hora. Cada reintento trae su propio t.
  5. Calculá el HMAC-SHA256 de {t}.{cuerpo crudo} con el secreto y comparalo con v1 en tiempo constante.
  6. 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 "", 200

Resultado 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_received y unmatched).
  • 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-Type application/json
X-Chytapay-Signature t=<unix>,v1=<hex> (verificarla).
X-Chytapay-Webhook-Id El id del evento, igual en sus reintentos (deduplicar).
validation-token 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.
stateType El estado del cobro (tabla de abajo).
requestedAmount El monto original, sin recargo.
paidAmount Lo pagado hasta ahora.
thisPaymentAmount El pago que disparó el evento. Solo en payment_request.payment_received.
remainingAmount 0 en total_payment; en otro estado, activeDueDateAmount - paidAmount, nunca negativo.
activeDueDateIndex El vencimiento activo: 1 o 2.
activeDueDate Su fecha, en ISO UTC.
activeDueDateAmount Su monto, con recargo si corresponde.
totalDueDates Cuántos vencimientos tiene.
upcomingDueDateIndex, upcomingDueDate, upcomingDueDateAmount, upcomingAmountToPay El próximo vencimiento, su monto y lo que faltaría pagar. Si hay uno y el cobro no está saldado.
reconciledPayerName, reconciledPayerCbu Titular y cuenta de origen (CBU o CVU). En pagos, si se conoce.
reconciledPayerCuil 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.

stateType Qué significa
partial_payment Se pagó una parte y queda saldo.
total_payment Pagado; si ya entró plata, un saldo menor a $1 también cuenta como pagado.
overdue Venció sin pagos.
partial_overdue Venció con una parte pagada.
refunded, partially_refunded Se devolvió todo o una parte. No generan un evento payment_request.*: 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 paymentRequest? Qué pasó
expired Sí El cobro ya había vencido.
already_paid 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_not_accepted 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_cuil_match No En un CVU compartido, el CUIL de quien pagó no es el de ningún pagador. Pasa cuando paga un tercero.
no_pending 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
JSON
{
  "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 paymentRequest para saber si está atribuido. Si no, no viene (no llega en null).
  • referenceId va adentro de paymentRequest, nunca en la raíz: así un handler que no mira event no marca pagado un cobro. Puede faltar si el cobro se creó sin referenceId; dueDate viaja siempre.
  • amount es la plata que entró, payerCbuCvu la cuenta de origen (CBU o CVU) y creditCvu el CVU de cobro. payerCuil, payerName, payerCbuCvu y creditCvu se 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) o merchantUserId. Son los mismos valores que devuelve GET /me como accountHolderTaxId y merchantUserId. merchantName es su nombre de fantasía (fantasyName en /me), para mostrarlo. Los tres viajan siempre.
  • tenantKey es la clave de la sede y solo viaja si el comercio está en una; si no, se omite (no llega en null). 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_request.payment_received 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_paid Un pago al CVU de un cobro saldado hace menos de 24 h (en modo CUIL, del mismo pagador). Cualquier monto sirve.
no_cuil_match En un cobro por CUIL, un pago con otro CUIL de origen.