docs
Webhooks V2

REFUND

概述

当一笔 PIX 退款的状态发生变化时,会发送 REFUND Webhook。存在两种情形,各自对应控制台中登记的一个事件:

  1. refund_in 事件(CashInReversal):您退还了一笔已收到的 PIX(例如通过 PUT /api/pix/:e2eid/devolucao/:id)
  2. refund_out 事件(CashOutReversal):收款人退还了您发送的一笔 PIX

两种情形都以 type: "REFUND" 送达。要以 V2 格式接收,请将这些事件的 URL 登记为 PIX BACEN 格式。

负载结构

{
  "data": {
    "id": 12345,
    "txId": "7978c0c97ea847e78e8849634473c1f1",
    "pixKey": "7d9f0335-8dcc-4054-9bf9-0dbd61d36906",
    "status": "REFUNDED",
    "payment": {
      "amount": "100",
      "currency": "BRL"
    },
    "refunds": [
      {
        "status": "LIQUIDATED",
        "payment": {
          "amount": 50,
          "currency": "BRL"
        },
        "errorCode": null,
        "eventDate": "2024-01-15T10:30:00.000Z",
        "endToEndId": "D12345678202401151030abcdefghijk",
        "information": "Devolução solicitada pelo recebedor"
      }
    ],
    "createdAt": "2024-01-15T09:00:00.000Z",
    "errorCode": null,
    "endToEndId": "E12345678202401150900abcdefghijk",
    "providerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
    "ticketData": {},
    "webhookType": "REFUND",
    "debtorAccount": {
      "ispb": null,
      "name": null,
      "bankName": null,
      "issuer": null,
      "number": null,
      "document": null,
      "accountType": null
    },
    "idempotencyKey": "7978c0c97ea847e78e8849634473c1f1",
    "creditDebitType": "DEBIT",
    "creditorAccount": {
      "ispb": "12345678",
      "name": "Carlos Oliveira",
      "bankName": "Banco Exemplo S.A.",
      "issuer": "000",
      "number": "123456",
      "document": "12345678909",
      "accountType": null
    },
    "localInstrument": "DICT",
    "transactionType": "PIX",
    "remittanceInformation": "Devolução solicitada pelo recebedor"
  },
  "type": "REFUND"
}

refund_in 与 refund_out

您退还了一笔已收到的 PIX。

creditDebitType = DEBIT (saindo da sua conta)
debtorAccount = Sua conta (campos null)
creditorAccount = Quem recebe de volta

示例:您收到 R$ 100,退还了 R$ 50。

收款人退还了您发送的一笔 PIX。

creditDebitType = CREDIT (entrando na sua conta)
debtorAccount = Quem está devolvendo
creditorAccount = Sua conta (campos null)

示例:您发送了 R$ 100,收款人退还了 R$ 30。

重要字段

typestring

始终为 "REFUND"。

data.idnumber

原始交易的 ID(不是退款的 ID)。

data.txIdstring

原始交易的外部标识符:收款的 txid,或原始转账的 x-idempotency-key。data.idempotencyKey 中为同一值。

data.statusstring

触发该 Webhook 的退款的状态:

  • REFUNDED:退款已完成
  • ERROR:退款失败
  • PENDING:退款处理中
data.paymentobject

原始交易的金额,而不是退款金额。

data.refundsarray

只包含一项:触发此 Webhook 的退款。每笔部分退款都会以各自的 Webhook 送达。

data.providerIdstring

退款在结算提供商处的 ID。

data.creditDebitTypestring

资金方向:

  • DEBIT:从您的账户流出(refund_in)
  • CREDIT:流入您的账户(refund_out)
data.endToEndIdstring

原始交易的 End to End ID。

data.createdAtstring

原始交易的创建日期。

处理 Webhook

Node.js 示例

interface RefundWebhook {
  type: 'REFUND';
  data: {
    id: number;
    txId: string | null;
    status: 'PENDING' | 'REFUNDED' | 'ERROR';
    payment: {
      amount: string;
      currency: 'BRL';
    };
    refunds: Array<{
      status: 'PENDING' | 'LIQUIDATED' | 'ERROR';
      payment: {
        amount: number;
        currency: 'BRL';
      };
      errorCode: string | null;
      endToEndId: string | null;
      eventDate: string;
      information: string | null;
    }>;
    endToEndId: string | null;
    providerId: string | null;
    creditDebitType: 'CREDIT' | 'DEBIT';
  };
}

async function handleRefund(webhook: RefundWebhook) {
  const { data } = webhook;
  const refund = data.refunds[0];

  if (data.creditDebitType === 'DEBIT') {
    await handleRefundIn(data, refund);
  } else {
    await handleRefundOut(data, refund);
  }
}

async function handleRefundIn(data: RefundWebhook['data'], refund: RefundWebhook['data']['refunds'][number]) {
  const original = await findReceiptByE2eId(data.endToEndId);

  if (data.status === 'REFUNDED') {
    await registerRefundSent({
      originalId: original.id,
      amount: refund.payment.amount,
      refundE2eId: refund.endToEndId,
    });
  }
}

async function handleRefundOut(data: RefundWebhook['data'], refund: RefundWebhook['data']['refunds'][number]) {
  const original = await findTransferByIdempotencyKey(data.txId);

  if (data.status === 'REFUNDED') {
    await registerRefundReceived({
      originalId: original.id,
      amount: refund.payment.amount,
      refundE2eId: refund.endToEndId,
    });
  }
}

Python 示例

from decimal import Decimal

def handle_refund(webhook: dict):
    data = webhook['data']
    refund = data['refunds'][0]

    if data['status'] != 'REFUNDED':
        return

    amount = Decimal(str(refund['payment']['amount']))

    if data['creditDebitType'] == 'DEBIT':
        original = find_receipt_by_e2e(data['endToEndId'])
        register_refund_sent(original.id, amount, refund['endToEndId'])
    else:
        original = find_transfer_by_idempotency_key(data['txId'])
        register_refund_received(original.id, amount, refund['endToEndId'])

部分退款

一笔交易可以有多次部分退款。每一次都会生成各自的 REFUND Webhook,data.id 和 data.payment.amount 相同(均为原始交易的值),refunds 中的项则各不相同。

要知道还可以退多少,请在您的系统中汇总已完成的退款:

const valorOriginal = Number(data.payment.amount);
const totalDevolvido = await sumCompletedRefunds(data.id);
const saldoDisponivel = valorOriginal - totalDevolvido;

注意:refunds 中的 amount 是 number

在 refunds 数组中,payment.amount 字段是 number,而不是 string。

const originalAmount = data.payment.amount;
const refundAmount = data.refunds[0].payment.amount;

originalAmount 是字符串(例如 "100"),refundAmount 是数字(例如 50)。

幂等性

由于 data.id 是原始交易的 ID,它会在多次部分退款之间重复。请按退款去重:

async function handleWebhook(webhook: RefundWebhook) {
  const refund = webhook.data.refunds[0];
  const refundKey = refund.endToEndId ?? webhook.data.providerId;
  const key = `refund:${webhook.data.id}:${refundKey}:${webhook.data.status}`;

  const isNew = await redis.set(`webhook:${key}`, '1', 'NX', 'EX', 60 * 60 * 24 * 7);
  if (!isNew) return;

  await handleRefund(webhook);
}

错误处理

如果 data.status 为 ERROR,则退款失败。错误码位于 data.errorCode 和 refunds[0].errorCode:

if (data.status === 'ERROR') {
  await notifyRefundFailed({
    originalE2eId: data.endToEndId,
    errorCode: data.errorCode,
  });
}

最佳实践

下一步

本页目录