> Chytapay is a bank-transfer payments platform in Argentina. This page is part of the Integration API docs: https://chytapay.com.ar/api/
> Recommended reconciliation: by CUIL. Environments: test (https://integration-api.test.chytapay.com.ar) and production (https://integration-api.chytapay.com.ar).
> Page: "Webhooks" (https://chytapay.com.ar/api/en/webhooks/), updated on 2026-10-09.
> Full index for AI assistants: https://chytapay.com.ar/llms.txt

# Webhooks

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

&#xA;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](https://chytapay.com.ar/api/en/getting-started/).

1. [Set the URL](https://chytapay.com.ar/api/en/webhooks/#configurar-urls): in the Portal
2. [Choose the events](https://chytapay.com.ar/api/en/webhooks/#suscripcion): payment.unmatched is opt-in
3. [Verify the signature](https://chytapay.com.ar/api/en/webhooks/#firma): X-Chytapay-Signature
4. [Respond and deduplicate](https://chytapay.com.ar/api/en/webhooks/#entrega): 2xx within 5 s

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.

## 1. Set your webhook URL

It must be HTTPS and reachable from the internet: Chytapay rejects private or internal addresses. Set it under [**Configuración**](https://chytapay.com.ar/portal/settings) (Settings) in the Portal. The validation token is optional, at least 16 characters long: see [what it's for](https://chytapay.com.ar/api/en/webhooks/#validacion).

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

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](https://chytapay.com.ar/api/en/sedes/).

## 2. Choose 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**](https://chytapay.com.ar/portal/notifications) 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**.

## 3. Verify 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**](https://chytapay.com.ar/portal/settings) (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](https://chytapay.com.ar/api/en/webhooks/#entrega).

In your backend, verify, deduplicate and respond:

**Node**

```js
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**

```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](https://chytapay.com.ar/api/en/webhooks/#firma). Allowing it only opens the way; the signature still proves who sent it.

## 4. Respond 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**](https://chytapay.com.ar/portal/notifications), **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](https://chytapay.com.ar/api/en/webhooks/#firma).                        |
| `X-Chytapay-Webhook-Id` | The event id, the same across retries. See [deduplication](https://chytapay.com.ar/api/en/webhooks/#deduplicacion). |
| `validation-token`      | Your token, or empty. See [the validation-token](https://chytapay.com.ar/api/en/webhooks/#validacion).              |

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

&#xA;&#xA;

&#xA;A partial payment with two due dates, a charge with a due time that expired unpaid, and an unattributed [unmatched payment](https://chytapay.com.ar/api/en/webhooks/#pago-sin-imputar):

**payment_request.payment_received**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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"
}
```

### 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](https://chytapay.com.ar/api/en/crear-cobros/#ciclo-de-vida).

## 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](https://chytapay.com.ar/api/en/webhooks/#suscripcion).

### 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.                          |



```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](https://chytapay.com.ar/api/en/conciliacion/#pagador-tercero).

### 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.

## 5. Test your endpoint in Sandbox

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

### Test webhook

Under [**Webhooks**](https://chytapay.com.ar/portal/notifications), **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**](https://chytapay.com.ar/portal/simulate) (Payment simulator), enter the CVU of one of your charges, the amount and the paying CUIL.

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.  |

- [Connect merchant accounts](https://chytapay.com.ar/api/en/conectar-comercios/): OAuth, `redirect_uri` and token lifetime.
- [Reconciliation](https://chytapay.com.ar/api/en/conciliacion/): Why CUIL, third-party payers and `no_cuil_match`.
- [Locations](https://chytapay.com.ar/api/en/sedes/): One webhook URL per location.
- [Operations and errors](https://chytapay.com.ar/api/en/operacion/): Environments, limits and going live.
