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

<QuickLinks>
  <QuickLink href="/docs/conta-digital/endpoints/webhooks/post_webhook" title="注册 Webhook 端点" method="POST" path="/transactions/webhooks" />

  <QuickLink href="/docs/conta-digital/endpoints/webhooks/put_webhook" title="修改 Webhook 端点" method="PUT" path="/transactions/webhooks/{webhookId}" />

  <QuickLink href="/docs/conta-digital/endpoints/webhooks/post_callback_secret" title="签发回调密钥" method="POST" path="/transactions/callback-secret" />
</QuickLinks>

Webhook 通过两种途径到达，两者可以并存。既注册了端点、操作中又带有 `callbackUrl` 时，两边都会收到 Webhook。

| 途径                                    | 接收                | 签名密钥                    |
| ------------------------------------- | ----------------- | ----------------------- |
| [已注册的端点](#已注册的端点)                     | 在 `events` 中选择的事件 | 端点的 `secret`（`whsec_…`） |
| [操作的 `callbackUrl`](#操作的-callbackurl) | 该操作的所有事件          | 账户的回调密钥（`cbsec_…`）      |

<Mermaid
  chart="`
sequenceDiagram
  participant App as 你的应用
  participant PZ as PayZu
  participant Banco as 付款方银行

  App->>PZ: POST /transactions/payment
  PZ-->>App: 201，状态 PENDING
  Banco->>PZ: Pix 已支付
  PZ->>App: POST 端点，已签名的 PAYMENT_PAID
  App-->>PZ: 10 秒内返回 2xx
`"
/>

## 已注册的端点 [#已注册的端点]

在 [`POST /transactions/webhooks`](/docs/conta-digital/endpoints/webhooks/post_webhook)（作用域 `WEBHOOK_WRITE`）或控制台中注册 URL 和事件。

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

* URL 必须是 HTTPS、可公开访问，且不超过 2048 个字符。不能与账户中其他端点的 URL 重复。
* 至少发送一个事件。端点创建后即为启用状态。
* 保存响应中的 `secret`：它只出现在那里。在控制台中，生成新密钥的按钮会生成另一个，旧密钥立即失效。
* 要停止接收，在 [`PUT /transactions/webhooks/{webhookId}`](/docs/conta-digital/endpoints/webhooks/put_webhook) 中发送 `isActive: false`。已有投递记录的端点不能删除：`DELETE` 返回 `409` `WEBHOOK_HAS_DELIVERIES`。

## 操作的 `callbackUrl` [#操作的-callbackurl]

在收款、提现、Pix 复制粘贴码付款或转账的请求体中发送 `callbackUrl`，即可接收该操作的所有 Webhook。这里不能选择事件。

* 事先用 [`POST /transactions/callback-secret`](/docs/conta-digital/endpoints/webhooks/post_callback_secret) 签发账户的回调密钥。没有它，带 `callbackUrl` 的操作会以 `412` `CALLBACK_SECRET_MISSING` 被拒绝。
* 密钥只出现在这个响应中。[`GET /transactions/callback-secret`](/docs/conta-digital/endpoints/webhooks/get_callback_secret) 只告诉你它是否存在，[`POST /transactions/callback-secret/rotate`](/docs/conta-digital/endpoints/webhooks/post_callback_secret_rotate) 会生成另一个；旧密钥立即失效。
* URL 遵循端点的规则：HTTPS、可公开访问、不超过 2048 个字符。
* 会收到该操作的所有事件，包括退款和争议，但 `WITHDRAW_REFUND_RECEIVED` 除外，它只发往已注册的端点。
* 在转账中，URL 属于转出方：来自目标账户的 `INTERNAL_TRANSFER_RECEIVED` 不会发往这个 URL。
* URL 在创建时确定。用同一个 `externalRef` 或 `Idempotency-Key` 加另一个 `callbackUrl` 重复该操作，会返回原操作及原 URL。
* 操作的响应带有被接受的 `callbackUrl`。名称不同的字段，例如 `callback_url`，会被忽略，响应中它为 `null`。

## 事件 [#事件]

每个事件的请求体，逐字段说明，见该事件的页面。

| 事件                                                                                                              | 何时到达                                                    |
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| [`PAYMENT_CREATED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_created)                       | 收款已登记，二维码已生成。还不是付款。                                     |
| [`PAYMENT_PAID`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_paid)                             | 收款已支付。在这里放行订单。                                          |
| [`PAYMENT_REFUNDED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_refunded)                     | 银行自行退回了这笔收款。全额从账户扣出，手续费不退还。                             |
| [`PAYMENT_EXPIRED`](/docs/conta-digital/endpoints/webhook-events/webhook_payment_expired)                       | 收款过期未支付。                                                |
| [`WITHDRAW_CREATED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_created)                     | 已发起提现或 Pix 复制粘贴码付款，金额和手续费已从可用余额中扣出。                     |
| [`WITHDRAW_COMPLETED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_completed)                 | 资金已到达目的地。                                               |
| [`WITHDRAW_FAILED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_failed)                       | 提现失败，金额和手续费已退回余额。                                       |
| [`REFUND_COMPLETED`](/docs/conta-digital/endpoints/webhook-events/webhook_refund_completed)                     | 通过 API、控制台或客服发起的退款已完成：金额已退还给付款人。                        |
| [`REFUND_FAILED`](/docs/conta-digital/endpoints/webhook-events/webhook_refund_failed)                           | 退款被拒，金额已退回余额。                                           |
| [`DEPOSIT_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_deposit_received)                     | 账户的某个密钥收到了一笔无收款单的 Pix。金额已入账。                            |
| [`INTERNAL_TRANSFER_SENT`](/docs/conta-digital/endpoints/webhook-events/webhook_internal_transfer_sent)         | 账户发出了一笔转账。                                              |
| [`INTERNAL_TRANSFER_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_internal_transfer_received) | 账户收到了一笔转账。                                              |
| [`WITHDRAW_REFUND_RECEIVED`](/docs/conta-digital/endpoints/webhook-events/webhook_withdraw_refund_received)     | 账户发出的 Pix 被收款方全部或部分退回。                                  |
| [`INFRACTION_OPENED`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_opened)                   | 针对账户发起了 MED 争议。                                         |
| [`INFRACTION_CLOSED`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_closed)                   | 争议已结束或已取消。请求体中的 `status` 指明是哪种。                         |
| [`INFRACTION_DEADLINE`](/docs/conta-digital/endpoints/webhook-events/webhook_infraction_deadline)               | 争议的答复期限临近：剩余 48、24 或 6 小时。                              |
| [`ACCOUNT_BLOCKED`](/docs/conta-digital/endpoints/webhook-events/webhook_account_blocked)                       | 银行冻结了账户操作，或冻结列表发生变化。`blockedOperations` 指明 API 会拒绝哪些操作。 |
| [`ACCOUNT_UNBLOCKED`](/docs/conta-digital/endpoints/webhook-events/webhook_account_unblocked)                   | 冻结已解除。                                                  |

通过 API 发起的全额退款会把收款变为 `REFUNDED`，但 Webhook 是 `REFUND_COMPLETED`，不是 `PAYMENT_REFUNDED`。

## 请求 [#请求]

```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              | 值                                |
| ------------------- | -------------------------------- |
| `X-Payzu-Event`     | 事件，与请求体中的 `event` 相同。            |
| `X-Payzu-Delivery`  | 投递标识，与请求体中的 `id` 相同。每次尝试和重发时都相同。 |
| `X-Payzu-Timestamp` | 本次尝试的时间点，单位毫秒（Unix）。参与签名。        |
| `X-Payzu-Signature` | `sha256=` 后接十六进制的 HMAC-SHA256。   |

* `data` 带有事件内容，金额以分为单位。没有值的字段不会出现：任何字段都不会以 `null` 到达。
* `sentAt` 是第一次尝试生成的时间，重试和重发时都不变。`accountId` 是产生该事件的账户。
* 要把 Webhook 与你的订单对应起来，请使用 `externalRef` 和 `metadata`，它们出现在全部四个 `PAYMENT_*` 事件中。
* 要查询操作，请使用 `data` 中的标识：`paymentId`、`withdrawId`、`transferId` 或 `depositId`。它不是创建时返回的 `id`，但查询路由两者都接受。在账单中，它出现在 `originId` 中。
* `refundId` 指向收款或存款 `refunds` 列表中的那笔退款。

## 签名 [#签名]

签名是对 `<X-Payzu-Timestamp>.<raw body>` 计算的 HMAC-SHA256，使用目标对应的密钥：已注册端点的 `secret`，或者对于发往 `callbackUrl` 的 Webhook，使用账户的回调密钥。

<Steps>
  <Step>
    读取 `X-Payzu-Timestamp`、`X-Payzu-Signature`，以及原样到达的请求体。重新序列化 JSON 会改变空格和键的顺序，签名将无法匹配。
  </Step>

  <Step>
    以十六进制计算 `HMAC-SHA256(secret, "<timestamp>.<body>")`，并以恒定时间与 `sha256=` 之后的值比较。
  </Step>

  <Step>
    拒绝超出容差窗口的时间戳。每次尝试都在发送时签名，所以重试的时间戳总是最新的。
  </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'));
}
```

## 响应与重试 [#响应与重试]

* 在 **10 秒**内返回任意 `2xx`。超时则本次尝试视为失败。如果处理耗时较长，先响应，再处理。
* 共 **10 次**尝试，等待时间递增，从几分钟到几小时；最后两次相隔 6 小时。从头到尾约 18 小时。第十次之后放弃该投递，端点保持启用。
* 同一账户的 Webhook 按发生顺序发出。当某个 Webhook 等待重试时，同一账户后续的 Webhook 都会等待，发往其他端点的也一样。

在控制台中，投递标签页显示每一次尝试以及你的服务器的响应。失败或已放弃的投递可以通过**重发**按钮重新发送，需要操作 PIN：发送相同的请求体，带相同的 `X-Payzu-Delivery`。

## 重复或乱序的 Webhook [#重复或乱序的-webhook]

* 同一个 Webhook 可能不止一次到达。用 `X-Payzu-Delivery` 丢弃已处理过的 Webhook，它在每次尝试和重发时都相同。
* 要按操作去重，请使用事件 + `data` 标识这一组合，但有两个例外：`INFRACTION_DEADLINE` 每个争议最多到达三次，每个 `hoursRemaining` 一次；`ACCOUNT_BLOCKED` 和 `ACCOUNT_UNBLOCKED` 没有标识。
* 已放弃的投递之后再重发时，顺序会丢失；不同账户之间也没有顺序。有 `data.status` 的地方，它表示事件发生时的状态：在 `PAYMENT_PAID` 之后到达的 `PAYMENT_CREATED` 不会撤销付款。在冻结相关的 Webhook 中，以最新的 `changedAt` 为准。