Webhooks
无需反复查询支付是否到账,我们会在状态变更的瞬间通知您的服务器,如果您的系统未响应,会以间隔时间递增的方式重试最多 40 次。
什么是 webhook(callback)
webhook(也称为 callback)是当事件发生时,PayZu 向您的服务器发送的 POST 请求。与常规 API(由您调用 PayZu)相反,这里是反向的:由 PayZu 调用您。
设想一笔 Pix 收款。您创建了它,向客户展示了 QR,现在需要知道客户何时付款。有两个选择:
- 轮询,每隔 X 秒不停地问"付了吗?付了吗?"(成本高、慢、不必要)。
- webhook,让 PayZu 在款项到账时立即通知您(即时、高效、推荐)。
如何配置
您可以通过两种方式接收通知:
- 已注册的 webhook(推荐):在
POST /user/webhooks中注册一个持久化 URL,配置 HMAC 密钥和事件筛选。同一个 URL 适用于所有交易。 - 每笔交易的
callbackUrl:在每笔创建的交易 body 的callbackUrl字段中传入 URL:
{
"amount": 99.90,
"callbackUrl": "https://your-site.com/webhooks/payzu",
"clientReference": "pedido-2025-001"
}每当该交易状态发生变化时(PENDING → COMPLETED,COMPLETED → REFUNDED 等),PayZu 都会向该 URL 发送 callback。
在您的服务器上创建一个公开的 endpoint
在互联网上可访问的位置,接收带 JSON 的 POST 请求。例如:https://your-site.com/webhooks/payzu、https://api.your-company.com/payzu/callback。
本地开发时,使用 ngrok 或 Cloudflare Tunnel 等隧道工具暴露 localhost。
创建交易时传入 URL
在每个 POST /pix、POST /withdraw、POST /internal-transfer 中包含 callbackUrl 字段。所有交易可以使用相同的 URL。
实现 handler
接收 POST,读取 JSON,处理并在 5 秒内响应 2xx。参见 接收 Pix · 步骤 3 中的示例。
PayZu 发送 Content-Type: application/json。其他投递头信息见
投递的头信息。
事件
POST /user/webhooks
的 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 |
如果交易的 status 在队列处理事件之前发生变化,已注册 webhook 的投递
会被丢弃:没有发送、没有历史记录、也没有重试。在快速 Pix 中,
TRANSACTION_PENDING 通常不会到达,因此不要要求先收到前一事件才
接受 TRANSACTION_COMPLETED。
三个事件不对应 status:
| 事件 | 触发时机 |
|---|---|
INFRACTION_CHANGED | 与您的某笔交易关联的一个 MED 违规 被开启、状态被变更或被关闭。body 是交易的内容,附加 infraction 对象:交易 id 在 id,违规 id 在 infraction.id。 |
TRANSACTION_SUSPECTED_FRAUD | 保留。目前没有服务发出此事件。 |
TRANSACTION_SUSPECTED_FRAUD_REVERSAL | 保留。目前没有服务发出此事件。 |
这两个可疑欺诈事件可以订阅,但目前没有任何投递会产生这些事件。 只订阅这两个事件的 webhook 什么也收不到。
重试系统
PayZu 的 webhook 拥有强大的重试系统,能够在临时故障下也确保 投递成功。PayZu 会以指数退避加抖动的方式最多重试 40 次发送 同一个 callback,从而更均匀地分散负载,避免请求峰值。
响应时间: webhook 必须在 5 秒内返回 2xx(例如
200 或 204)。2xx 范围以外的响应(包括 4xx)和超时同样会
触发重试。
安全性
为保障完整性和安全性,请限制对您 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 验证
每次签名的投递都带有 X-Callback-Signature。签名所用的密钥取决于目标:
| 投递目标 | 签名密钥 | 创建位置 |
|---|---|---|
| 已注册的 webhook | webhook 密钥 | POST /user/webhooks 中的 generateSecret: true,或 POST /user/webhooks/{id}/rotate-secret |
交易的 callbackUrl | 账户的 callback 密钥 | POST /v1/user/callbacks/secret,通过 PATCH /v1/user/callbacks/secret/rotate 轮换 |
发送到交易 callbackUrl 的投递,只有在账户配置了 callback 密钥的情况下
才会签名。没有该密钥就没有签名:请创建密钥或通过来源 IP 保护
endpoint。
在处理 body 之前先验证签名:
读取 X-Callback-Signature 头。其值为 t=<timestamp>, v1=<签名>,
其中发送时间以秒(Unix)为单位,签名为 64 个字符的十六进制。
将 timestamp 与请求的原始 body 用 . 拼接形成基础字符串,
组成 <timestamp>.<body>。
使用目标的密钥(webhook 密钥或账户的 callback 密钥)
对该字符串生成 HMAC SHA-256,并以恒定时间与 v1 的值比较。
如果不一致,则拒绝该投递。
同时拒绝超出容忍窗口的 timestamp。每次尝试都在发送时刻签名, 因此重试的 timestamp 始终是最新的。
Node.js 示例,使用 crypto.timingSafeEqual 以恒定时间比较签名:
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"),
);
}请在任何 JSON 解析之前,对原始请求 body 计算 HMAC,与接收时完全一致。
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 密钥的支付
| 字段 | 类型 | 描述 |
|---|---|---|
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 争议)
| 字段 | 类型 | 描述 |
|---|---|---|
infraction | object | 违规开启时的详细信息(参见 MED) |
最佳实践
- 快速响应:在 5 秒内返回
2xx。将繁重的处理放在 队列/worker 中,不要在 handler 内做。 - 幂等性:使用
id加事件进行去重,而不仅仅是id+status。 同一个 callback 可能到达多次(重试、连续变化),并且INFRACTION_CHANGED不会改变status。参见 callback 去重。 - 使用
clientReference:在创建交易时传入外部标识符。 会在 callback 中返回,便于与您的订单关联。 - 按 IP 限制:仅接受来自 PayZu 官方 IP 的 callback。
- 返回
2xx以结束投递:2xx范围以外的任何响应, 包括4xx,以及任何超时都会进入同样的最多 40 次重试循环。要 停止重发,请返回2xx并在您这边处理错误。 - 在日志中掩码
payerDocument:不做掩码就打印 payload 会带来 LGPD 风险。
测试与重发
本地测试
通过 ngrok 或 Cloudflare Tunnel 暴露您的 localhost,然后手动发送 payload:
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"
}'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',
}),
});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',
},
)重发真实的 callback
重发的 endpoint 取决于目标 URL 的配置位置。
在交易中传入的 callbackUrl:
POST /user/callbacks/resend/{transactionId},单笔交易POST /user/callbacks/resend,按筛选条件批量,必须指定日期窗口
两者仅覆盖已填写 callbackUrl 的交易,不会为已注册 webhook 生成投递。
已注册的 webhook:
POST /user/callbacks/resend/webhook/{webhookId},重新入队该 webhook 上失败的投递
按 webhook 重发会针对每对交易与事件重新处理一次投递,
将 300 起的响应和无响应视为失败。status 已与当前交易状态
不匹配的事件在重发时依然会被丢弃。
响应在 enqueued 中返回,包含 count(接受重发的总数)、truncated
和 items。items 列表在 500 条时截断。当 truncated 为 true 时,
重发依然覆盖所有 count,只是响应中的列表被截断。
200 表示已接受重发,而非已入队投递:入队发生在
响应之后。而且该路由不再返回 count: 0 的 200。如果
{webhookId} 没有活动的 webhook,会返回 404 PZW300;在期间
没有失败的 callback,则返回 404 PZW310。
检查历史记录
PayZu 会保存所有投递尝试。用于排查失败很有用:
GET /user/callbacks,分页列表GET /user/callbacks/{id},包含 status code、response body、response time 的详情