TRANSFER
Overview
The TRANSFER webhook is sent when a PIX transfer initiated by your application is processed. This event indicates the result (success or failure) of a call to the /dict/pix endpoint.
When it is sent
- PIX transfer processed successfully (
LIQUIDATED) - PIX transfer failed (
ERROR)
Final-state guarantee
Every accepted transfer (HTTP 200) that is definitively rejected by the payment provider
emits a TRANSFER webhook with status: ERROR and data.errorCode. A PSP rejection is
never silent.
This gives you the contract for reading the synchronous response of POST /dict/pix:
- Synchronous 4xx = a declared rejection, FINAL state. Either the request was refused
before a transfer existed (e.g.
422for a key not found in DICT, insufficient balance or a blocked ISPB) — in which case no webhook will arrive, since no transfer was created — or the provider refused the transfer after it was created, and then theERRORwebhook also arrives. In both cases you may close the order immediately. Fixing and retrying with a newx-idempotency-keyapplies only to the pre-transfer rejection; when the rejection came from the provider, wait for theERRORwebhook before any new attempt. - A synchronous 5xx is never a final state. It means "indeterminate": the payment may have
gone through. Do not cancel the order or treat it as a failure — wait for the
TRANSFERwebhook (LIQUIDATEDorERROR), correlating bydata.idempotencyKey. Retrying here with a different idempotency key creates double-payment risk. - Final state comes only from the webhook — never inferred from a synchronous 5xx.
The guarantee above covers definitive provider rejections. Internal platform
failures may close the transfer as ERROR without emitting a webhook. If you got a
synchronous 5xx and no webhook arrived within a few minutes, contact support with the
request's x-idempotency-key — do not retry through another rail without that confirmation.
Payload Structure
{
"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"
}
}Important Fields
typestringAlways "TRANSFER" for sent PIX.
data.idnumberTransaction ID. Same value returned by POST /dict/pix.
data.endToEndIdstringEnd to End ID - unique identifier of the PIX transaction at the Central Bank.
data.statusstringTransfer status:
LIQUIDATED: Transfer confirmed (success)ERROR: Transfer failed
data.paymentobjectdata.idempotencyKeystringIdempotency key sent in the x-idempotency-key header of the original request.
data.creditorAccountobjectData of who received (the recipient).
data.creditDebitTypestringAlways "DEBIT" for sent transfers.
data.errorCodestringError code when status === 'ERROR'. Can be null on success.
data.remittanceInformationstringTransfer description (description field sent in the request).
Processing the Webhook
Node.js Example
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;
// Find transfer by idempotencyKey
const transfer = await findTransferByIdempotencyKey(data.idempotencyKey);
if (!transfer) {
console.warn(`Transfer not found: ${data.idempotencyKey}`);
return;
}
if (data.status === 'LIQUIDATED') {
// Success - confirm transfer
await updateTransfer(transfer.id, {
status: 'COMPLETED',
endToEndId: data.endToEndId,
completedAt: new Date(),
});
// Notify user
await notifyTransferSuccess({
transferId: transfer.id,
amount: parseFloat(data.payment.amount),
recipient: data.creditorAccount.name,
});
} else if (data.status === 'ERROR') {
// Failure - revert
await updateTransfer(transfer.id, {
status: 'FAILED',
errorCode: data.errorCode,
});
// Notify user
await notifyTransferFailed({
transferId: transfer.id,
errorCode: data.errorCode,
});
// Release blocked balance
await releaseBlockedBalance(transfer.id);
}
}Python Example
from decimal import Decimal
def handle_transfer(webhook: dict):
data = webhook['data']
# Find transfer
transfer = find_transfer_by_idempotency_key(data['idempotencyKey'])
if not transfer:
print(f"Transfer not found: {data['idempotencyKey']}")
return
if data['status'] == 'LIQUIDATED':
# Success
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':
# Failure
update_transfer(
transfer_id=transfer.id,
status='FAILED',
error_code=data['errorCode']
)
notify_transfer_failed(
transfer_id=transfer.id,
error_code=data['errorCode']
)
# Release balance
release_blocked_balance(transfer.id)Correlation with Request
Use idempotencyKey to correlate the webhook with your original request:
// 1. Create transfer
const idempotencyKey = crypto.randomUUID();
const transfer = await createTransfer(idempotencyKey, {
pixKey: 'destino@email.com',
amount: 100.50,
});
// 2. Save association
await saveTransfer({
id: transfer.id,
idempotencyKey,
status: 'PENDING',
});
// 3. In the TRANSFER webhook
const savedTransfer = await findByIdempotencyKey(webhook.data.idempotencyKey);
// savedTransfer.id corresponds to the original transferError Handling
Common error codes:
| Code | Description | Recommended Action |
|---|---|---|
INSUFFICIENT_BALANCE | Insufficient balance | Check balance before transferring |
INVALID_KEY | Invalid PIX key | Verify the key with the user |
KEY_NOT_FOUND | Key not found in DICT | Request a valid key |
ACCOUNT_BLOCKED | Blocked account | Contact support |
TIMEOUT | Processing timeout | Try again |
if (data.status === 'ERROR') {
switch (data.errorCode) {
case 'INSUFFICIENT_BALANCE':
// Notify insufficient balance
await notifyInsufficientBalance(transfer);
break;
case 'INVALID_KEY':
case 'KEY_NOT_FOUND':
// Request new key from user
await requestNewPixKey(transfer);
break;
case 'TIMEOUT':
// Can try again with a new idempotency key
await retryTransfer(transfer);
break;
default:
// Generic error
await notifyGenericError(transfer, data.errorCode);
}
}Balance Flow
sequenceDiagram
participant App
participant API
participant Bank
Note over App,Bank: Balance: available=1000, blocked=0
App->>API: POST /dict/pix (R$ 100)
API-->>App: { type: PENDING }
Note over App,Bank: Balance: available=900, blocked=100
alt Success
Bank->>API: Confirmation
API->>App: Webhook TRANSFER (LIQUIDATED)
Note over App,Bank: Balance: available=900, blocked=0
else Failure
Bank->>API: Error
API->>App: Webhook TRANSFER (ERROR)
Note over App,Bank: Balance: available=1000, blocked=0
endIdempotency
Use data.id to avoid duplicate processing:
async function handleWebhook(webhook: TransferWebhook) {
const webhookId = `transfer:${webhook.data.id}`;
const isProcessed = await redis.sismember('processed', webhookId);
if (isProcessed) {
return; // Already processed
}
await redis.sadd('processed', webhookId);
await handleTransfer(webhook);
}