Webhooks V2
REFUND
概述
REFUND Webhook 在 PIX 退款处理完成时发送。有两种场景:
- CashInReversal:您退还了收到的 PIX(通过
/pix/:e2eid/devolucao/:id) - CashOutReversal:对方退还了您发送的 PIX
发送时机
- 已收到 PIX 的退款确认(您在退款)
- 已发送 PIX 的退款到账(对方退款给您)
负载结构
{
"type": "REFUND",
"data": {
"id": 123,
"txId": "7978c0c97ea847e78e8849634473c1f1",
"pixKey": "7d9f0335-8dcc-4054-9bf9-0dbd61d36906",
"status": "REFUNDED",
"payment": {
"amount": "100.00",
"currency": "BRL"
},
"refunds": [
{
"status": "LIQUIDATED",
"payment": {
"amount": 50.00,
"currency": "BRL"
},
"errorCode": null,
"eventDate": "2024-01-15T10:30:00.000Z",
"endToEndId": "D12345678901234567890123456789012",
"information": "Devolução solicitada pelo recebedor"
}
],
"createdAt": "2024-01-15T09:00:00.000Z",
"errorCode": null,
"endToEndId": "E12345678901234567890123456789012",
"ticketData": {},
"webhookType": "REFUND",
"debtorAccount": {
"ispb": null,
"name": null,
"issuer": null,
"number": null,
"document": null,
"accountType": null
},
"idempotencyKey": "7978c0c97ea847e78e8849634473c1f1",
"creditDebitType": "DEBIT",
"creditorAccount": {
"ispb": "18236120",
"name": "NU PAGAMENTOS S.A.",
"issuer": "260",
"number": "12345-6",
"document": "123.xxx.xxx-xx",
"accountType": null
},
"localInstrument": "DICT",
"transactionType": "PIX",
"remittanceInformation": "Devolução parcial"
}
}CashInReversal 与 CashOutReversal 的区别
您退还了收到的 PIX。
creditDebitType = DEBIT(从您的账户流出)
debtorAccount = 您的账户
creditorAccount = 退款接收方示例:您收到了 R$ 100,然后退还了 R$ 50。
对方退还了您发送的 PIX。
creditDebitType = CREDIT(流入您的账户)
debtorAccount = 退款方
creditorAccount = 您的账户示例:您发送了 R$ 100,收款方退还了 R$ 30。
重要字段
typestring退款时始终为 "REFUND"。
data.idnumber原始交易 ID(非退款的 ID)。
data.statusstring退款后原始交易的状态:
REFUNDED:退款已处理ERROR:退款失败
data.paymentobject原始交易金额,非退款金额。
data.refundsarray已执行的退款列表。包含每次退款的详细信息。
data.creditDebitTypestring资金方向:
DEBIT:从您的账户流出(CashInReversal)CREDIT:流入您的账户(CashOutReversal)
data.endToEndIdstring原始交易的 E2E ID。
处理 Webhook
Node.js 示例
interface RefundWebhook {
type: 'REFUND';
data: {
id: number;
txId: string | null;
status: 'REFUNDED' | 'ERROR';
payment: {
amount: string;
currency: string;
};
refunds: Array<{
status: 'LIQUIDATED' | 'ERROR';
payment: {
amount: number; // number 类型,不是 string!
currency: string;
};
endToEndId: string;
eventDate: string;
information: string | null;
}>;
endToEndId: string;
creditDebitType: 'CREDIT' | 'DEBIT';
};
}
async function handleRefund(webhook: RefundWebhook) {
const { data } = webhook;
// 识别退款类型
const isCashInReversal = data.creditDebitType === 'DEBIT';
if (isCashInReversal) {
// 您退还了收到的 PIX
await handleCashInReversal(data);
} else {
// 对方退还了您发送的 PIX
await handleCashOutReversal(data);
}
}
async function handleCashInReversal(data: RefundWebhook['data']) {
// 查找原始交易
const original = await findTransactionByE2eId(data.endToEndId);
// 处理每笔退款
for (const refund of data.refunds) {
if (refund.status === 'LIQUIDATED') {
// 退款已确认 - 从余额中扣除
await processRefundOut({
originalId: original.id,
refundAmount: refund.payment.amount, // 已经是 number 类型
refundE2eId: refund.endToEndId,
});
console.log(`Refunded R$ ${refund.payment.amount} from PIX ${original.id}`);
}
}
}
async function handleCashOutReversal(data: RefundWebhook['data']) {
// 查找原始转账
const original = await findTransferByE2eId(data.endToEndId);
// 处理每笔收到的退款
for (const refund of data.refunds) {
if (refund.status === 'LIQUIDATED') {
// 收到退款 - 入账到余额
await processRefundIn({
originalId: original.id,
refundAmount: refund.payment.amount,
refundE2eId: refund.endToEndId,
});
console.log(`Received R$ ${refund.payment.amount} from refund`);
}
}
}Python 示例
from decimal import Decimal
def handle_refund(webhook: dict):
data = webhook['data']
# 识别类型
is_cash_in_reversal = data['creditDebitType'] == 'DEBIT'
if is_cash_in_reversal:
handle_cash_in_reversal(data)
else:
handle_cash_out_reversal(data)
def handle_cash_in_reversal(data: dict):
"""您退还了收到的 PIX"""
original = find_transaction_by_e2e(data['endToEndId'])
for refund in data['refunds']:
if refund['status'] == 'LIQUIDATED':
# 已经是 number 类型,转换为 Decimal
amount = Decimal(str(refund['payment']['amount']))
process_refund_out(
original_id=original.id,
refund_amount=amount,
refund_e2e=refund['endToEndId']
)
def handle_cash_out_reversal(data: dict):
"""对方退还了您发送的 PIX"""
original = find_transfer_by_e2e(data['endToEndId'])
for refund in data['refunds']:
if refund['status'] == 'LIQUIDATED':
amount = Decimal(str(refund['payment']['amount']))
process_refund_in(
original_id=original.id,
refund_amount=amount,
refund_e2e=refund['endToEndId']
)部分退款
一笔交易可以有多次部分退款。refunds 数组包含所有退款记录:
{
"type": "REFUND",
"data": {
"payment": { "amount": "100.00" },
"refunds": [
{
"payment": { "amount": 30.00 },
"eventDate": "2024-01-15T10:00:00Z"
},
{
"payment": { "amount": 50.00 },
"eventDate": "2024-01-15T11:00:00Z"
}
]
}
}退款余额计算:
const originalAmount = parseFloat(data.payment.amount); // 100.00
const totalRefunded = data.refunds
.filter(r => r.status === 'LIQUIDATED')
.reduce((sum, r) => sum + r.payment.amount, 0); // 80.00
const availableBalance = originalAmount - totalRefunded; // 20.00注意:退款中的 amount 是 number 类型
在 refunds 数组内部,payment.amount 字段是 number 类型,不是 string!
// data.payment.amount -> string "100.00"
// data.refunds[0].payment.amount -> number 50.00
// 正确
const refundAmount = data.refunds[0].payment.amount; // 50.00 (number)
// 错误 - 不需要 parseFloat
const refundAmount = parseFloat(data.refunds[0].payment.amount);幂等性
使用 data.id 和 refunds[].endToEndId 的组合实现幂等性:
async function handleWebhook(webhook: RefundWebhook) {
for (const refund of webhook.data.refunds) {
const key = `refund:${webhook.data.id}:${refund.endToEndId}`;
const isProcessed = await redis.sismember('processed', key);
if (isProcessed) {
continue; // 已处理
}
await redis.sadd('processed', key);
await processRefund(webhook.data, refund);
}
}错误处理
如果 refund.status === 'ERROR',说明退款失败:
for (const refund of data.refunds) {
if (refund.status === 'ERROR') {
console.error(`Refund failed: ${refund.errorCode}`);
// 通知退款失败
await notifyRefundFailed({
originalE2eId: data.endToEndId,
refundE2eId: refund.endToEndId,
errorCode: refund.errorCode,
});
}
}