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

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

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

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

Os webhooks chegam por dois caminhos, que podem conviver. Com endpoint cadastrado e `callbackUrl` na operação, o webhook chega nos dois.

| Caminho                                               | Recebe                            | Segredo que assina                         |
| ----------------------------------------------------- | --------------------------------- | ------------------------------------------ |
| [Endpoint cadastrado](#endpoint-cadastrado)           | Os eventos escolhidos em `events` | O `secret` do endpoint (`whsec_…`)         |
| [`callbackUrl` da operação](#callbackurl-da-operação) | Todos os eventos daquela operação | O segredo de callback da conta (`cbsec_…`) |

<Mermaid
  chart="`
sequenceDiagram
  participant App as Sua aplicação
  participant PZ as PayZu
  participant Banco as Banco do pagador

  App->>PZ: POST /transactions/payment
  PZ-->>App: 201, status PENDING
  Banco->>PZ: Pix pago
  PZ->>App: POST endpoint, PAYMENT_PAID assinado
  App-->>PZ: 2xx em até 10 s
`"
/>

## Endpoint cadastrado [#endpoint-cadastrado]

Cadastre a URL e os eventos em [`POST /transactions/webhooks`](/docs/conta-digital/endpoints/webhooks/post_webhook), escopo `WEBHOOK_WRITE`, ou no painel.

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

* A URL precisa ser HTTPS, pública e ter até 2048 caracteres. Não pode repetir a de outro endpoint da conta.
* Mande ao menos um evento. O endpoint nasce ativo.
* Guarde o `secret` da resposta: ele só aparece ali. No painel, o botão de novo segredo gera outro, e o anterior para de valer na hora.
* Para parar de receber, mande `isActive: false` em [`PUT /transactions/webhooks/{webhookId}`](/docs/conta-digital/endpoints/webhooks/put_webhook). Endpoint que já teve entrega não pode ser excluído: o `DELETE` responde `409` `WEBHOOK_HAS_DELIVERIES`.

## `callbackUrl` da operação [#callbackurl-da-operação]

Mande `callbackUrl` no corpo da cobrança, do saque, do pagamento de Pix copia e cola ou da transferência para receber todos os webhooks daquela operação. Aqui não há escolha de eventos.

* Antes, emita o segredo de callback da conta em [`POST /transactions/callback-secret`](/docs/conta-digital/endpoints/webhooks/post_callback_secret). Sem ele, a operação com `callbackUrl` é recusada com `412` `CALLBACK_SECRET_MISSING`.
* O segredo só aparece nessa resposta. [`GET /transactions/callback-secret`](/docs/conta-digital/endpoints/webhooks/get_callback_secret) diz apenas se ele existe, e [`POST /transactions/callback-secret/rotate`](/docs/conta-digital/endpoints/webhooks/post_callback_secret_rotate) gera outro; o anterior para de valer na hora.
* A URL segue as regras do endpoint: HTTPS, pública, até 2048 caracteres.
* Chegam todos os eventos da operação, inclusive estorno e contestação, menos `WITHDRAW_REFUND_RECEIVED`, que vai só aos endpoints cadastrados.
* Na transferência, a URL é de quem envia: o `INTERNAL_TRANSFER_RECEIVED`, da conta de destino, não vai para ela.
* A URL fica fixada na criação. Repetir a operação com a mesma `externalRef` ou `Idempotency-Key` e outra `callbackUrl` devolve a operação original, com a URL original.
* A resposta da operação traz a `callbackUrl` aceita. Um campo com outro nome, como `callback_url`, é ignorado, e ela volta `null`.

## Eventos [#eventos]

O corpo de cada evento, campo a campo, está na página dele.

| Evento                                                                                                          | Quando chega                                                                                                         |
| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| [`PAYMENT_CREATED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_created)                       | A cobrança foi registrada e o QR Code existe. Ainda não é pagamento.                                                 |
| [`PAYMENT_PAID`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_paid)                             | A cobrança foi paga. Libere o pedido aqui.                                                                           |
| [`PAYMENT_REFUNDED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_refunded)                     | O banco estornou o recebimento por conta própria. O valor cheio sai da conta, e a tarifa não volta.                  |
| [`PAYMENT_EXPIRED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_expired)                       | A cobrança venceu sem pagamento.                                                                                     |
| [`WITHDRAW_CREATED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_created)                     | O saque ou o pagamento de Pix copia e cola foi pedido, e o valor e a tarifa saíram do saldo disponível.              |
| [`WITHDRAW_COMPLETED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_completed)                 | O dinheiro chegou ao destino.                                                                                        |
| [`WITHDRAW_FAILED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_failed)                       | O saque falhou, e o valor e a tarifa voltaram ao saldo.                                                              |
| [`REFUND_COMPLETED`](/docs/conta-digital/endpoints/webhook-events/webhook_refund_completed)                     | O estorno pedido pela API, pelo painel ou pelo suporte foi concluído: o valor voltou ao pagador.                     |
| [`REFUND_FAILED`](/docs/conta-digital/endpoints/webhook-events/webhook_refund_failed)                           | O estorno foi recusado, e o valor voltou ao saldo.                                                                   |
| [`DEPOSIT_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_deposit_received)                     | Um Pix caiu numa chave da conta sem cobrança. O valor já está creditado.                                             |
| [`INTERNAL_TRANSFER_SENT`](/docs/conta-digital/endpoints/webhook-events/webhook_internal_transfer_sent)         | A conta enviou uma transferência.                                                                                    |
| [`INTERNAL_TRANSFER_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_internal_transfer_received) | A conta recebeu uma transferência.                                                                                   |
| [`WITHDRAW_REFUND_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_refund_received)     | Quem recebeu um Pix da conta devolveu o valor, inteiro ou em parte.                                                  |
| [`INFRACTION_OPENED`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_opened)                   | Uma contestação MED foi aberta contra a conta.                                                                       |
| [`INFRACTION_CLOSED`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_closed)                   | A contestação foi encerrada ou cancelada. O `status` do corpo diz qual.                                              |
| [`INFRACTION_DEADLINE`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_deadline)               | O prazo de resposta da contestação está chegando: faltam 48, 24 ou 6 horas.                                          |
| [`ACCOUNT_BLOCKED`](/docs/conta-digital/endpoints/webhook-events/webhook_account_blocked)                       | O banco bloqueou operações da conta, ou a lista de bloqueios mudou. `blockedOperations` diz o que a API vai recusar. |
| [`ACCOUNT_UNBLOCKED`](/docs/conta-digital/endpoints/webhook-events/webhook_account_unblocked)                   | Os bloqueios saíram.                                                                                                 |

O estorno total pedido pela API leva a cobrança a `REFUNDED`, mas o webhook é `REFUND_COMPLETED`, não `PAYMENT_REFUNDED`.

## Requisição [#requisição]

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

| Cabeçalho           | Valor                                                                                          |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| `X-Payzu-Event`     | O evento, o mesmo de `event` no corpo.                                                         |
| `X-Payzu-Delivery`  | Identificador da entrega, o mesmo de `id` no corpo. Igual em todas as tentativas e no reenvio. |
| `X-Payzu-Timestamp` | Instante desta tentativa, em milissegundos (Unix). Entra na assinatura.                        |
| `X-Payzu-Signature` | `sha256=` seguido do HMAC-SHA256 em hexadecimal.                                               |

* `data` traz o corpo do evento, com valores em centavos. Campo sem valor não vem: nenhum chega como `null`.
* `sentAt` é quando a primeira tentativa foi montada e não muda na nova tentativa nem no reenvio. `accountId` é a conta que produziu o evento.
* Para casar o webhook com o seu pedido, use `externalRef` e `metadata`, que vêm nos quatro eventos `PAYMENT_*`.
* Para consultar a operação, use o identificador de `data`: `paymentId`, `withdrawId`, `transferId` ou `depositId`. Ele não é o `id` devolvido na criação, mas as rotas de consulta aceitam os dois. No extrato, ele aparece em `originId`.
* `refundId` aponta o estorno na lista `refunds` da cobrança ou do depósito.

## Assinatura [#assinatura]

A assinatura é o HMAC-SHA256 de `<X-Payzu-Timestamp>.<corpo cru>`, com o segredo do destino: o `secret` do endpoint cadastrado ou, nos webhooks enviados à `callbackUrl`, o segredo de callback da conta.

<Steps>
  <Step>
    Leia `X-Payzu-Timestamp`, `X-Payzu-Signature` e o corpo exatamente como chegou. Reserializar o JSON muda espaços e ordem de chaves, e a assinatura não fecha.
  </Step>

  <Step>
    Calcule `HMAC-SHA256(segredo, "<timestamp>.<corpo>")` em hexadecimal e compare, em tempo constante, com o valor depois de `sha256=`.
  </Step>

  <Step>
    Recuse timestamp fora de uma janela de tolerância. Cada tentativa é assinada no envio, então o timestamp de uma nova tentativa é sempre recente.
  </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'));
}
```

## Resposta e nova tentativa [#resposta-e-nova-tentativa]

* Responda com qualquer `2xx` em até **10 segundos**. Depois disso, a tentativa conta como falha. Se o processamento for demorado, responda antes e processe depois.
* São **10 tentativas** no total, com espera crescente, de minutos a horas; as duas últimas ficam a 6 horas uma da outra. Do começo ao fim, cerca de 18 horas. Depois da décima, a entrega é abandonada, e o endpoint segue ativo.
* Os webhooks de uma conta saem na ordem em que aconteceram. Enquanto um webhook aguarda nova tentativa, os seguintes da mesma conta esperam, inclusive os de outros endpoints.

No painel, a aba de entregas mostra cada tentativa e a resposta do seu servidor. Uma entrega com falha ou abandonada pode ser reenviada pelo botão **Reenviar**, com o PIN de operação: vai o mesmo corpo, com o mesmo `X-Payzu-Delivery`.

## Webhook repetido ou fora de ordem [#webhook-repetido-ou-fora-de-ordem]

* O mesmo webhook pode chegar mais de uma vez. Descarte o que já foi processado pelo `X-Payzu-Delivery`, que é igual em todas as tentativas e no reenvio.
* Para deduplicar pela operação, use o par evento + identificador de `data`, com duas exceções: `INFRACTION_DEADLINE` chega até três vezes por contestação, uma por `hoursRemaining`; `ACCOUNT_BLOCKED` e `ACCOUNT_UNBLOCKED` não têm identificador.
* A ordem se perde quando uma entrega abandonada é reenviada depois, e não existe entre contas. Onde há `data.status`, ele é o estado no momento do evento: um `PAYMENT_CREATED` que chega depois do `PAYMENT_PAID` não desfaz o pagamento. Nos webhooks de bloqueio, vale o `changedAt` mais recente.