docs
Webhooks V2

概述

什么是 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_inRECEIVE
cash_outTRANSFER
refund_inREFUND(您退还了一笔已收到的 PIX)
refund_outREFUND(您收回了一笔已发送的 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;
}
字段描述
idFyPay 中的交易 ID。在 REFUND 中为原始交易的 ID。在 TRANSFER 中,如果转账在创建前就被拒绝,可能为 null
txId交易的外部标识符:RECEIVE 中为收款的 txid,TRANSFER 中为 x-idempotency-key,REFUND 中为原始交易的外部标识符
endToEndIdPIX 的 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 的退款
errorCodestatus 为 ERROR 时的错误码;否则为 null。错误码集合取决于提供商
debtorAccount / creditorAccount对手方数据。您自己账户的数据所有字段始终为 null
name / bankName账户持有人姓名 / 机构名称
document对手方的 CPF/CNPJ,按提供商提供的内容返回
ticketData始终为 {}

V1 与 V2 的区别

方面V1V2
格式字段位于根层级{data, type} 信封
事件类型event: "CashIn"type: "RECEIVE"
方面V1V2
PIX 成功CONFIRMEDLIQUIDATED
退款成功CONFIRMEDREFUNDED
错误ERRORERROR
方面V1V2
字段counterpartdebtorAccount / creditorAccount
银行bank.bankNamebankName
ISPBbank.bankISPBispb
方面V1V2
类型numberstring(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 重发。

请实现定期查询作为兜底,确保不遗漏任何交易。

下一步

本页目录