REFUND
Overview
The REFUND webhook is sent when a PIX refund changes state. There are two scenarios, each tied to an event registered in the dashboard:
refund_inevent (CashInReversal): you refunded a received PIX (for example, viaPUT /api/pix/:e2eid/devolucao/:id)refund_outevent (CashOutReversal): the recipient refunded a PIX you sent
Both arrive with type: "REFUND". To receive them in the V2 format, register the URLs for these events with the PIX BACEN format.
Payload Structure
{
"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 vs refund_out
You refunded a received PIX.
creditDebitType = DEBIT (leaving your account)
debtorAccount = Your account (null fields)
creditorAccount = Who gets the money backExample: you received R$ 100 and refunded R$ 50.
The recipient refunded a PIX you sent.
creditDebitType = CREDIT (entering your account)
debtorAccount = Who is refunding
creditorAccount = Your account (null fields)Example: you sent R$ 100 and the recipient refunded R$ 30.
Key Fields
typestringAlways "REFUND".
data.idnumberID of the original transaction (not of the refund).
data.txIdstringExternal identifier of the original transaction: the charge txid, or the x-idempotency-key of the original transfer. The same value comes in data.idempotencyKey.
data.statusstringStatus of the refund that triggered the webhook:
REFUNDED: refund completedERROR: refund failedPENDING: refund being processed
data.paymentobjectAmount of the original transaction, not of the refund.
data.refundsarrayContains a single item: the refund that triggered this webhook. Each partial refund arrives in its own webhook.
data.providerIdstringID of the refund at the settlement provider.
data.creditDebitTypestringMoney direction:
DEBIT: leaving your account (refund_in)CREDIT: entering your account (refund_out)
data.endToEndIdstringEnd to End ID of the original transaction.
data.createdAtstringCreation date of the original transaction.
Processing the Webhook
Node.js Example
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 Example
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'])Partial Refunds
A transaction can have several partial refunds. Each one generates its own REFUND webhook, with the same data.id and data.payment.amount (those of the original transaction) and a different item in refunds.
To know how much can still be refunded, sum the completed refunds in your system:
const originalAmount = Number(data.payment.amount);
const totalRefunded = await sumCompletedRefunds(data.id);
const availableToRefund = originalAmount - totalRefunded;Note: amount is a number in refunds
Inside the refunds array, the payment.amount field is a number, not a string.
const originalAmount = data.payment.amount;
const refundAmount = data.refunds[0].payment.amount;originalAmount is a string (e.g. "100") and refundAmount is a number (e.g. 50).
Idempotency
Since data.id is the original transaction's, it repeats across partial refunds. Deduplicate by refund:
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);
}Error Handling
If data.status is ERROR, the refund failed. The code is in data.errorCode and in refunds[0].errorCode:
if (data.status === 'ERROR') {
await notifyRefundFailed({
originalE2eId: data.endToEndId,
errorCode: data.errorCode,
});
}