docs
Webhooks V2

REFUND

Visión General

El webhook REFUND se envía cuando una devolución PIX cambia de estado. Existen dos escenarios, cada uno ligado a un evento registrado en el panel:

  1. Evento refund_in (CashInReversal): usted devolvió un PIX recibido (por ejemplo, vía PUT /api/pix/:e2eid/devolucao/:id)
  2. Evento refund_out (CashOutReversal): el destinatario devolvió un PIX que usted envió

Las dos situaciones llegan con type: "REFUND". Para recibir en formato V2, registre las URLs de esos eventos con el formato PIX BACEN.

Estructura del Payload

{
  "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": "Devolución solicitada por el receptor"
      }
    ],
    "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": "Devolución solicitada por el receptor"
  },
  "type": "REFUND"
}

refund_in vs refund_out

Usted devolvió un PIX recibido.

creditDebitType = DEBIT (saliendo de su cuenta)
debtorAccount = Su cuenta (campos null)
creditorAccount = Quien recibe de vuelta

Ejemplo: usted recibió R$ 100 y devolvió R$ 50.

El destinatario devolvió un PIX que usted envió.

creditDebitType = CREDIT (entrando en su cuenta)
debtorAccount = Quien está devolviendo
creditorAccount = Su cuenta (campos null)

Ejemplo: usted envió R$ 100 y el destinatario devolvió R$ 30.

Campos Importantes

typestring

Siempre "REFUND".

data.idnumber

ID de la transacción original (no de la devolución).

data.txIdstring

Identificador externo de la transacción original: el txid del cobro, o el x-idempotency-key de la transferencia original. El mismo valor viene en data.idempotencyKey.

data.statusstring

Estado de la devolución que generó el webhook:

  • REFUNDED: devolución concluida
  • ERROR: falla en la devolución
  • PENDING: devolución en procesamiento
data.paymentobject

Valor de la transacción original, no de la devolución.

data.refundsarray

Contiene un único ítem: la devolución que generó este webhook. Cada devolución parcial llega en su propio webhook.

data.providerIdstring

ID de la devolución en el proveedor de liquidación.

data.creditDebitTypestring

Dirección del dinero:

  • DEBIT: saliendo de su cuenta (refund_in)
  • CREDIT: entrando en su cuenta (refund_out)
data.endToEndIdstring

End to End ID de la transacción original.

data.createdAtstring

Fecha de creación de la transacción original.

Procesando el Webhook

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

Ejemplo 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'])

Devoluciones Parciales

Una transacción puede tener varias devoluciones parciales. Cada una genera su propio webhook REFUND, con el mismo data.id y data.payment.amount (los de la transacción original) y un ítem diferente en refunds.

Para saber cuánto aún se puede devolver, sume en su sistema las devoluciones concluidas:

const valorOriginal = Number(data.payment.amount);
const totalDevuelto = await sumCompletedRefunds(data.id);
const saldoDisponible = valorOriginal - totalDevuelto;

Atención: amount es number en refunds

Dentro del array refunds, el campo payment.amount es number, no string.

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

originalAmount es un string (ej.: "100") y refundAmount es un número (ej.: 50).

Idempotencia

Como data.id es el de la transacción original, se repite entre devoluciones parciales. Deduplique por la devolución:

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

Manejo de Errores

Si data.status es ERROR, la devolución falló. El código queda en data.errorCode y en refunds[0].errorCode:

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

Buenas Prácticas

Próximos Pasos

En esta página