docs
Webhooks V2

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:

  1. refund_in event (CashInReversal): you refunded a received PIX (for example, via PUT /api/pix/:e2eid/devolucao/:id)
  2. refund_out event (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 back

Example: 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

typestring

Always "REFUND".

data.idnumber

ID of the original transaction (not of the refund).

data.txIdstring

External 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.statusstring

Status of the refund that triggered the webhook:

  • REFUNDED: refund completed
  • ERROR: refund failed
  • PENDING: refund being processed
data.paymentobject

Amount of the original transaction, not of the refund.

data.refundsarray

Contains a single item: the refund that triggered this webhook. Each partial refund arrives in its own webhook.

data.providerIdstring

ID of the refund at the settlement provider.

data.creditDebitTypestring

Money direction:

  • DEBIT: leaving your account (refund_in)
  • CREDIT: entering your account (refund_out)
data.endToEndIdstring

End to End ID of the original transaction.

data.createdAtstring

Creation 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,
  });
}

Best Practices

Next Steps

On this page