# Webhooks (/en/docs/conta-digital/webhooks)

<QuickLinks>
  <QuickLink href="/docs/conta-digital/endpoints/webhooks/post_webhook" title="Register webhook endpoint" method="POST" path="/transactions/webhooks" />

  <QuickLink href="/docs/conta-digital/endpoints/webhooks/put_webhook" title="Update webhook endpoint" method="PUT" path="/transactions/webhooks/{webhookId}" />

  <QuickLink href="/docs/conta-digital/endpoints/webhooks/post_callback_secret" title="Issue callback secret" method="POST" path="/transactions/callback-secret" />
</QuickLinks>

Webhooks arrive through two paths, which can be used together. With a registered endpoint and a `callbackUrl` on the operation, the webhook arrives at both.

| Path                                              | Receives                      | Secret that signs                         |
| ------------------------------------------------- | ----------------------------- | ----------------------------------------- |
| [Registered endpoint](#registered-endpoint)       | The events chosen in `events` | The endpoint's `secret` (`whsec_…`)       |
| [Operation `callbackUrl`](#operation-callbackurl) | All events of that operation  | The account's callback secret (`cbsec_…`) |

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

  App->>PZ: POST /transactions/payment
  PZ-->>App: 201, status PENDING
  Banco->>PZ: Pix paid
  PZ->>App: POST to endpoint, signed PAYMENT_PAID
  App-->>PZ: 2xx within 10 s
`"
/>

## Registered endpoint [#registered-endpoint]

Register the URL and the events with [`POST /transactions/webhooks`](/docs/conta-digital/endpoints/webhooks/post_webhook), scope `WEBHOOK_WRITE`, or in the dashboard.

<Tabs items="['curl', 'Node.js']">
  <Tab value="curl">
    ```bash
    curl -X POST https://api.hub.payzu.com.br/api/v1/transactions/webhooks \
      -H "Authorization: Bearer $PAYZU_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://sualoja.com.br/webhooks/payzu",
        "events": ["PAYMENT_PAID", "PAYMENT_EXPIRED", "WITHDRAW_COMPLETED", "WITHDRAW_FAILED"]
      }'
    ```
  </Tab>

  <Tab value="Node.js">
    ```ts
    const res = await fetch('https://api.hub.payzu.com.br/api/v1/transactions/webhooks', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.PAYZU_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        url: 'https://sualoja.com.br/webhooks/payzu',
        events: ['PAYMENT_PAID', 'PAYMENT_EXPIRED', 'WITHDRAW_COMPLETED', 'WITHDRAW_FAILED'],
      }),
    });
    const endpoint = await res.json();
    ```
  </Tab>
</Tabs>

* The URL must be HTTPS, public and up to 2048 characters long. It cannot repeat the URL of another endpoint on the account.
* Send at least one event. The endpoint starts active.
* Store the `secret` from the response: it appears only there. In the dashboard, the new secret button generates another one, and the previous one stops working right away.
* To stop receiving, send `isActive: false` to [`PUT /transactions/webhooks/{webhookId}`](/docs/conta-digital/endpoints/webhooks/put_webhook). An endpoint that already had a delivery cannot be deleted: the `DELETE` responds `409` `WEBHOOK_HAS_DELIVERIES`.

## Operation `callbackUrl` [#operation-callbackurl]

Send `callbackUrl` in the body of the charge, the withdrawal, the Pix copy-and-paste payment or the transfer to receive all webhooks of that operation. Here you do not choose events.

* First, issue the account's callback secret with [`POST /transactions/callback-secret`](/docs/conta-digital/endpoints/webhooks/post_callback_secret). Without it, an operation with `callbackUrl` is refused with `412` `CALLBACK_SECRET_MISSING`.
* The secret appears only in that response. [`GET /transactions/callback-secret`](/docs/conta-digital/endpoints/webhooks/get_callback_secret) only says whether it exists, and [`POST /transactions/callback-secret/rotate`](/docs/conta-digital/endpoints/webhooks/post_callback_secret_rotate) generates another one; the previous one stops working right away.
* The URL follows the endpoint rules: HTTPS, public, up to 2048 characters.
* All events of the operation arrive, including refund and dispute, except `WITHDRAW_REFUND_RECEIVED`, which goes only to registered endpoints.
* In a transfer, the URL belongs to the sender: the `INTERNAL_TRANSFER_RECEIVED`, from the destination account, does not go to it.
* The URL is fixed at creation. Repeating the operation with the same `externalRef` or `Idempotency-Key` and another `callbackUrl` returns the original operation, with the original URL.
* The operation's response carries the accepted `callbackUrl`. A field with another name, such as `callback_url`, is ignored, and it comes back `null`.

## Events [#events]

The body of each event, field by field, is on its own page.

| Event                                                                                                           | When it arrives                                                                                                               |
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| [`PAYMENT_CREATED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_created)                       | The charge was registered and the QR code exists. It is not a payment yet.                                                    |
| [`PAYMENT_PAID`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_paid)                             | The charge was paid. Release the order here.                                                                                  |
| [`PAYMENT_REFUNDED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_refunded)                     | The bank refunded the payment received on its own. The full amount leaves the account, and the fee does not come back.        |
| [`PAYMENT_EXPIRED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_expired)                       | The charge expired without payment.                                                                                           |
| [`WITHDRAW_CREATED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_created)                     | The withdrawal or the Pix copy-and-paste payment was requested, and the amount and the fee left the available balance.        |
| [`WITHDRAW_COMPLETED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_completed)                 | The money reached the destination.                                                                                            |
| [`WITHDRAW_FAILED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_failed)                       | The withdrawal failed, and the amount and the fee came back to the balance.                                                   |
| [`REFUND_COMPLETED`](/docs/conta-digital/endpoints/webhook-events/webhook_refund_completed)                     | The refund requested through the API, the dashboard or support was completed: the amount went back to the payer.              |
| [`REFUND_FAILED`](/docs/conta-digital/endpoints/webhook-events/webhook_refund_failed)                           | The refund was refused, and the amount came back to the balance.                                                              |
| [`DEPOSIT_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_deposit_received)                     | A Pix landed on one of the account's keys without a charge. The amount is already credited.                                   |
| [`INTERNAL_TRANSFER_SENT`](/docs/conta-digital/endpoints/webhook-events/webhook_internal_transfer_sent)         | The account sent a transfer.                                                                                                  |
| [`INTERNAL_TRANSFER_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_internal_transfer_received) | The account received a transfer.                                                                                              |
| [`WITHDRAW_REFUND_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_refund_received)     | Whoever received a Pix from the account returned the amount, in full or in part.                                              |
| [`INFRACTION_OPENED`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_opened)                   | A MED dispute was opened against the account.                                                                                 |
| [`INFRACTION_CLOSED`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_closed)                   | The dispute was closed or cancelled. The body's `status` says which.                                                          |
| [`INFRACTION_DEADLINE`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_deadline)               | The dispute's response deadline is approaching: 48, 24 or 6 hours left.                                                       |
| [`ACCOUNT_BLOCKED`](/docs/conta-digital/endpoints/webhook-events/webhook_account_blocked)                       | The bank blocked operations on the account, or the list of blocks changed. `blockedOperations` says what the API will refuse. |
| [`ACCOUNT_UNBLOCKED`](/docs/conta-digital/endpoints/webhook-events/webhook_account_unblocked)                   | The blocks were lifted.                                                                                                       |

A full refund requested through the API takes the charge to `REFUNDED`, but the webhook is `REFUND_COMPLETED`, not `PAYMENT_REFUNDED`.

## Request [#request]

```http
POST /webhooks/payzu
Content-Type: application/json
X-Payzu-Event: PAYMENT_PAID
X-Payzu-Delivery: cmu1r7x2k000a01s6h4f2b9qd
X-Payzu-Timestamp: 1791210790441
X-Payzu-Signature: sha256=8f3b2c1d...
```

```json
{
  "event": "PAYMENT_PAID",
  "id": "cmu1r7x2k000a01s6h4f2b9qd",
  "sentAt": "2026-10-05T14:33:10.441Z",
  "accountId": "cmu0z8k2a000001s6acct0001",
  "data": {
    "paymentId": "cmu2wbljx0000e8gtlic8q1gi",
    "status": "PAID",
    "amount": 1500,
    "serviceFee": 105,
    "netAmount": 1395,
    "metadata": { "pedido": "4821", "canal": "checkout-web" },
    "externalRef": "pedido-4821",
    "endToEndId": "E99999999202610051433a1b2c3d4e5f"
  }
}
```

| Header              | Value                                                                                       |
| ------------------- | ------------------------------------------------------------------------------------------- |
| `X-Payzu-Event`     | The event, the same as `event` in the body.                                                 |
| `X-Payzu-Delivery`  | Delivery identifier, the same as `id` in the body. The same on every attempt and on resend. |
| `X-Payzu-Timestamp` | Time of this attempt, in milliseconds (Unix). Part of the signature.                        |
| `X-Payzu-Signature` | `sha256=` followed by the HMAC-SHA256 in hexadecimal.                                       |

* `data` carries the event body, with amounts in cents. A field without a value is left out: none arrives as `null`.
* `sentAt` is when the first attempt was built, and it does not change on a new attempt or on resend. `accountId` is the account that produced the event.
* To match the webhook to your order, use `externalRef` and `metadata`, which come in the four `PAYMENT_*` events.
* To get the operation, use the identifier in `data`: `paymentId`, `withdrawId`, `transferId` or `depositId`. It is not the `id` returned at creation, but the lookup routes accept both. In the statement, it appears in `originId`.
* `refundId` points to the refund in the `refunds` list of the charge or the deposit.

## Signature [#signature]

The signature is the HMAC-SHA256 of `<X-Payzu-Timestamp>.<raw body>`, with the destination's secret: the registered endpoint's `secret` or, for webhooks sent to the `callbackUrl`, the account's callback secret.

<Steps>
  <Step>
    Read `X-Payzu-Timestamp`, `X-Payzu-Signature` and the body exactly as it arrived. Serializing the JSON again changes spaces and key order, and the signature will not match.
  </Step>

  <Step>
    Compute `HMAC-SHA256(secret, "<timestamp>.<body>")` in hexadecimal and compare it, in constant time, with the value after `sha256=`.
  </Step>

  <Step>
    Refuse a timestamp outside a tolerance window. Each attempt is signed when it is sent, so the timestamp of a new attempt is always recent.
  </Step>
</Steps>

```ts
import crypto from 'node:crypto';

const TOLERANCE_MS = 5 * 60 * 1000;

function verifyPayzuSignature(rawBody, headers, secret) {
  const timestamp = headers['x-payzu-timestamp'];
  const signature = headers['x-payzu-signature'];
  if (typeof timestamp !== 'string' || typeof signature !== 'string') return false;
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const received = /^sha256=([0-9a-f]{64})$/i.exec(signature);
  if (!received) return false;

  return crypto.timingSafeEqual(Buffer.from(received[1], 'hex'), Buffer.from(expected, 'hex'));
}
```

## Response and new attempts [#response-and-new-attempts]

* Respond with any `2xx` within **10 seconds**. After that, the attempt counts as a failure. If processing takes long, respond first and process afterwards.
* There are **10 attempts** in total, with increasing waits, from minutes to hours; the last two are 6 hours apart. From start to finish, about 18 hours. After the tenth, the delivery is abandoned, and the endpoint stays active.
* An account's webhooks go out in the order they happened. While a webhook waits for a new attempt, the following ones from the same account wait too, including those for other endpoints.

In the dashboard, the deliveries tab shows each attempt and your server's response. A failed or abandoned delivery can be sent again with the **Resend** button, using the operation PIN: the same body goes out, with the same `X-Payzu-Delivery`.

## Duplicate or out-of-order webhook [#duplicate-or-out-of-order-webhook]

* The same webhook can arrive more than once. Discard what was already processed by `X-Payzu-Delivery`, which is the same on every attempt and on resend.
* To deduplicate by operation, use the event + `data` identifier pair, with two exceptions: `INFRACTION_DEADLINE` arrives up to three times per dispute, one per `hoursRemaining`; `ACCOUNT_BLOCKED` and `ACCOUNT_UNBLOCKED` have no identifier.
* Order is lost when an abandoned delivery is resent later, and there is no order across accounts. Where there is `data.status`, it is the state at the time of the event: a `PAYMENT_CREATED` that arrives after the `PAYMENT_PAID` does not undo the payment. In the block webhooks, the most recent `changedAt` wins.