> ## Documentation Index
> Fetch the complete documentation index at: https://devs.tapipay.la/llms.txt
> Use this file to discover all available pages before exploring further.

# Payment notification

> The webhook TapiPay uses to tell you a payment was confirmed, failed or reversed.

You implement this endpoint **yourself**, in your backend. TapiPay calls it every time a payment changes state, so you can update the debt in your system without polling the API.

<Warning>
  It is the **only** reliable source for a payment's status. The `paymentSubmitted` event from the [SDK](/en/payment-portal/payment-methods-sdk#events) means the user submitted the form, not that the payment settled. Only mark a debt as paid when this notification arrives with `status: "confirmed"`.
</Warning>

<Note>
  This notification is **not sent by the Facade API**, which does not implement webhooks. It is sent by the **referenced payments** platform, and it is configured **at the platform level during onboarding** with the TapiPay team: there is no endpoint to create or change the URL. The requests come from that platform's IPs, not from the Facade API host, and they are handed to you during onboarding.
</Note>

## Configuration

You define the **full URL** (host and path) TapiPay will send notifications to. You provide two:

| Environment | Purpose              |
| ----------- | -------------------- |
| Staging     | Integration testing. |
| Production  | Real transactions.   |

TapiPay does not impose a route. Any reachable path works: `/api/webhooks/confirm-payment`, `/tapi/callback`, `/notifications`, `/payment/status`.

### Requirements for your endpoint

<ResponseField name="Reachable" required>
  Reachable from the TapiPay source IPs handed to you during onboarding.
</ResponseField>

<ResponseField name="2xx response" required>
  Return any 2xx HTTP code to acknowledge receipt. **TapiPay does not read your response body**, only the status code, so you can respond empty.
</ResponseField>

<ResponseField name="Under 5 seconds" required>
  If you take longer, the request is treated as failed and enters retries. If you need heavy processing, queue it and respond first.
</ResponseField>

## The payload

`POST` with `Content-Type: application/json` to the endpoint you defined.

| Field               | Type   | Present | Description                                                                                                                                                                               |
| ------------------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `operationId`       | String | Always  | Operation ID in TapiPay. **Use it as your idempotency key.**                                                                                                                              |
| `status`            | String | Always  | `confirmed` (successful), `failed` (error) or `reversed` (later reversal).                                                                                                                |
| `amount`            | Number | Always  | Amount charged.                                                                                                                                                                           |
| `externalPaymentId` | String | Always  | Your payment operation ID, for traceability.                                                                                                                                              |
| `externalClientId`  | String | Always  | Your end user identifier.                                                                                                                                                                 |
| `externalRequestId` | String | Always  | Your identifier for the related debt. This is the field you use to correlate the notification against your system.                                                                        |
| `clientUsername`    | String | Always  | Your client's username in TapiPay.                                                                                                                                                        |
| `companyCode`       | String | Always  | Company code.                                                                                                                                                                             |
| `companyName`       | String | Always  | Company name.                                                                                                                                                                             |
| `paymentMethod`     | String | Always  | Method used to pay (for example, `TRANSFER` or `CARD`). See [payment methods](/en/concepts/payment-methods).                                                                              |
| `type`              | String | Always  | Notification type.                                                                                                                                                                        |
| `createdAt`         | String | Always  | Operation creation time, in ISO 8601.                                                                                                                                                     |
| `updatedAt`         | String | Always  | Operation last update time, in ISO 8601.                                                                                                                                                  |
| `additionalData`    | Object | Always  | Dynamic object with the optional fields loaded when the debt was created. See below.                                                                                                      |
| `hash`              | String | Varies  | Security hash, tied to the encrypted hash mechanism. Do not assume it is present: check it has a value before using it, and confirm with TapiPay whether your integration has it enabled. |

### `additionalData` is dynamic

<Warning>
  `additionalData` forwards **exactly** the optional fields loaded when the debt was created, so its keys vary between clients and between services. **There is no guaranteed set of fields.** Check each field exists before using it: do not assume any of them will be present.
</Warning>

### Example

The fields inside `additionalData` are **illustrative**: they show what a client might send, not a contract.

```json theme={null}
{
  "operationId": "1620990a-d124-4e1a-8dcd-7d8402824f37",
  "clientUsername": "miempresa.prod.mx",
  "status": "confirmed",
  "externalPaymentId": "8c06bb4a-f1a6-4dcf-859a-c71399e6d642",
  "externalClientId": "CLI-00042",
  "externalRequestId": "INV-2026-001",
  "createdAt": "2026-06-21T23:41:55.661Z",
  "updatedAt": "2026-06-21T23:41:55.661Z",
  "companyCode": "MX-S-12345",
  "companyName": "ACME",
  "amount": 350.00,
  "hash": null,
  "type": "SERVICE",
  "paymentMethod": "TRANSFER",
  "additionalData": {
    "referenceCode": "00001234567890",
    "amountType": "OPEN",
    "amount": 350.00,
    "description": "Service payment",
    "allowPartialPayments": true,
    "recurringDebt": false,
    "overduePayment": true,
    "debtReference": "00001234567890",
    "debtExpirationDate": "2026-06-30"
  }
}
```

## Validating the request comes from TapiPay

Your endpoint is public, so anyone can call it. TapiPay offers **five mechanisms**, configured during onboarding. You can enable one or combine several.

| Mechanism                    | How it works                                                           | What you validate                                              |
| ---------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- |
| **API Key**                  | TapiPay sends an `x-api-key` header with a secret key.                 | Header presence and value.                                     |
| **Bearer token (JWT)**       | TapiPay sends a JWT in `Authorization: Bearer ...`.                    | The token signature.                                           |
| **IP whitelist**             | Requests come from the source IPs handed to you.                       | That the source IP is on your list.                            |
| **mTLS certificate (X.509)** | Mutual TLS: TapiPay presents a client certificate.                     | The certificate against your CA.                               |
| **Encrypted hash**           | TapiPay includes a `hash` in the payload, generated with a shared key. | That the hash matches, confirming the payload was not altered. |

<Tip>
  Combine at least two, for example API Key plus IP whitelist, or Bearer token plus mTLS. A single header based mechanism is vulnerable if the key leaks.
</Tip>

## Retries and idempotency

<Warning>
  **TapiPay retries if your endpoint fails.** That means you can receive the same notification more than once. Implement idempotency by `operationId` or you will process the same payment twice.
</Warning>

The right order in your handler is: validate the origin, respond 2xx quickly, and only then process.

```javascript theme={null}
import express from "express";

const app = express();

app.post("/tapi/notifications", express.json(), async (req, res) => {
  // 1. Validate the origin before looking at the body
  if (req.header("x-api-key") !== process.env.TAPI_WEBHOOK_KEY) {
    return res.sendStatus(401);
  }

  const { operationId, status, amount, externalRequestId } = req.body;

  // 2. Respond before any heavy work: the limit is 5 seconds
  res.sendStatus(200);

  // 3. Idempotency by operationId
  if (await alreadyProcessed(operationId)) return;
  await markProcessed(operationId);

  // 4. Now the real processing
  await enqueue({ operationId, status, amount, externalRequestId });
});
```

<Note>
  `alreadyProcessed` and `markProcessed` must rely on **persistent** storage (a table with a unique `operationId`, or Redis), not process memory: if you restart the server between the original attempt and the retry, an in-memory mark is lost and the payment is processed again.
</Note>

## How to test it

TapiPay cannot reach your `localhost`, so you need a public URL. Two approaches, depending on what you want to verify.

<Tabs>
  <Tab title="Inspect the payload">
    To see the real payload without writing code, use a public receiver such as [webhook.site](https://webhook.site) and provide that URL as your staging endpoint. Every notification is logged with its headers and body.

    This is how you confirm what arrives in `additionalData` for your specific case, which is the part that cannot be documented in advance.

    <Warning>
      Staging with test data only. A public receiver exposes the payload to anyone holding the URL: never point production at it.
    </Warning>
  </Tab>

  <Tab title="Test your code">
    To exercise your real handler, expose your local server through a tunnel:

    ```bash theme={null}
    # with cloudflared
    cloudflared tunnel --url http://localhost:3000

    # or with ngrok
    ngrok http 3000
    ```

    Provide the tunnel's public URL (plus your path) as the staging endpoint. Note that the URL changes on every tunnel restart unless you use a fixed domain.
  </Tab>
</Tabs>

While you wait for TapiPay to fire a real notification, you can exercise your handler with the example payload from this page:

```bash theme={null}
curl --request POST \
  --url 'http://localhost:3000/tapi/notifications' \
  --header 'Content-Type: application/json' \
  --header 'x-api-key: YOUR_WEBHOOK_KEY' \
  --data '{
    "operationId": "1620990a-d124-4e1a-8dcd-7d8402824f37",
    "status": "confirmed",
    "amount": 350.00,
    "externalRequestId": "INV-2026-001",
    "externalClientId": "CLI-00042",
    "paymentMethod": "TRANSFER",
    "additionalData": {}
  }'
```

Call it **twice with the same `operationId`**: it is the fastest way to confirm your idempotency works before going to production.
