PayZuDocs

每当账户发生变化,例如收款已支付、提现已完成或争议已发起,你的服务器都会收到一个已签名的 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 返回 409 WEBHOOK_HAS_DELIVERIES。

操作的 callbackUrl

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

  • 事先用 POST /transactions/callback-secret 签发账户的回调密钥。没有它,带 callbackUrl 的操作会以 412 CALLBACK_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-Signaturesha256= 后接十六进制的 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 为准。

本页内容