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

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

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

## 什么是 webhook（callback） [#什么是-webhookcallback]

**webhook**（也称为 **callback**）是当事件发生时，**PayZu 向您的服务器发送**的 `POST` 请求。与常规 API（由您调用 PayZu）相反，这里是反向的：由 PayZu 调用您。

设想一笔 Pix 收款。您创建了它，向客户展示了 QR，现在需要知道客户何时付款。有两个选择：

1. **轮询**，每隔 X 秒不停地问"付了吗？付了吗？"（成本高、慢、不必要）。
2. **webhook**，让 PayZu 在款项到账时立即通知您（即时、高效、推荐）。

<Mermaid
  chart="`
sequenceDiagram
  participant App as 您的应用
  participant PZ as PayZu
  participant Banco as 客户的银行

  App->>PZ: POST /pix 带 callbackUrl
  PZ-->>App: id, qrCodeText, 状态 PENDING
  Banco->>PZ: 客户付款
  PZ->>App: POST callbackUrl 状态 COMPLETED
  App-->>PZ: HTTP 200 OK
`"
/>

## 如何配置 [#如何配置]

您可以通过两种方式接收通知：

* **已注册的 webhook**（推荐）：在 [`POST /user/webhooks`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook) 中注册一个持久化 URL，配置 HMAC 密钥和事件筛选。同一个 URL 适用于所有交易。
* **每笔交易的 `callbackUrl`**：在每笔创建的交易 body 的 `callbackUrl` 字段中传入 URL：

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

**每当该交易状态发生变化时**（PENDING → COMPLETED，COMPLETED → REFUNDED 等），PayZu 都会向该 URL 发送 callback。

<Steps>
  <Step>
    ### 在您的服务器上创建一个公开的 endpoint [#在您的服务器上创建一个公开的-endpoint]

    在互联网上可访问的位置，接收带 JSON 的 `POST` 请求。例如：`https://your-site.com/webhooks/payzu`、`https://api.your-company.com/payzu/callback`。

    本地开发时，使用 [ngrok](https://ngrok.com) 或 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 等隧道工具暴露 `localhost`。
  </Step>

  <Step>
    ### 创建交易时传入 URL [#创建交易时传入-url]

    在每个 `POST /pix`、`POST /withdraw`、`POST /internal-transfer` 中包含 `callbackUrl` 字段。所有交易可以使用相同的 URL。
  </Step>

  <Step>
    ### 实现 handler [#实现-handler]

    接收 `POST`，读取 JSON，处理并在 5 秒内响应 `2xx`。参见 [接收 Pix · 步骤 3](/docs/pix-processamento/tutoriais/receive-pix#在付款时接收-callback) 中的示例。
  </Step>
</Steps>

<Callout type="info">
  PayZu 发送 `Content-Type: application/json`。其他投递头信息见
  [投递的头信息](#投递的头信息)。
</Callout>

## 事件 [#事件]

[`POST /user/webhooks`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook)
的 `events` 字段定义哪些变化触发通知。留空则接收全部。

有七个事件对应交易的 `status`，每个值对应一个：

| 事件                               | 触发时机                                         | payload 中的 `status`  |
| -------------------------------- | -------------------------------------------- | -------------------- |
| `TRANSACTION_PENDING`            | 收款单已创建等待支付，或 Pix 支付进入处理中。内部转账不会经过 `PENDING`。 | `PENDING`            |
| `TRANSACTION_COMPLETED`          | 支付已确认。收款场景下，客户已付款；Pix 支付场景下，款项已发出。           | `COMPLETED`          |
| `TRANSACTION_CANCELED`           | 交易在完成前被取消，由手动操作或规则触发。                        | `CANCELED`           |
| `TRANSACTION_WAITING_FOR_REFUND` | 退款进入处理队列，通常在 MED 被接受后。                       | `WAITING_FOR_REFUND` |
| `TRANSACTION_REFUNDED`           | 款项已退还给付款方。                                   | `REFUNDED`           |
| `TRANSACTION_EXPIRED`            | 收款单超过 `expiresIn` 未被支付。                      | `EXPIRED`            |
| `TRANSACTION_ERROR`              | 交易在处理中失败。                                    | `ERROR`              |

<Callout type="warn">
  如果交易的 `status` 在队列处理事件之前发生变化，已注册 webhook 的投递
  会被丢弃：没有发送、没有历史记录、也没有重试。在快速 Pix 中，
  `TRANSACTION_PENDING` 通常不会到达，因此不要要求先收到前一事件才
  接受 `TRANSACTION_COMPLETED`。
</Callout>

三个事件不对应 `status`：

| 事件                                     | 触发时机                                                                                                                                  |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `INFRACTION_CHANGED`                   | 与您的某笔交易关联的一个 [MED 违规](/docs/pix-processamento/med) 被开启、状态被变更或被关闭。body 是交易的内容，附加 `infraction` 对象：交易 id 在 `id`，违规 id 在 `infraction.id`。 |
| `TRANSACTION_SUSPECTED_FRAUD`          | 保留。目前没有服务发出此事件。                                                                                                                       |
| `TRANSACTION_SUSPECTED_FRAUD_REVERSAL` | 保留。目前没有服务发出此事件。                                                                                                                       |

<Callout type="info">
  这两个可疑欺诈事件可以订阅，但目前没有任何投递会产生这些事件。
  只订阅这两个事件的 webhook 什么也收不到。
</Callout>

## 重试系统 [#重试系统]

PayZu 的 webhook 拥有强大的重试系统，能够在临时故障下也确保
投递成功。PayZu 会以指数退避加抖动的方式**最多重试 40 次**发送
同一个 callback，从而更均匀地分散负载，避免请求峰值。

<Mermaid
  chart="`
flowchart TD
  A[&#x22;交易状态发生变化&#x22;]
  A --> B[&#x22;PayZu 发送 POST callbackUrl&#x22;]
  B --> C{&#x22;5 秒内响应 2xx？&#x22;}
  C -->|是| D[&#x22;投递已确认&#x22;]
  C -->|否| E[&#x22;等待指数退避 + 抖动&#x22;]
  E --> F{&#x22;尝试次数少于 40？&#x22;}
  F -->|是| B
  F -->|否| G[&#x22;标记为最终失败&#x22;]

  click D &#x22;/zh/docs/pix-processamento/best-practices/idempotency&#x22; &#x22;callback 幂等性&#x22;
  click G &#x22;/zh/docs/pix-processamento/endpoints/callbacks/resend_user_callback_single&#x22; &#x22;手动重发&#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">
  **响应时间：** webhook 必须在 **5 秒**内返回 `2xx`（例如
  `200` 或 `204`）。`2xx` 范围以外的响应（包括 `4xx`）和超时同样会
  触发重试。
</Callout>

## 安全性 [#安全性]

为保障完整性和安全性，请**限制**对您 webhook endpoint 的访问。
向支持团队索取 PayZu Processamento 的官方 IP，仅接受来自该来源
的 callback。

## 投递的头信息 [#投递的头信息]

| 头信息                    | 值                                                     |
| ---------------------- | ----------------------------------------------------- |
| `Content-Type`         | `application/json`                                    |
| `User-Agent`           | `Callback-Service/1.0`                                |
| `X-Callback-Attempt`   | 本次投递的尝试次数。                                            |
| `X-Callback-Event`     | 触发投递的事件。仅在已注册的 webhook 中出现。                           |
| `X-Callback-Signature` | HMAC 签名。只要投递有密钥就会出现：已注册 webhook 的密钥，或账户的 callback 密钥。 |

`INFRACTION_CHANGED` 到达时交易的 `status` 保持不变，因此当您订阅
多个事件时，`X-Callback-Event` 是区分投递的依据。

## HMAC 验证 [#hmac-验证]

每次签名的投递都带有 `X-Callback-Signature`。签名所用的密钥取决于目标：

| 投递目标              | 签名密钥            | 创建位置                                                                                                                                                                                                                                     |
| ----------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 已注册的 webhook      | webhook 密钥      | [`POST /user/webhooks`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook) 中的 `generateSecret: true`，或 [`POST /user/webhooks/{id}/rotate-secret`](/docs/pix-processamento/endpoints/webhooks/post_user_webhook_rotate_secret) |
| 交易的 `callbackUrl` | 账户的 callback 密钥 | `POST /v1/user/callbacks/secret`，通过 `PATCH /v1/user/callbacks/secret/rotate` 轮换                                                                                                                                                          |

<Callout type="warn">
  发送到交易 `callbackUrl` 的投递，只有在账户配置了 callback 密钥的情况下
  才会签名。没有该密钥就没有签名：请创建密钥或通过来源 IP 保护
  endpoint。
</Callout>

在处理 body 之前先验证签名：

<Steps>
  <Step>
    读取 `X-Callback-Signature` 头。其值为 `t=<timestamp>, v1=<签名>`，
    其中发送时间以秒（Unix）为单位，签名为 64 个字符的十六进制。
  </Step>

  <Step>
    将 timestamp 与请求的原始 body 用 `.` 拼接形成基础字符串，
    组成 `<timestamp>.<body>`。
  </Step>

  <Step>
    使用目标的密钥（webhook 密钥或账户的 callback 密钥）
    对该字符串生成 HMAC SHA-256，并以恒定时间与 `v1` 的值比较。
    如果不一致，则拒绝该投递。
  </Step>

  <Step>
    同时拒绝超出容忍窗口的 timestamp。每次尝试都在发送时刻签名，
    因此重试的 timestamp 始终是最新的。
  </Step>
</Steps>

Node.js 示例，使用 `crypto.timingSafeEqual` 以恒定时间比较签名：

```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">
  请在任何 JSON 解析之前，对原始请求 body 计算 HMAC，与接收时完全一致。
</Callout>

## payload 字段 [#payload-字段]

### 标识 [#标识]

| 字段                | 类型     | 描述                                                     |
| ----------------- | ------ | ------------------------------------------------------ |
| `id`              | string | 交易 ID                                                  |
| `clientReference` | string | 您提供的外部引用                                               |
| `virtualAccount`  | string | 虚拟子账户（最多 50 字符）。会在 callback 中返回，用于关联门店、分店、marketplace。 |
| `callbackUrl`     | string | 配置用于接收此 webhook 的 URL                                  |

### 状态与金额 [#状态与金额]

| 字段                  | 类型     | 描述                                                                                 |
| ------------------- | ------ | ---------------------------------------------------------------------------------- |
| `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 | 金额（BRL）                                                                            |
| `serviceFeeCharged` | number | 收取的手续费                                                                             |

`COMMISSION` 标识贷记到您账户的佣金入账，会伴随 `TRANSACTION_COMPLETED` 到达。

### 生成的收款单（存款） [#生成的收款单存款]

| 字段                  | 类型     | 描述                    |
| ------------------- | ------ | --------------------- |
| `qrCodeText`        | string | Pix 复制粘贴代码            |
| `qrCodeUrl`         | string | QR Code 图像的 URL       |
| `qrCodeBase64`      | string | Base64 格式的 QR Code 图像 |
| `generatedName`     | string | 引用名称                  |
| `generatedDocument` | string | CPF 或 CNPJ            |
| `generatedEmail`    | string | 与交易关联的邮箱              |

### 付款方 [#付款方]

| 字段                     | 类型     | 描述                                                 |
| ---------------------- | ------ | -------------------------------------------------- |
| `payerName`            | string | 付款方姓名                                              |
| `payerDocument`        | string | 付款方证件                                              |
| `payerInstitutionIspb` | string | 付款方银行的 ISPB                                        |
| `payerInstitutionName` | string | 付款方银行的名称                                           |
| `payerAccountNumber`   | string | 付款方的 PayZu 账户（6 位数字）。当由 PayZu 账户付款时填充：Pix 支付和内部转账。 |

### 收款方 [#收款方]

| 字段                        | 类型     | 描述                                             |
| ------------------------- | ------ | ---------------------------------------------- |
| `receiverName`            | string | 收款方姓名                                          |
| `receiverDocument`        | string | 收款方证件                                          |
| `receiverInstitutionIspb` | string | 收款方银行的 ISPB                                    |
| `receiverInstitutionName` | string | 收款方银行的名称                                       |
| `receiverAccountNumber`   | string | 收款方的 PayZu 账户（6 位数字）。当由 PayZu 账户收款时填充：存款和内部转账。 |

### 通过 Pix 密钥的支付 [#通过-pix-密钥的支付]

| 字段                | 类型     | 描述                                 |
| ----------------- | ------ | ---------------------------------- |
| `withdrawPixKey`  | string | 支付所使用的 Pix 密钥                      |
| `withdrawPixType` | string | `cpf`、`cnpj`、`phone`、`email`、`evp` |

### 结算与退款 [#结算与退款]

| 字段                   | 类型     | 描述                               |
| -------------------- | ------ | -------------------------------- |
| `endToEndId`         | string | Pix 的 EndToEnd ID                |
| `paidAt`             | string | 支付时间戳（ISO 8601）                  |
| `cancellationReason` | string | 取消原因                             |
| `refundEndToEndId`   | string | 退款的 EndToEnd ID                  |
| `refundAmount`       | string | 退款金额                             |
| `refundStatus`       | string | `PENDING`、`COMPLETED`、`CANCELED` |
| `refundReason`       | string | 退款原因                             |
| `refundDescription`  | string | 退款描述                             |
| `refundedAt`         | string | 退款时间戳（ISO 8601）                  |

### 时间戳 [#时间戳]

| 字段          | 类型     | 描述              |
| ----------- | ------ | --------------- |
| `createdAt` | string | 创建时间戳（ISO 8601） |
| `updatedAt` | string | 更新时间戳（ISO 8601） |

### 违规（Pix 争议） [#违规pix-争议]

| 字段           | 类型     | 描述                                                |
| ------------ | ------ | ------------------------------------------------- |
| `infraction` | object | 违规开启时的详细信息（参见 [MED](/docs/pix-processamento/med)） |

## 最佳实践 [#最佳实践]

* **快速响应**：在 5 秒内返回 `2xx`。将繁重的处理放在
  队列/worker 中，不要在 handler 内做。
* **幂等性**：使用 `id` 加事件进行去重，而不仅仅是 `id` + `status`。
  同一个 callback 可能到达多次（重试、连续变化），并且
  `INFRACTION_CHANGED` 不会改变 `status`。参见 [callback 去重](/docs/pix-processamento/best-practices/idempotency#dedupe-de-callbacks)。
* **使用 `clientReference`**：在创建交易时传入外部标识符。
  会在 callback 中返回，便于与您的订单关联。
* **按 IP 限制**：仅接受来自 PayZu 官方 IP 的 callback。
* **返回 `2xx` 以结束投递**：`2xx` 范围以外的任何响应，
  包括 `4xx`，以及任何超时都会进入同样的最多 40 次重试循环。要
  停止重发，请返回 `2xx` 并在您这边处理错误。
* **在日志中掩码 `payerDocument`**：不做掩码就打印 payload
  会带来 LGPD 风险。

## 测试与重发 [#测试与重发]

### 本地测试 [#本地测试]

通过 [ngrok](https://ngrok.com) 或 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 暴露您的 localhost，然后手动发送 payload：

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

### 重发真实的 callback [#重发真实的-callback]

重发的 endpoint 取决于目标 URL 的配置位置。

**在交易中传入的 `callbackUrl`：**

* [`POST /user/callbacks/resend/{transactionId}`](/docs/pix-processamento/endpoints/callbacks/resend_user_callback_single)，单笔交易
* [`POST /user/callbacks/resend`](/docs/pix-processamento/endpoints/callbacks/resend_user_callbacks)，按筛选条件批量，必须指定日期窗口

两者仅覆盖已填写 `callbackUrl` 的交易，不会为已注册 webhook 生成投递。

**已注册的 webhook：**

* [`POST /user/callbacks/resend/webhook/{webhookId}`](/docs/pix-processamento/endpoints/callbacks/resend_user_callbacks_webhook)，重新入队该 webhook 上失败的投递

按 webhook 重发会针对每对交易与事件重新处理一次投递，
将 `300` 起的响应和无响应视为失败。`status` 已与当前交易状态
不匹配的事件在重发时依然会被丢弃。

响应在 `enqueued` 中返回，包含 `count`（接受重发的总数）、`truncated`
和 `items`。`items` 列表在 500 条时截断。当 `truncated` 为 `true` 时，
重发依然覆盖所有 `count`，只是响应中的列表被截断。

<Callout type="warn">
  `200` 表示已接受重发，而非已入队投递：入队发生在
  响应之后。而且该路由不再返回 `count: 0` 的 `200`。如果
  `{webhookId}` 没有活动的 webhook，会返回 `404 PZW300`；在期间
  没有失败的 callback，则返回 `404 PZW310`。
</Callout>

### 检查历史记录 [#检查历史记录]

PayZu 会保存所有投递尝试。用于排查失败很有用：

* [`GET /user/callbacks`](/docs/pix-processamento/endpoints/callbacks/get_user_callbacks)，分页列表
* [`GET /user/callbacks/{id}`](/docs/pix-processamento/endpoints/callbacks/get_user_callback_by_id)，包含 status code、response body、response time 的详情

## 下一步 [#下一步]

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

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