EYTdocs
Webhooks V2

TRANSFER

Descripción general

El webhook TRANSFER se envia cuando una transferencia PIX iniciada por su aplicación es procesada. Este evento indica el resultado (exito o fallo) de una llamada al endpoint /dict/pix.

Cuando se envia

  • Transferencia PIX procesada exitosamente (LIQUIDATED)
  • Transferencia PIX fallida (ERROR)

Garantía de estado final

Toda transferencia aceptada (HTTP 200) que sea rechazada de forma definitiva por el proveedor de pago emite un webhook TRANSFER con status: ERROR y data.errorCode. El rechazo del PSP nunca es silencioso.

De ahí se deriva el contrato de lectura de la respuesta síncrona del POST /dict/pix:

  • 4xx síncrono = rechazo declarado, estado FINAL. O bien la solicitud fue rechazada antes de que existiera la transferencia (ej.: 422 por clave no encontrada en el DICT, saldo insuficiente o ISPB bloqueado) — en cuyo caso no llegará webhook, porque ninguna transferencia fue creada — o bien el proveedor rechazó la transferencia ya creada, y entonces el webhook ERROR también llega. En ambos casos puede cerrar el pedido de inmediato. Corregir y reintentar con un nuevo x-idempotency-key aplica solo al rechazo previo a la transferencia; cuando el rechazo vino del proveedor, espere el webhook ERROR antes de cualquier nuevo intento.
  • Un 5xx síncrono nunca es estado final. Significa "indeterminado": el pago puede haber salido. No cancele el pedido ni lo trate como fallo — espere el webhook TRANSFER (LIQUIDATED o ERROR), correlacionando por data.idempotencyKey. Reintentar aquí con otra clave de idempotencia crea riesgo de pago duplicado.
  • El estado final viene solamente del webhook — nunca se infiere de un 5xx síncrono.

La garantía anterior cubre el rechazo definitivo del proveedor. Fallos internos de la plataforma pueden cerrar la transferencia como ERROR sin emitir webhook. Si recibió un 5xx síncrono y ningún webhook llegó en unos minutos, contacte a soporte con el x-idempotency-key de la solicitud — no reintente por otro medio sin esa confirmación.

Estructura del Payload

{
  "type": "TRANSFER",
  "data": {
    "id": 456,
    "txId": null,
    "pixKey": "destino@email.com",
    "status": "LIQUIDATED",
    "payment": {
      "amount": "100.50",
      "currency": "BRL"
    },
    "refunds": [],
    "createdAt": "2024-01-15T10:30:00.000Z",
    "errorCode": null,
    "endToEndId": "E12345678901234567890123456789012",
    "ticketData": {},
    "webhookType": "TRANSFER",
    "debtorAccount": {
      "ispb": null,
      "name": null,
      "issuer": null,
      "number": null,
      "document": null,
      "accountType": null
    },
    "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
    "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": "Pagamento NF 12345"
  }
}

Campos Importantes

typestring

Siempre "TRANSFER" para PIX enviados.

data.idnumber

ID de la transacción. Mismo valor retornado por POST /dict/pix.

data.endToEndIdstring

End to End ID - identificador único de la transacción PIX en el Banco Central.

data.statusstring

Estado de la transferencia:

  • LIQUIDATED: Transferencia confirmada (exito)
  • ERROR: Transferencia fallida
data.paymentobject

data.idempotencyKeystring

Clave de idempotencia enviada en el header x-idempotency-key de la solicitud original.

data.creditorAccountobject

Datos de quien recibio (el destinatario).

data.creditDebitTypestring

Siempre "DEBIT" para transferencias enviadas.

data.errorCodestring

Código de error cuando status === 'ERROR'. Puede ser null en caso de exito.

data.remittanceInformationstring

Descripción de la transferencia (campo description enviado en la solicitud).

Procesamiento del Webhook

Ejemplo en Node.js

interface TransferWebhook {
  type: 'TRANSFER';
  data: {
    id: number;
    status: 'LIQUIDATED' | 'ERROR';
    payment: {
      amount: string;
      currency: string;
    };
    endToEndId: string;
    idempotencyKey: string;
    creditorAccount: {
      name: string | null;
      document: string | null;
    };
    errorCode: string | null;
  };
}

async function handleTransfer(webhook: TransferWebhook) {
  const { data } = webhook;

  // Buscar transferencia por idempotencyKey
  const transfer = await findTransferByIdempotencyKey(data.idempotencyKey);

  if (!transfer) {
    console.warn(`Transferencia no encontrada: ${data.idempotencyKey}`);
    return;
  }

  if (data.status === 'LIQUIDATED') {
    // Exito - confirmar transferencia
    await updateTransfer(transfer.id, {
      status: 'COMPLETED',
      endToEndId: data.endToEndId,
      completedAt: new Date(),
    });

    // Notificar al usuario
    await notifyTransferSuccess({
      transferId: transfer.id,
      amount: parseFloat(data.payment.amount),
      recipient: data.creditorAccount.name,
    });

  } else if (data.status === 'ERROR') {
    // Fallo - revertir
    await updateTransfer(transfer.id, {
      status: 'FAILED',
      errorCode: data.errorCode,
    });

    // Notificar al usuario
    await notifyTransferFailed({
      transferId: transfer.id,
      errorCode: data.errorCode,
    });

    // Liberar saldo bloqueado
    await releaseBlockedBalance(transfer.id);
  }
}

Ejemplo en Python

from decimal import Decimal

def handle_transfer(webhook: dict):
    data = webhook['data']

    # Buscar transferencia
    transfer = find_transfer_by_idempotency_key(data['idempotencyKey'])

    if not transfer:
        print(f"Transferencia no encontrada: {data['idempotencyKey']}")
        return

    if data['status'] == 'LIQUIDATED':
        # Exito
        update_transfer(
            transfer_id=transfer.id,
            status='COMPLETED',
            e2e_id=data['endToEndId']
        )

        notify_transfer_success(
            transfer_id=transfer.id,
            amount=Decimal(data['payment']['amount']),
            recipient=data['creditorAccount'].get('name')
        )

    elif data['status'] == 'ERROR':
        # Fallo
        update_transfer(
            transfer_id=transfer.id,
            status='FAILED',
            error_code=data['errorCode']
        )

        notify_transfer_failed(
            transfer_id=transfer.id,
            error_code=data['errorCode']
        )

        # Liberar saldo
        release_blocked_balance(transfer.id)

Correlacion con la Solicitud

Use idempotencyKey para correlacionar el webhook con su solicitud original:

// 1. Crear transferencia
const idempotencyKey = crypto.randomUUID();
const transfer = await createTransfer(idempotencyKey, {
  pixKey: 'destino@email.com',
  amount: 100.50,
});

// 2. Guardar asociacion
await saveTransfer({
  id: transfer.id,
  idempotencyKey,
  status: 'PENDING',
});

// 3. En el webhook TRANSFER
const savedTransfer = await findByIdempotencyKey(webhook.data.idempotencyKey);
// savedTransfer.id corresponde a la transferencia original

Manejo de Errores

Códigos de error comunes:

CódigoDescripciónAcción Recomendada
INSUFFICIENT_BALANCESaldo insuficienteVerificar saldo antes de transferir
INVALID_KEYClave PIX invalidaVerificar la clave con el usuario
KEY_NOT_FOUNDClave no encontrada en DICTSolicitar una clave valida
ACCOUNT_BLOCKEDCuenta bloqueadaContactar soporte
TIMEOUTTimeout de procesamientoIntentar nuevamente
if (data.status === 'ERROR') {
  switch (data.errorCode) {
    case 'INSUFFICIENT_BALANCE':
      // Notificar saldo insuficiente
      await notifyInsufficientBalance(transfer);
      break;

    case 'INVALID_KEY':
    case 'KEY_NOT_FOUND':
      // Solicitar nueva clave al usuario
      await requestNewPixKey(transfer);
      break;

    case 'TIMEOUT':
      // Puede intentar de nuevo con una nueva clave de idempotencia
      await retryTransfer(transfer);
      break;

    default:
      // Error generico
      await notifyGenericError(transfer, data.errorCode);
  }
}

Flujo de Saldo

sequenceDiagram
    participant App
    participant API
    participant Bank

    Note over App,Bank: Saldo: available=1000, blocked=0

    App->>API: POST /dict/pix (R$ 100)
    API-->>App: { type: PENDING }

    Note over App,Bank: Saldo: available=900, blocked=100

    alt Exito
        Bank->>API: Confirmacion
        API->>App: Webhook TRANSFER (LIQUIDATED)
        Note over App,Bank: Saldo: available=900, blocked=0
    else Fallo
        Bank->>API: Error
        API->>App: Webhook TRANSFER (ERROR)
        Note over App,Bank: Saldo: available=1000, blocked=0
    end

Idempotencia

Use data.id para evitar el procesamiento duplicado:

async function handleWebhook(webhook: TransferWebhook) {
  const webhookId = `transfer:${webhook.data.id}`;

  const isProcessed = await redis.sismember('processed', webhookId);
  if (isProcessed) {
    return; // Ya procesado
  }

  await redis.sadd('processed', webhookId);
  await handleTransfer(webhook);
}

Mejores Prácticas

Próximos Pasos

En esta página