Webhooks
每当账户发生变化,例如收款已支付、提现已完成或争议已发起,你的服务器都会收到一个已签名的 Webhook。
Webhook 通过两种途径到达,两者可以并存。既注册了端点、操作中又带有 callbackUrl 时,两边都会收到 Webhook。
| 途径 | 接收 | 签名密钥 |
|---|---|---|
| 已注册的端点 | 在 events 中选择的事件 | 端点的 secret(whsec_…) |
操作的 callbackUrl | 该操作的所有事件 | 账户的回调密钥(cbsec_…) |
已注册的端点
在 POST /transactions/webhooks(作用域 WEBHOOK_WRITE)或控制台中注册 URL 和事件。
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"]
}'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();- URL 必须是 HTTPS、可公开访问,且不超过 2048 个字符。不能与账户中其他端点的 URL 重复。
- 至少发送一个事件。端点创建后即为启用状态。
- 保存响应中的
secret:它只出现在那里。在控制台中,生成新密钥的按钮会生成另一个,旧密钥立即失效。 - 要停止接收,在
PUT /transactions/webhooks/{webhookId}中发送isActive: false。已有投递记录的端点不能删除:DELETE返回409WEBHOOK_HAS_DELIVERIES。
操作的 callbackUrl
在收款、提现、Pix 复制粘贴码付款或转账的请求体中发送 callbackUrl,即可接收该操作的所有 Webhook。这里不能选择事件。
- 事先用
POST /transactions/callback-secret签发账户的回调密钥。没有它,带callbackUrl的操作会以412CALLBACK_SECRET_MISSING被拒绝。 - 密钥只出现在这个响应中。
GET /transactions/callback-secret只告诉你它是否存在,POST /transactions/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 | 收款已登记,二维码已生成。还不是付款。 |
PAYMENT_PAID | 收款已支付。在这里放行订单。 |
PAYMENT_REFUNDED | 银行自行退回了这笔收款。全额从账户扣出,手续费不退还。 |
PAYMENT_EXPIRED | 收款过期未支付。 |
WITHDRAW_CREATED | 已发起提现或 Pix 复制粘贴码付款,金额和手续费已从可用余额中扣出。 |
WITHDRAW_COMPLETED | 资金已到达目的地。 |
WITHDRAW_FAILED | 提现失败,金额和手续费已退回余额。 |
REFUND_COMPLETED | 通过 API、控制台或客服发起的退款已完成:金额已退还给付款人。 |
REFUND_FAILED | 退款被拒,金额已退回余额。 |
DEPOSIT_RECEIVED | 账户的某个密钥收到了一笔无收款单的 Pix。金额已入账。 |
INTERNAL_TRANSFER_SENT | 账户发出了一笔转账。 |
INTERNAL_TRANSFER_RECEIVED | 账户收到了一笔转账。 |
WITHDRAW_REFUND_RECEIVED | 账户发出的 Pix 被收款方全部或部分退回。 |
INFRACTION_OPENED | 针对账户发起了 MED 争议。 |
INFRACTION_CLOSED | 争议已结束或已取消。请求体中的 status 指明是哪种。 |
INFRACTION_DEADLINE | 争议的答复期限临近:剩余 48、24 或 6 小时。 |
ACCOUNT_BLOCKED | 银行冻结了账户操作,或冻结列表发生变化。blockedOperations 指明 API 会拒绝哪些操作。 |
ACCOUNT_UNBLOCKED | 冻结已解除。 |
通过 API 发起的全额退款会把收款变为 REFUNDED,但 Webhook 是 REFUND_COMPLETED,不是 PAYMENT_REFUNDED。
请求
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...{
"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,使用账户的回调密钥。
读取 X-Payzu-Timestamp、X-Payzu-Signature,以及原样到达的请求体。重新序列化 JSON 会改变空格和键的顺序,签名将无法匹配。
以十六进制计算 HMAC-SHA256(secret, "<timestamp>.<body>"),并以恒定时间与 sha256= 之后的值比较。
拒绝超出容差窗口的时间戳。每次尝试都在发送时签名,所以重试的时间戳总是最新的。
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 可能不止一次到达。用
X-Payzu-Delivery丢弃已处理过的 Webhook,它在每次尝试和重发时都相同。 - 要按操作去重,请使用事件 +
data标识这一组合,但有两个例外:INFRACTION_DEADLINE每个争议最多到达三次,每个hoursRemaining一次;ACCOUNT_BLOCKED和ACCOUNT_UNBLOCKED没有标识。 - 已放弃的投递之后再重发时,顺序会丢失;不同账户之间也没有顺序。有
data.status的地方,它表示事件发生时的状态:在PAYMENT_PAID之后到达的PAYMENT_CREATED不会撤销付款。在冻结相关的 Webhook 中,以最新的changedAt为准。