Skip to content
ESENGo to the Portal

Webhooks

Receive payment events in your backend: subscription, signature verification, retries and deduplication.

By the end you have an endpoint that receives Chytapay events, verifies the signature, drops duplicates and responds in time. You need an integration in Sandbox: if you don't have one, start with Getting started.

There are three events:

Event When it's sent
payment_request.payment_received A payment came in and was applied to a charge, partially or in full.
payment_request.expired A charge with a due date and time expired unpaid.
payment.unmatched Money came in to one of your collection CVUs and wasn't applied to any charge.

Creating, canceling or drafting a charge, or a due date without a time passing, generates no event.

1Set your webhook URL

It must be HTTPS and reachable from the internet: Chytapay rejects private or internal addresses. Set it under Configuración (Settings) in the Portal. The validation token is optional, at least 16 characters long: see what it's for.

On registration, Chytapay sends a test POST with {"test": true, "timestamp": "..."} and the header X-Chytapay-Webhook-Test: true. Respond 2xx and don't process it.

Which URL each event goes to

Which URL each event goes to

Each event goes to one URL only, the one from the first rule that applies:

  1. The charge's webhookUrl, if it was created with one (it gets an empty validation-token). Only for payment_request.*: payment.unmatched doesn't use it.
  2. The URL of the merchant account's location. If the location has none, the event isn't delivered.
  3. With no location, the integration's webhook URL. If there is none, the event isn't delivered (and with more than one, on earlier integrations, neither).

An event with no destination is discarded and never retried, even if you fix the configuration. See how to assign a URL per location in Locations.

2Choose which events you receive

By default you get both payment_request.* events.

Warning: payment.unmatched is opt-in. It starts disabled. An unmatched payment that comes in while it's off is never sent, not even after you enable it.

Enable it on the Webhooks screen in the Portal.

There's one list per integration, and it applies to every destination: the integration's URL, each location's URL and each charge's webhookUrl. An empty list is valid (you receive nothing). An event outside the catalog returns 422.

3Verify the signature

Note: Already validating with the validation-token? You don't have to change anything. They are two independent mechanisms, and both headers always arrive. The token is a text you chose yourself and travels as-is. The signature is computed with a secret that never travels, so it also detects whether anyone modified the body. We recommend the signature for new integrations.

X-Chytapay-Signature proves the event comes from Chytapay and the body wasn't changed:

  1. Copy the signing secret (whsec_...) under Configuración (Settings) in the Portal, next to the webhook URL, and store it, for example as CHYTAPAY_WEBHOOK_SECRET.
  2. Read the raw body before parsing: if you serialize it again, the signature won't match.
  3. Split the t=<unix>,v1=<hex> header into t (Unix seconds) and v1 (hex HMAC).
  4. Reject the event if t is more than 300 seconds off your clock. Each retry carries its own t.
  5. Compute the HMAC-SHA256 of {t}.{raw body} with the secret and compare it with v1 in constant time.
  6. If the header is missing or doesn't match, respond 401. Chytapay counts that as a failed attempt and retries it.

In your backend, verify, deduplicate and respond:

Node

import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.CHYTAPAY_WEBHOOK_SECRET; // the full value, including "whsec_"
const TOLERANCE_S = 300;
const processed = new Set(); // in production, a table with a unique index

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); // the raw body, unparsed
    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(); // duplicate: already processed
      return;
    }
    processed.add(webhookId);
    const event = JSON.parse(rawBody.toString('utf8'));
    res.writeHead(200).end(); // respond within 5 s
    setImmediate(() => handleEvent(event));
  });
}).listen(3000);

function handleEvent(event) {
  if (event.event === 'payment.unmatched') {
    console.log('unmatched payment', event.reason, event.paymentRequest?.referenceId);
    return;
  }
  // payment_request.payment_received or payment_request.expired
  console.log(event.referenceId, event.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()  # the full value, including "whsec_"
TOLERANCE_S = 300
processed = set()  # in production, a table with a unique index

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()  # the raw body, before parsing
    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  # duplicate: already processed
    processed.add(webhook_id)

    event = request.get_json()
    if event["event"] == "payment.unmatched":
        print("unmatched payment", event["reason"], event.get("paymentRequest", {}).get("referenceId"))
    else:
        # payment_request.payment_received or payment_request.expired
        print(event["referenceId"], event["stateType"])  # total_payment, partial_payment, overdue...
    # If the work is heavy, queue it: the response must go out within 5 s.
    return "", 200

Expected result: correct signature, 200; different or missing signature, 401; the same X-Chytapay-Webhook-Id again, 200 without processing.

Note: Use the full secret as the key, whsec_ included. There's one per integration, and it signs every destination.

Validation-token

The validation-token header carries the validationToken you set with the URL (each location has its own), or "" if you set none or the event goes to a charge's webhookUrl. Comparing it is still valid. The signature is the recommended check: it covers every destination and detects body changes.

Egress IP

Every webhook is sent from 18.231.238.36, the same in Sandbox and Production.

If your URL sits behind a WAF, a firewall or Cloudflare (Bot Fight Mode included), allow that IP. A bot challenge can't be solved by a server, so without the exception every delivery fails.

The symptom: we see the delivery failing and your application logs nothing, because your edge drops the request before it reaches your code. Check your WAF's event log before debugging the handler.

Warning: The IP doesn't replace verifying the signature. Allowing it only opens the way; the signature still proves who sent it.

4Respond in time and deduplicate

An attempt succeeds with 2xx in under 5 seconds; anything else is a failure. Respond as soon as the signature checks out and process afterwards.

The 8 delivery attempts of an event, all with the same X-Chytapay-Webhook-Id.

  • An attempt succeeds if your endpoint returns 2xx within 5 seconds.
  • If it fails, Chytapay retries 1 minute, 5 minutes, 15 minutes, 30 minutes, 1 hour, 3 hours and 6 hours after the previous attempt.
  • If the eighth fails, the event isn't sent again.

If all 8 attempts fail (about 11 hours), the event isn't sent again and there's no endpoint to resend it: query the charge with GET /payment-request/{referenceId}. Each attempt's delivery shows under Webhooks, Historial (History) tab.

Deduplicate by X-Chytapay-Webhook-Id

An event can arrive more than once, for example if you processed it but took over 5 seconds. Store the X-Chytapay-Webhook-Id with a unique index and drop any you already have.

  • It's the same across an event's retries.
  • It differs between events, even for the same charge or the same transfer (payment_received and unmatched).
  • It depends on the URL: if you change it with retries pending, they reach the new one with another id.

Warning: Don't deduplicate by referenceId + stateType. Two partial payments on the same charge share both: you'd drop the second payment.

Order

Order isn't guaranteed, especially with retries. Each attempt builds the body from the charge's state at that moment: treat stateType as the current state, not the arrival order.

Payload

Each event is a POST with a JSON body and these headers:

Header Contents
Content-Type application/json
X-Chytapay-Signature t=<unix>,v1=<hex>. See how to verify it.
X-Chytapay-Webhook-Id The event id, the same across retries. See deduplication.
validation-token Your token, or empty. See the validation-token.

Every event carries event (route on it first, because payment.unmatched has another shape) and livemode (true only for a real production event).

A partial payment with two due dates, a charge with a due time that expired unpaid, and an unattributed unmatched payment:

payment_request.payment_received

{
  "referenceId": "invoice-1001",
  "event": "payment_request.payment_received",
  "livemode": false,
  "requestedAmount": 25000,
  "paidAmount": 10000,
  "thisPaymentAmount": 10000,
  "reconciledPayerCuil": 20000000001,
  "reconciledPayerName": "Test Payer",
  "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": "booking-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": "Test Payer",
  "payerCbuCvu": "0000000000000000000001",
  "creditCvu": "0000000000000000000002"
}
See the payment_request.* fields

payment_request.* fields

Both payment_request.* events share a shape and reflect the charge when they're sent. Fields are always present unless noted:

Field What it is
referenceId The one you sent when creating the charge.
stateType The charge's state (table below).
requestedAmount The original amount, without surcharge.
paidAmount Paid so far.
thisPaymentAmount The payment that triggered the event. Only in payment_request.payment_received.
remainingAmount activeDueDateAmount - paidAmount, never negative.
activeDueDateIndex The active due date: 1 or 2.
activeDueDate Its date, in ISO UTC.
activeDueDateAmount Its amount, surcharge included when it applies.
totalDueDates How many due dates the charge has.
upcomingDueDateIndex, upcomingDueDate, upcomingDueDateAmount, upcomingAmountToPay The next due date, its amount and what would be left to pay. If there is one and the charge isn't paid off.
reconciledPayerName, reconciledPayerCbu Account holder and source account (CBU or CVU). In payments, when known.
reconciledPayerCuil The CUIL the payment was matched with. Only when reconciling by CUIL.
metadata As you sent it, if you did.

Amounts are in pesos with two decimals. A due date without a time is sent as the end of that day in Argentina time, in UTC. Optional fields are omitted, never null.

stateType Meaning
partial_payment Part was paid and a balance remains.
total_payment Paid off.
overdue Expired with no payments.
partial_overdue Expired with part paid.
pending, draft, canceled Valid states that don't generate events.

There's no PAID: full payment is total_payment. See the charge lifecycle.

Unmatched payments: payment.unmatched

The money is still credited to the merchant account: without this event, you don't find out. It arrives like the others, and you have to enable it.

Two shapes of the same event

reason is always present. The paymentRequest object (which charge the money was meant for) comes only when it could be attributed:

reason Includes paymentRequest? What happened
expired Yes The charge had already expired.
already_paid Yes The charge was already paid off.
canceled Yes The charge was canceled.
no_cuil_match No On a shared CVU, the payer's CUIL isn't any charge's payer. It happens when a third party pays.
no_pending No No charge, current or past, on that CVU.
ambiguous No More than one candidate charge. It doesn't depend on your integration.

See the attributed shape and its fields
JSON
{
  "event": "payment.unmatched",
  "livemode": false,
  "reason": "expired",
  "amount": 1234.56,
  "operationDate": "2026-10-01T15:04:05.000Z",
  "payerCuil": 20000000001,
  "payerName": "Test Payer",
  "payerCbuCvu": "0000000000000000000001",
  "creditCvu": "0000000000000000000002",
  "paymentRequest": {
    "referenceId": "booking-2002",
    "dueDate": "2026-10-01T18:30:00.000Z"
  }
}
  • Check whether paymentRequest exists to know if it's attributed. If not, it's absent (never null).
  • referenceId sits inside paymentRequest, never at the root, so a handler that ignores event doesn't mark a charge as paid. It can be missing if the charge was created without one; dueDate is always there.
  • amount is the money that came in, payerCbuCvu the source account (CBU or CVU) and creditCvu the collection CVU. payerCuil, payerName, payerCbuCvu and creditCvu are omitted when unknown.

Note: Correcting the payer doesn't match a no_cuil_match. PATCH /payment-request/{referenceId}/customer doesn't pick up money that already came in: don't expect a payment_request.payment_received, and don't charge again or refund automatically. Handling that payment goes through Chytapay support. See how to avoid third-party payers.

Two different events with "expired"

Tell them apart by event: payment_request.expired is a charge that expired with no money in; payment.unmatched with reason: "expired" is money that came in after the charge expired.

5Test your endpoint in Sandbox

In Sandbox you generate events without moving money. These tools don't exist in Production.

Test webhook

Under Webhooks, Probar el webhook (Test the webhook) sends a fictitious payment.unmatched, signed like a real one, to test your parser with each reason. If it doesn't arrive, check that payment.unmatched is enabled and that a URL is set. Each test carries a new X-Chytapay-Webhook-Id and livemode: false.

Payment simulator

A fictitious transfer that goes through real matching and fires the real event. In Simulador de pagos (Payment simulator), enter the CVU of one of your charges, the amount and the paying CUIL.

How to reproduce each event

The simulator accepts the CVU of any of your charges, even expired, canceled or paid:

To receive Simulate
payment_request.payment_received A payment on a pending charge.
expired A payment on a charge with a due time that already passed.
canceled A payment on a canceled charge.
already_paid A second payment on a paid-off charge.
no_cuil_match On a CUIL charge, a payment with a different source CUIL.