概述
什么是 V2 Webhook?
V2 Webhook 是在 PIX 交易状态变化时发送给您应用的通知。V2 格式使用 {data, type} 信封,将付款方(debtorAccount)和收款方(creditorAccount)分开,并使用 LIQUIDATED 和 REFUNDED 状态。
如何以 V2 格式接收
格式按 Webhook URL 选择。在控制台(https://banking.fypay.me)的 设置 → Webhooks 中登记 URL 时,选择负载版本 PIX BACEN。登记为 默认 (V1) 的 URL 继续接收 V1 格式。
V2 格式适用于交易事件:
| 登记的事件 | V2 Webhook 的 type |
|---|---|
cash_in | RECEIVE |
cash_out | TRANSFER |
refund_in | REFUND(您退还了一笔已收到的 PIX) |
refund_out | REFUND(您收回了一笔已发送的 PIX) |
MED 事件(med_created、med_accepted、med_rejected)不随版本变化:始终以 MED Webhook 中描述的格式投递。
基本结构
所有 V2 Webhook 都遵循以下结构:
{
"data": {
"...": "dados do evento"
},
"type": "RECEIVE"
}type 为 RECEIVE、TRANSFER 或 REFUND。
事件类型
| Type | 描述 | V1 对应 |
|---|---|---|
RECEIVE | 收到 PIX(Cash-In) | CashIn |
TRANSFER | 发送 PIX(Cash-Out) | CashOut |
REFUND | 退款(收款或付款的退款) | CashInReversal / CashOutReversal |
Data 完整结构
interface WebhookV2Data {
id: number | null;
txId: string | null;
endToEndId: string | null;
providerId: string | null;
pixKey: string | null;
status: 'PENDING' | 'LIQUIDATED' | 'REFUNDED' | 'ERROR';
payment: {
amount: string;
currency: 'BRL';
};
mismatchAmount?: string | null;
refunds: RefundInfo[];
createdAt: string;
errorCode: string | null;
webhookType: 'RECEIVE' | 'TRANSFER' | 'REFUND';
creditDebitType: 'CREDIT' | 'DEBIT';
transactionType: 'PIX';
localInstrument: 'DICT';
debtorAccount: AccountInfo;
creditorAccount: AccountInfo;
idempotencyKey: string | null;
ticketData: Record<string, unknown>;
remittanceInformation: string | null;
}
interface AccountInfo {
ispb: string | null;
name: string | null;
bankName: string | null;
issuer: string | null;
number: string | null;
document: string | null;
accountType: string | null;
}
interface RefundInfo {
status: 'PENDING' | 'LIQUIDATED' | 'ERROR';
payment: {
amount: number;
currency: 'BRL';
};
errorCode: string | null;
eventDate: string;
endToEndId: string | null;
information: string | null;
}| 字段 | 描述 |
|---|---|
id | FyPay 中的交易 ID。在 REFUND 中为原始交易的 ID。在 TRANSFER 中,如果转账在创建前就被拒绝,可能为 null |
txId | 交易的外部标识符:RECEIVE 中为收款的 txid,TRANSFER 中为 x-idempotency-key,REFUND 中为原始交易的外部标识符 |
endToEndId | PIX 的 End to End ID(在 REFUND 中为原始 PIX 的) |
providerId | 结算提供商处的交易 ID;尚未分配时为 null |
payment.amount | 以雷亚尔表示的金额,为十进制字符串,小数位数不固定(例如 "100.5"、"100"、"0.01") |
mismatchAmount | 仅在 RECEIVE 和 TRANSFER 中出现。应收金额与实付金额之差(如有);否则为 null |
refunds | 在 RECEIVE 和 TRANSFER 中为空。在 REFUND 中包含一项,即触发该 Webhook 的退款 |
errorCode | status 为 ERROR 时的错误码;否则为 null。错误码集合取决于提供商 |
debtorAccount / creditorAccount | 对手方数据。您自己账户的数据所有字段始终为 null |
name / bankName | 账户持有人姓名 / 机构名称 |
document | 对手方的 CPF/CNPJ,按提供商提供的内容返回 |
ticketData | 始终为 {} |
V1 与 V2 的区别
| 方面 | V1 | V2 |
|---|---|---|
| 格式 | 字段位于根层级 | {data, type} 信封 |
| 事件类型 | event: "CashIn" | type: "RECEIVE" |
| 方面 | V1 | V2 |
|---|---|---|
| PIX 成功 | CONFIRMED | LIQUIDATED |
| 退款成功 | CONFIRMED | REFUNDED |
| 错误 | ERROR | ERROR |
| 方面 | V1 | V2 |
|---|---|---|
| 字段 | counterpart | debtorAccount / creditorAccount |
| 银行 | bank.bankName | bankName |
| ISPB | bank.bankISPB | ispb |
| 方面 | V1 | V2 |
|---|---|---|
| 类型 | number | string(refunds[].payment.amount 除外,它是 number) |
| 格式 | 100.5 | "100.5" |
账户映射
收到 PIX(RECEIVE)
debtorAccount = Quem pagou (contraparte)
creditorAccount = Sua conta (campos null)
creditDebitType = CREDIT发送 PIX(TRANSFER)
debtorAccount = Sua conta (campos null)
creditorAccount = Quem recebeu (contraparte)
creditDebitType = DEBIT收款退款(REFUND,refund_in 事件)
debtorAccount = Sua conta (campos null)
creditorAccount = Quem recebe de volta (contraparte)
creditDebitType = DEBIT付款退款(REFUND,refund_out 事件)
debtorAccount = Quem está devolvendo (contraparte)
creditorAccount = Sua conta (campos null)
creditDebitType = CREDIT投递
- HTTPS URL;每个事件最多 3 个 URL
- 方法
POST,Content-Type: application/json - 超时 10 秒;只有 2xx 响应视为成功
- 每个 URL 最多 5 个自定义请求头,在控制台登记
- 来源 IP:
184.197.16.190、54.85.39.76、184.197.130.162、184.196.180.161、52.0.159.232、54.88.75.254
认证
在控制台生成签名密钥后,每个 Webhook 都会带上 X-Avista-Signature 请求头,其值为请求原始正文的 HMAC-SHA256(十六进制)。在生成密钥之前,Webhook 投递时不带该请求头。
请求头的名称确实是 X-Avista-Signature。请在解析 JSON 之前,基于收到的原始字节验证签名。参见如何验证。
FyPay 不会主动发送 Basic Auth。如果您的端点需要 Authorization 或其他密钥,请将其登记为自定义请求头。
处理程序示例
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.FYPAY_WEBHOOK_SECRET!;
const processed = new Set<string>();
function isValidSignature(rawBody: Buffer, signature: string | undefined) {
if (!signature) return false;
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function dedupKey(webhook: WebhookV2) {
const { type, data } = webhook;
if (type === 'REFUND') {
const refund = data.refunds[0];
return `REFUND:${refund?.endToEndId ?? data.providerId}:${refund?.status}`;
}
return `${type}:${data.id ?? data.idempotencyKey}:${data.status}`;
}
app.post('/webhooks/pix', (req, res) => {
if (!isValidSignature(req.body, req.header('x-avista-signature'))) {
return res.status(401).end();
}
const webhook: WebhookV2 = JSON.parse(req.body.toString('utf8'));
res.status(200).json({ acknowledged: true });
const key = dedupKey(webhook);
if (processed.has(key)) return;
processed.add(key);
switch (webhook.type) {
case 'RECEIVE':
handleReceive(webhook.data);
break;
case 'TRANSFER':
handleTransfer(webhook.data);
break;
case 'REFUND':
handleRefund(webhook.data);
break;
}
});
function handleReceive(data: WebhookV2Data) {
if (data.status === 'LIQUIDATED') {
console.log(`收到 PIX:R$ ${data.payment.amount}`);
}
}
function handleTransfer(data: WebhookV2Data) {
if (data.status === 'LIQUIDATED') {
console.log(`PIX 已发送:${data.endToEndId}`);
} else if (data.status === 'ERROR') {
console.log(`PIX 失败:${data.errorCode}`);
}
}
function handleRefund(data: WebhookV2Data) {
const refund = data.refunds[0];
if (data.status === 'REFUNDED') {
console.log(`退款:R$ ${refund.payment.amount}`);
}
}在生产环境中,请使用持久化存储(数据库或 Redis)进行去重。由于重试或手动重发,同一个 Webhook 可能会到达多次。
重试
如果您的端点未在 10 秒内返回 HTTP 2xx,投递将会重试:
| 尝试次数 | 间隔 | 累计 |
|---|---|---|
| 第 1 次 | 立即 | 0 |
| 第 2 次 | 30 秒 | 30 秒 |
| 第 3 次 | 1 分钟 | 1 分 30 秒 |
| 第 4 次 | 3 分钟 | 4 分 30 秒 |
| 第 5 次 | 5 分钟 | 9 分 30 秒 |
| 第 6 次 | 15 分钟 | 24 分 30 秒 |
6 次尝试均失败后,Webhook 不再自动重发。您可以通过 POST https://api.fypay.me/api/resend-webhook/{transactionIdentifier} 申请重发(每分钟最多 60 次请求)。重发使用每个 URL 登记的负载版本。参见 Webhook 重发。
请实现定期查询作为兜底,确保不遗漏任何交易。