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.:
422por 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 webhookERRORtambién llega. En ambos casos puede cerrar el pedido de inmediato. Corregir y reintentar con un nuevox-idempotency-keyaplica solo al rechazo previo a la transferencia; cuando el rechazo vino del proveedor, espere el webhookERRORantes 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(LIQUIDATEDoERROR), correlacionando pordata.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
typestringSiempre "TRANSFER" para PIX enviados.
data.idnumberID de la transacción. Mismo valor retornado por POST /dict/pix.
data.endToEndIdstringEnd to End ID - identificador único de la transacción PIX en el Banco Central.
data.statusstringEstado de la transferencia:
LIQUIDATED: Transferencia confirmada (exito)ERROR: Transferencia fallida
data.paymentobjectdata.idempotencyKeystringClave de idempotencia enviada en el header x-idempotency-key de la solicitud original.
data.creditorAccountobjectDatos de quien recibio (el destinatario).
data.creditDebitTypestringSiempre "DEBIT" para transferencias enviadas.
data.errorCodestringCódigo de error cuando status === 'ERROR'. Puede ser null en caso de exito.
data.remittanceInformationstringDescripció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 originalManejo de Errores
Códigos de error comunes:
| Código | Descripción | Acción Recomendada |
|---|---|---|
INSUFFICIENT_BALANCE | Saldo insuficiente | Verificar saldo antes de transferir |
INVALID_KEY | Clave PIX invalida | Verificar la clave con el usuario |
KEY_NOT_FOUND | Clave no encontrada en DICT | Solicitar una clave valida |
ACCOUNT_BLOCKED | Cuenta bloqueada | Contactar soporte |
TIMEOUT | Timeout de procesamiento | Intentar 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
endIdempotencia
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);
}