REFUND
Overview
The REFUND webhook is sent when a PIX refund is processed. There are two scenarios:
- CashInReversal: You refunded a received PIX (via
/pix/:e2eid/devolucao/:id) - CashOutReversal: Someone refunded a PIX that you sent
When it is sent
- Refund of a received PIX confirmed (you are refunding)
- Refund of a sent PIX received (someone is refunding to you)
Payload Structure
{
"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"
}
}Difference between CashInReversal and CashOutReversal
You refunded a received PIX.
creditDebitType = DEBIT (leaving your account)
debtorAccount = Your account
creditorAccount = Who will receive backExample: You received R$ 100, then refunded R$ 50.
Someone refunded a PIX that you sent.
creditDebitType = CREDIT (entering your account)
debtorAccount = Who is refunding
creditorAccount = Your accountExample: You sent R$ 100, the recipient refunded R$ 30.
Important Fields
typestringAlways "REFUND" for refunds.
data.idnumberOriginal transaction ID (not the refund's).
data.statusstringStatus of the original transaction after the refund:
REFUNDED: Refund processedERROR: Refund failed
data.paymentobjectOriginal transaction amount, not the refund amount.
data.refundsarrayList of refunds made. Contains details of each refund.
data.creditDebitTypestringMoney direction:
DEBIT: Leaving your account (CashInReversal)CREDIT: Entering your account (CashOutReversal)
data.endToEndIdstringOriginal transaction E2E ID.
Processing the Webhook
Node.js Example
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, not string!
currency: string;
};
endToEndId: string;
eventDate: string;
information: string | null;
}>;
endToEndId: string;
creditDebitType: 'CREDIT' | 'DEBIT';
};
}
async function handleRefund(webhook: RefundWebhook) {
const { data } = webhook;
// Identify refund type
const isCashInReversal = data.creditDebitType === 'DEBIT';
if (isCashInReversal) {
// You refunded a received PIX
await handleCashInReversal(data);
} else {
// Someone refunded a PIX that you sent
await handleCashOutReversal(data);
}
}
async function handleCashInReversal(data: RefundWebhook['data']) {
// Find original transaction
const original = await findTransactionByE2eId(data.endToEndId);
// Process each refund
for (const refund of data.refunds) {
if (refund.status === 'LIQUIDATED') {
// Refund confirmed - debit from balance
await processRefundOut({
originalId: original.id,
refundAmount: refund.payment.amount, // already a number
refundE2eId: refund.endToEndId,
});
console.log(`Refunded R$ ${refund.payment.amount} from PIX ${original.id}`);
}
}
}
async function handleCashOutReversal(data: RefundWebhook['data']) {
// Find original transfer
const original = await findTransferByE2eId(data.endToEndId);
// Process each received refund
for (const refund of data.refunds) {
if (refund.status === 'LIQUIDATED') {
// Refund received - credit to balance
await processRefundIn({
originalId: original.id,
refundAmount: refund.payment.amount,
refundE2eId: refund.endToEndId,
});
console.log(`Received R$ ${refund.payment.amount} from refund`);
}
}
}Python Example
from decimal import Decimal
def handle_refund(webhook: dict):
data = webhook['data']
# Identify type
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):
"""You refunded a received PIX"""
original = find_transaction_by_e2e(data['endToEndId'])
for refund in data['refunds']:
if refund['status'] == 'LIQUIDATED':
# Already a number, convert to 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):
"""Someone refunded a PIX that you sent"""
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']
)Partial Refunds
A transaction can have multiple partial refunds. The refunds array contains all of them:
{
"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"
}
]
}
}Refund balance calculation:
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.00Note: amount is number in refunds
Inside the refunds array, the payment.amount field is a number, not a string!
// data.payment.amount -> string "100.00"
// data.refunds[0].payment.amount -> number 50.00
// CORRECT
const refundAmount = data.refunds[0].payment.amount; // 50.00 (number)
// WRONG - no parseFloat needed
const refundAmount = parseFloat(data.refunds[0].payment.amount);Idempotency
Use a combination of data.id and refunds[].endToEndId for idempotency:
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; // Already processed
}
await redis.sadd('processed', key);
await processRefund(webhook.data, refund);
}
}Error Handling
If refund.status === 'ERROR', the refund failed:
for (const refund of data.refunds) {
if (refund.status === 'ERROR') {
console.error(`Refund failed: ${refund.errorCode}`);
// Notify about failure
await notifyRefundFailed({
originalE2eId: data.endToEndId,
refundE2eId: refund.endToEndId,
errorCode: refund.errorCode,
});
}
}