# Webhooks (/en/docs/pix-processamento/webhooks)

<QuickLinks>
  <QuickLink href="/docs/pix-processamento/endpoints/webhooks/post_user_webhook" title="Create webhook" />

  <QuickLink href="/docs/pix-processamento/endpoints/callbacks/get_user_callbacks" title="List callbacks" />
</QuickLinks>

## What is a webhook (callback) [#what-is-a-webhook-callback]

A **webhook** (also called a **callback**) is a `POST` request that **PayZu sends to your server** when something happens. Unlike the normal API (where you call PayZu), here it's the opposite: PayZu calls you.

Think about a Pix charge. You created it, displayed the QR to the customer, and now you need to know when the customer pays. Two options:

1. **Polling**, asking every X seconds "did they pay yet? did they pay yet?" (costly, slow, unnecessary).
2. **Webhook**, letting PayZu notify you as soon as the payment comes in (instant, efficient, recommended).

<Mermaid
  chart="`
sequenceDiagram
  participant App as Your application
  participant PZ as PayZu
  participant Banco as Customer's bank

  App->>PZ: POST /pix with callbackUrl
  PZ-->>App: id, qrCodeText, status PENDING
  Banco->>PZ: Customer pays
  PZ->>App: POST callbackUrl status COMPLETED
  App-->>PZ: HTTP 200 OK
`"
/>

## How to configure [#how-to-configure]

You can receive notifications in two ways:

* **Registered webhook** (recommended): register a persistent URL at [`POST /user/webhooks`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook), with HMAC secret and event selection. The same URL applies to all transactions.
* **Per-transaction `callbackUrl`**: provide the URL in the `callbackUrl` field of the body on each created transaction:

```json
{
  "amount": 99.90,
  "callbackUrl": "https://yoursite.com/webhooks/payzu",
  "clientReference": "pedido-2025-001"
}
```

PayZu will send the callback to this URL **every time that transaction changes status** (PENDING → COMPLETED, COMPLETED → REFUNDED, etc).

<Steps>
  <Step>
    ### Create a public endpoint on your server [#create-a-public-endpoint-on-your-server]

    Somewhere accessible over the internet that accepts `POST` with JSON. Examples: `https://yoursite.com/webhooks/payzu`, `https://api.yourcompany.com/payzu/callback`.

    During local development, use tunnels like [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to expose `localhost`.
  </Step>

  <Step>
    ### Pass the URL when creating the transaction [#pass-the-url-when-creating-the-transaction]

    In every `POST /pix`, `POST /withdraw`, `POST /internal-transfer`, include the `callbackUrl` field. It can be the same URL for all.
  </Step>

  <Step>
    ### Implement the handler [#implement-the-handler]

    Receive the `POST`, read the JSON, process it, and respond `2xx` within 5 seconds. See examples in [Receive Pix · step 3](/docs/pix-processamento/tutoriais/receive-pix#receive-callback-when-paid).
  </Step>
</Steps>

<Callout type="info">
  PayZu sends `Content-Type: application/json`. The other delivery headers are
  in [Delivery headers](#delivery-headers).
</Callout>

## Events [#events]

The `events` field of [`POST /user/webhooks`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook)
defines which changes trigger the notification. Leave it empty to receive all.

Seven events track the transaction's `status`, one for each value:

| Event                            | Triggers when                                                                                                                      | `status` in payload  |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| `TRANSACTION_PENDING`            | The charge was created and awaits payment, or the Pix payment entered processing. Internal transfer does not go through `PENDING`. | `PENDING`            |
| `TRANSACTION_COMPLETED`          | The payment was confirmed. For a deposit, the customer paid; for a Pix payment, the money went out.                                | `COMPLETED`          |
| `TRANSACTION_CANCELED`           | The transaction was canceled before completing, by manual action or by rule.                                                       | `CANCELED`           |
| `TRANSACTION_WAITING_FOR_REFUND` | The refund entered the processing queue, usually after an accepted MED.                                                            | `WAITING_FOR_REFUND` |
| `TRANSACTION_REFUNDED`           | The amount was returned to the payer.                                                                                              | `REFUNDED`           |
| `TRANSACTION_EXPIRED`            | The charge passed `expiresIn` without being paid.                                                                                  | `EXPIRED`            |
| `TRANSACTION_ERROR`              | The transaction failed in processing.                                                                                              | `ERROR`              |

<Callout type="warn">
  If the transaction's `status` changes before the queue processes the event, the delivery
  of the registered webhook is discarded: there is no send, no history record, no retry. In
  fast Pix, `TRANSACTION_PENDING` typically doesn't arrive, so don't require a prior event
  to accept `TRANSACTION_COMPLETED`.
</Callout>

Three events don't mirror the `status`:

| Event                                  | Triggers when                                                                                                                                                                                                                                                                         |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INFRACTION_CHANGED`                   | A [MED infraction](/docs/pix-processamento/med) linked to one of your transactions was opened, had its status changed, or was closed. The body is the transaction's, with the `infraction` object attached: the transaction id comes in `id` and the infraction's in `infraction.id`. |
| `TRANSACTION_SUSPECTED_FRAUD`          | Reserved. No service emits this event today.                                                                                                                                                                                                                                          |
| `TRANSACTION_SUSPECTED_FRAUD_REVERSAL` | Reserved. No service emits this event today.                                                                                                                                                                                                                                          |

<Callout type="info">
  The two suspected-fraud events can be subscribed to, but no delivery is generated
  for them today. A webhook that subscribes only to these two receives nothing.
</Callout>

## Retry system [#retry-system]

PayZu webhooks have a robust retry system that guarantees
delivery even under temporary failures. PayZu resends the same
callback **up to 40 times** with exponential backoff and jitter, better
distributing the load and avoiding request spikes.

<Mermaid
  chart="`
flowchart TD
  A[&#x22;Status change on transaction&#x22;]
  A --> B[&#x22;PayZu sends POST callbackUrl&#x22;]
  B --> C{&#x22;2xx response within 5s?&#x22;}
  C -->|Yes| D[&#x22;Delivery confirmed&#x22;]
  C -->|No| E[&#x22;Wait exponential backoff + jitter&#x22;]
  E --> F{&#x22;Attempt less than 40?&#x22;}
  F -->|Yes| B
  F -->|No| G[&#x22;Marked as definitive failure&#x22;]

  click D &#x22;/en/docs/pix-processamento/best-practices/idempotency&#x22; &#x22;Callback idempotency&#x22;
  click G &#x22;/en/docs/pix-processamento/endpoints/callbacks/resend_user_callback_single&#x22; &#x22;Resend manually&#x22;

  style A fill:#f59e0b,stroke:#d97706,color:#ffffff
  style D fill:#14ce71,stroke:#0eb464,color:#ffffff
  style G fill:#ef4444,stroke:#dc2626,color:#ffffff
`"
/>

<Callout type="warn">
  **Response time:** the webhook must respond with a `2xx` (for example
  `200` or `204`) within **5 seconds**. A response outside the `2xx` range, including
  `4xx`, and timeout enter the retry cycle just the same.
</Callout>

## Security [#security]

To ensure integrity and security, **restrict access** to your
webhook endpoint. Request the official PayZu Processamento IP from
support and only accept callbacks from that origin.

## Delivery headers [#delivery-headers]

| Header                 | Value                                                                                                                  |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`         | `application/json`                                                                                                     |
| `User-Agent`           | `Callback-Service/1.0`                                                                                                 |
| `X-Callback-Attempt`   | Attempt number of this delivery.                                                                                       |
| `X-Callback-Event`     | The event that triggered the delivery. Only present in registered webhooks.                                            |
| `X-Callback-Signature` | HMAC signature. Present whenever the delivery has a secret: the registered webhook's or the account's callback secret. |

`INFRACTION_CHANGED` arrives with the transaction's `status` unchanged, so
`X-Callback-Event` is what distinguishes the deliveries when you subscribe to more than one event.

## HMAC verification [#hmac-verification]

Every signed delivery carries `X-Callback-Signature`. Which secret signs depends on the destination:

| Delivery destination        | Signing secret          | Where to create                                                                                                                                                                                                                            |
| --------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Registered webhook          | Webhook secret          | `generateSecret: true` in [`POST /user/webhooks`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook), or [`POST /user/webhooks/{id}/rotate-secret`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook_rotate_secret) |
| Transaction's `callbackUrl` | Account callback secret | `POST /v1/user/callbacks/secret`, rotated via `PATCH /v1/user/callbacks/secret/rotate`                                                                                                                                                     |

<Callout type="warn">
  Delivery to the transaction's `callbackUrl` is only signed if the account has a callback
  secret registered. Without this secret there is no signature: create the secret or
  protect the endpoint by source IP.
</Callout>

Validate the signature before processing the body:

<Steps>
  <Step>
    Read the `X-Callback-Signature` header. The value comes as `t=<timestamp>, v1=<signature>`,
    with the send time in seconds (Unix) and the signature in 64-character hexadecimal.
  </Step>

  <Step>
    Build the base string by concatenating the timestamp and the raw request body, separated
    by `.`, forming `<timestamp>.<body>`.
  </Step>

  <Step>
    Generate an HMAC SHA-256 of this string using the destination's secret, the webhook's or
    the account's callback secret, and compare with the `v1` value in constant time. If
    they don't match, reject the delivery.
  </Step>

  <Step>
    Also reject timestamps outside a tolerance window. Each attempt is signed
    at send time, so a retry's timestamp is always recent.
  </Step>
</Steps>

Node.js example, using `crypto.timingSafeEqual` to compare signatures in constant time:

```js
const crypto = require("node:crypto");

const TOLERANCE_SECONDS = 300;

function verifyCallbackSignature(request, webhookSecret) {
  const header = request.headers["x-callback-signature"];
  if (typeof header !== "string") return false;

  const parts = Object.fromEntries(
    header.split(",").map((part) => part.trim().split("=")),
  );
  const timestamp = Number(parts.t);
  const signature = parts.v1;

  if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/i.test(signature ?? "")) {
    return false;
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (age > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", webhookSecret)
    .update(`${timestamp}.${request.rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signature, "hex"),
  );
}
```

<Callout type="info">
  Compute the HMAC over the raw request body, exactly as received, before any JSON parsing.
</Callout>

## Payload fields [#payload-fields]

### Identification [#identification]

| Field             | Type   | Description                                                                                                     |
| ----------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| `id`              | string | Transaction ID                                                                                                  |
| `clientReference` | string | External reference you provided                                                                                 |
| `virtualAccount`  | string | Virtual subaccount (up to 50 characters). Returned in the callback to correlate stores, branches, marketplaces. |
| `callbackUrl`     | string | URL configured to receive this webhook                                                                          |

### Status and amounts [#status-and-amounts]

| Field               | Type   | Description                                                                              |
| ------------------- | ------ | ---------------------------------------------------------------------------------------- |
| `status`            | string | `PENDING`, `COMPLETED`, `CANCELED`, `WAITING_FOR_REFUND`, `REFUNDED`, `EXPIRED`, `ERROR` |
| `type`              | string | `DEPOSIT`, `WITHDRAW`, `COMMISSION`                                                      |
| `method`            | string | `PIX`, `BANK_SLIP`, `INTERNAL_TRANSFER`                                                  |
| `amount`            | number | Amount in BRL                                                                            |
| `serviceFeeCharged` | number | Fee charged                                                                              |

`COMMISSION` identifies a commission entry credited to your account and arrives with `TRANSACTION_COMPLETED`.

### Generated charge (deposit) [#generated-charge-deposit]

| Field               | Type   | Description                     |
| ------------------- | ------ | ------------------------------- |
| `qrCodeText`        | string | Pix copy-and-paste code         |
| `qrCodeUrl`         | string | QR Code image URL               |
| `qrCodeBase64`      | string | QR Code image in Base64 format  |
| `generatedName`     | string | Reference name                  |
| `generatedDocument` | string | CPF or CNPJ                     |
| `generatedEmail`    | string | Email linked to the transaction |

### Payer [#payer]

| Field                  | Type   | Description                                                                                                                |
| ---------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- |
| `payerName`            | string | Payer's name                                                                                                               |
| `payerDocument`        | string | Payer's document                                                                                                           |
| `payerInstitutionIspb` | string | ISPB of the payer's bank                                                                                                   |
| `payerInstitutionName` | string | Name of the payer's bank                                                                                                   |
| `payerAccountNumber`   | string | Payer's PayZu account (6 digits). Filled in when the PayZu account is the one paying: Pix payments and internal transfers. |

### Receiver [#receiver]

| Field                     | Type   | Description                                                                                                                   |
| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `receiverName`            | string | Recipient's name                                                                                                              |
| `receiverDocument`        | string | Recipient's document                                                                                                          |
| `receiverInstitutionIspb` | string | ISPB of the recipient's bank                                                                                                  |
| `receiverInstitutionName` | string | Name of the recipient's bank                                                                                                  |
| `receiverAccountNumber`   | string | Recipient's PayZu account (6 digits). Filled in when the PayZu account is the one receiving: deposits and internal transfers. |

### Pix payment via key [#pix-payment-via-key]

| Field             | Type   | Description                            |
| ----------------- | ------ | -------------------------------------- |
| `withdrawPixKey`  | string | Pix key used in the payment            |
| `withdrawPixType` | string | `cpf`, `cnpj`, `phone`, `email`, `evp` |

### Settlement and refund [#settlement-and-refund]

| Field                | Type   | Description                        |
| -------------------- | ------ | ---------------------------------- |
| `endToEndId`         | string | Pix EndToEnd ID                    |
| `paidAt`             | string | Payment timestamp (ISO 8601)       |
| `cancellationReason` | string | Cancellation reason                |
| `refundEndToEndId`   | string | Refund EndToEnd ID                 |
| `refundAmount`       | string | Refunded amount                    |
| `refundStatus`       | string | `PENDING`, `COMPLETED`, `CANCELED` |
| `refundReason`       | string | Refund reason                      |
| `refundDescription`  | string | Refund description                 |
| `refundedAt`         | string | Refund timestamp (ISO 8601)        |

### Timestamps [#timestamps]

| Field       | Type   | Description                   |
| ----------- | ------ | ----------------------------- |
| `createdAt` | string | Creation timestamp (ISO 8601) |
| `updatedAt` | string | Update timestamp (ISO 8601)   |

### Infraction (Pix dispute) [#infraction-pix-dispute]

| Field        | Type   | Description                                                             |
| ------------ | ------ | ----------------------------------------------------------------------- |
| `infraction` | object | Infraction details when opened (see [MED](/docs/pix-processamento/med)) |

## Best practices [#best-practices]

* **Respond fast**: return `2xx` in under 5s. Do heavy processing in
  a queue/worker, not in the handler.
* **Idempotency**: dedupe by `id` plus the event, not just by `id` + `status`.
  The same callback may arrive more than once (retry, successive changes), and
  `INFRACTION_CHANGED` doesn't change the `status`. See [Callback dedupe](/docs/pix-processamento/best-practices/idempotency#dedupe-de-callbacks).
* **Use `clientReference`**: pass an external identifier when creating the
  transaction. It comes back in the callback and makes it easier to correlate with your order.
* **Restrict by IP**: only accept callbacks from PayZu's official IP.
* **Respond `2xx` to end delivery**: any response outside the `2xx` range,
  including `4xx`, and any timeout enter the same cycle of up to 40 attempts. To
  stop the resend, respond `2xx` and handle the error on your side.
* **Mask `payerDocument` in logs**: printing the payload without masking
  personal data is an LGPD risk.

## Test and resend [#test-and-resend]

### Test locally [#test-locally]

Expose your localhost via [ngrok](https://ngrok.com) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) and trigger the payload manually:

<Tabs items="['curl', 'Node.js', 'Python']">
  <Tab value="curl">
    ```bash
    curl -X POST https://your-tunnel.ngrok.io/webhooks/payzu \
      -H "Content-Type: application/json" \
      -d '{
        "id": "PAYZU20260811K7M2X9QP4T000000",
        "type": "DEPOSIT",
        "status": "COMPLETED",
        "amount": 99.90,
        "clientReference": "order-1234",
        "virtualAccount": "loja-rj-01",
        "paidAt": "2026-08-11T10:46:26.986Z"
      }'
    ```
  </Tab>

  <Tab value="Node.js">
    ```ts
    await fetch('https://your-tunnel.ngrok.io/webhooks/payzu', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: 'PAYZU20260811K7M2X9QP4T000000',
        type: 'DEPOSIT',
        status: 'COMPLETED',
        amount: 99.90,
        clientReference: 'order-1234',
        virtualAccount: 'loja-rj-01',
        paidAt: '2026-08-11T10:46:26.986Z',
      }),
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import requests

    requests.post(
        'https://your-tunnel.ngrok.io/webhooks/payzu',
        headers={'Content-Type': 'application/json'},
        json={
            'id': 'PAYZU20260811K7M2X9QP4T000000',
            'type': 'DEPOSIT',
            'status': 'COMPLETED',
            'amount': 99.90,
            'clientReference': 'order-1234',
            'virtualAccount': 'loja-rj-01',
            'paidAt': '2026-08-11T10:46:26.986Z',
        },
    )
    ```
  </Tab>
</Tabs>

### Resend a real callback [#resend-a-real-callback]

The resend endpoint depends on where the destination URL is configured.

**`callbackUrl` provided in the transaction:**

* [`POST /user/callbacks/resend/{transactionId}`](/docs/pix-processamento/endpoints/callbacks/resend_user_callback_single), one transaction
* [`POST /user/callbacks/resend`](/docs/pix-processamento/endpoints/callbacks/resend_user_callbacks), batch by filter, with mandatory date window

Both only reach transactions with `callbackUrl` filled in and do not generate delivery for a registered webhook.

**Registered webhook:**

* [`POST /user/callbacks/resend/webhook/{webhookId}`](/docs/pix-processamento/endpoints/callbacks/resend_user_callbacks_webhook), requeues the failed deliveries on that webhook

The webhook resend reprocesses one delivery per pair of transaction and event, and considers a failure any response from `300` on and the absence of a response. An event whose `status` no longer matches the transaction's current one is still discarded on resend.

The response comes in `enqueued`, with `count` (total accepted for resend), `truncated`, and `items`. The `items` list stops at 500 entries. When `truncated` is `true`, the resend still covers all `count`, only the response's list was cut.

<Callout type="warn">
  `200` means accepted for resend, not queued delivery: queuing happens
  after the response. And the route no longer responds `200` with `count: 0`. Without an active webhook
  for the `{webhookId}` it responds `404 PZW300`, and without a failed callback in the period,
  `404 PZW310`.
</Callout>

### Inspect the history [#inspect-the-history]

PayZu stores all delivery attempts. Useful for investigating failures:

* [`GET /user/callbacks`](/docs/pix-processamento/endpoints/callbacks/get_user_callbacks), paginated list
* [`GET /user/callbacks/{id}`](/docs/pix-processamento/endpoints/callbacks/get_user_callback_by_id), detail with status code, response body, response time

## Next steps [#next-steps]

<QuickLinks>
  <QuickLink href="/docs/pix-processamento/best-practices/idempotency" title="Idempotency" />

  <QuickLink href="/docs/pix-processamento/best-practices/security" title="Security" />
</QuickLinks>