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_ |
A payment came in and was applied to a charge, partially or in full. |
payment_ |
A charge with a due date and time expired unpaid. |
payment. |
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:
- The charge's
webhookUrl, if it was created with one (it gets an emptyvalidation-token). Only forpayment_request.*:payment.unmatcheddoesn't use it. - The URL of the merchant account's location. If the location has none, the event isn't delivered.
- 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:
- Copy the signing secret (
whsec_...) under Configuración (Settings) in the Portal, next to the webhook URL, and store it, for example asCHYTAPAY_WEBHOOK_SECRET. - Read the raw body before parsing: if you serialize it again, the signature won't match.
- Split the
t=<unix>,v1=<hex>header intot(Unix seconds) andv1(hex HMAC). - Reject the event if
tis more than 300 seconds off your clock. Each retry carries its ownt. - Compute the HMAC-SHA256 of
{t}.{raw body}with the secret and compare it withv1in constant time. - 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 "", 200Expected 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_receivedandunmatched). - 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- |
application/ |
X- |
t=<unix>,v1=<hex>. See how to verify it. |
X- |
The event id, the same across retries. See deduplication. |
validation- |
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. |
state |
The charge's state (table below). |
requested |
The original amount, without surcharge. |
paid |
Paid so far. |
this |
The payment that triggered the event. Only in payment_. |
remaining |
active, never negative. |
active |
The active due date: 1 or 2. |
active |
Its date, in ISO UTC. |
active |
Its amount, surcharge included when it applies. |
total |
How many due dates the charge has. |
upcoming, upcoming, upcoming, upcoming |
The next due date, its amount and what would be left to pay. If there is one and the charge isn't paid off. |
reconciled, reconciled |
Account holder and source account (CBU or CVU). In payments, when known. |
reconciled |
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.
state |
Meaning |
|---|---|
partial_ |
Part was paid and a balance remains. |
total_ |
Paid off. |
overdue |
Expired with no payments. |
partial_ |
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 payment? |
What happened |
|---|---|---|
expired |
Yes | The charge had already expired. |
already_ |
Yes | The charge was already paid off. |
canceled |
Yes | The charge was canceled. |
no_ |
No | On a shared CVU, the payer's CUIL isn't any charge's payer. It happens when a third party pays. |
no_ |
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
{
"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
paymentRequestexists to know if it's attributed. If not, it's absent (nevernull). referenceIdsits insidepaymentRequest, never at the root, so a handler that ignoreseventdoesn't mark a charge as paid. It can be missing if the charge was created without one;dueDateis always there.amountis the money that came in,payerCbuCvuthe source account (CBU or CVU) andcreditCvuthe collection CVU.payerCuil,payerName,payerCbuCvuandcreditCvuare 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_ |
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_ |
A second payment on a paid-off charge. |
no_ |
On a CUIL charge, a payment with a different source CUIL. |