TRANSFER
Overview
The TRANSFER webhook is sent to the URLs registered for the cash_out event with the PIX BACEN format when a PIX transfer started by your application at POST /api/dict/pix is processed. It reports the result: success (LIQUIDATED) or failure (ERROR).
When it is sent
- PIX transfer settled (
LIQUIDATED) - PIX transfer rejected or failed (
ERROR)
Final state guarantee
Every accepted transfer (HTTP 201) that is definitively rejected by the payment provider
emits a TRANSFER webhook with status: ERROR and data.errorCode. Provider rejection
is never silent.
This defines how to read the synchronous response of POST /api/dict/pix:
- Synchronous 4xx = declared rejection, FINAL state of the submitted request. Either the request was refused before
the transfer existed (e.g.
400 PIX_INSUFFICIENT_BALANCE,400 PIX_DUPLICATE_EXTERNAL_ID,422 PIX_KEY_NOT_FOUND), in which case there is no webhook because no transfer was created, or the provider rejected the already created transfer, and then theERRORwebhook also arrives. In both cases you can close the request immediately. Fixing and retrying with a newx-idempotency-keyonly applies to rejections before creation; when the rejection came from the provider, wait for theERRORwebhook before any new attempt. Exception:429 TOO_MANY_PIX_KEYS_NOT_FOUNDand422 PIX_KEY_NOT_FOUNDwithdetails.cached: trueare refusals before the request is created. Nothing was created and the request can be repeated afterRetry-After. - Synchronous 5xx is never a final state. It means "indeterminate": the payment may have gone out.
Do not cancel the request or treat it as a failure. Wait for the
TRANSFERwebhook (LIQUIDATEDorERROR), correlating bydata.idempotencyKey. Retrying with another idempotency key creates a risk of double payment. - The final state comes only from the webhook, never inferred from a synchronous 5xx.
The guarantee above covers definitive provider rejection. Internal platform
failures may end the transfer as ERROR without emitting a webhook. If you got
a synchronous 5xx and no webhook arrived within a few minutes, query the transaction at
GET https://api.fypay.me/api/pix/transaction/{id} (with the x-idempotency-key) or contact support at
suporte@fypay.me [TBD] with the x-idempotency-key. Do not retry by other means without that confirmation.
Payload Structure
{
"data": {
"id": 12345,
"txId": "550e8400-e29b-41d4-a716-446655440000",
"pixKey": "destino@email.com",
"status": "LIQUIDATED",
"payment": {
"amount": "100.5",
"currency": "BRL"
},
"mismatchAmount": null,
"refunds": [],
"createdAt": "2024-01-15T10:30:00.000Z",
"errorCode": null,
"endToEndId": "E12345678202401151030abcdefghijk",
"providerId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"ticketData": {},
"webhookType": "TRANSFER",
"debtorAccount": {
"ispb": null,
"name": null,
"bankName": null,
"issuer": null,
"number": null,
"document": null,
"accountType": null
},
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
"creditDebitType": "DEBIT",
"creditorAccount": {
"ispb": "12345678",
"name": "João Silva",
"bankName": "Banco Exemplo S.A.",
"issuer": "000",
"number": "123456",
"document": "12345678909",
"accountType": null
},
"localInstrument": "DICT",
"transactionType": "PIX",
"remittanceInformation": "Pagamento referente a NF 12345"
},
"type": "TRANSFER"
}Key Fields
typestringAlways "TRANSFER".
data.idnumberTransaction ID at FyPay. Same value as id in the POST /api/dict/pix response. It can be null when the transfer was rejected before being created; in that case correlate by idempotencyKey.
data.txIdstringThe value of the x-idempotency-key sent in the request.
data.idempotencyKeystringThe value of the x-idempotency-key sent in the request. Use this field to correlate the webhook with your transfer.
data.endToEndIdstringPIX End to End ID. Available once the transfer reaches the provider; may be null on failures.
data.statusstringTransfer status:
LIQUIDATED: transfer confirmedERROR: transfer failedPENDING: still being processed
data.paymentobjectdata.creditorAccountobjectRecipient data.
data.debtorAccountobjectYour account. All fields come as null.
data.creditDebitTypestringAlways "DEBIT".
data.errorCodestringError code when status is ERROR; null otherwise. The set of codes depends on the settlement provider.
data.remittanceInformationstringTransfer description (the description field sent in the request).
Processing the Webhook
Node.js Example
interface TransferWebhook {
type: 'TRANSFER';
data: {
id: number | null;
status: 'PENDING' | 'LIQUIDATED' | 'ERROR';
payment: {
amount: string;
currency: 'BRL';
};
endToEndId: string | null;
idempotencyKey: string | null;
creditorAccount: {
name: string | null;
document: string | null;
};
errorCode: string | null;
};
}
async function handleTransfer(webhook: TransferWebhook) {
const { data } = webhook;
const transfer = await findTransferByIdempotencyKey(data.idempotencyKey);
if (!transfer) {
console.warn(`Transfer not found: ${data.idempotencyKey}`);
return;
}
if (data.status === 'LIQUIDATED') {
await updateTransfer(transfer.id, {
status: 'COMPLETED',
endToEndId: data.endToEndId,
});
return;
}
if (data.status === 'ERROR') {
await updateTransfer(transfer.id, {
status: 'FAILED',
errorCode: data.errorCode,
});
}
}Python Example
def handle_transfer(webhook: dict):
data = webhook['data']
transfer = find_transfer_by_idempotency_key(data['idempotencyKey'])
if not transfer:
print(f"Transfer not found: {data['idempotencyKey']}")
return
if data['status'] == 'LIQUIDATED':
update_transfer(
transfer_id=transfer.id,
status='COMPLETED',
e2e_id=data['endToEndId'],
)
elif data['status'] == 'ERROR':
update_transfer(
transfer_id=transfer.id,
status='FAILED',
error_code=data['errorCode'],
)Correlation with the Request
Use idempotencyKey to correlate the webhook with the original request:
const idempotencyKey = crypto.randomUUID();
await saveTransfer({ idempotencyKey, status: 'PENDING' });
const transfer = await createTransfer(idempotencyKey, {
pixKey: 'destino@email.com',
payment: { amount: 100.50 },
});
await updateTransferId(idempotencyKey, transfer.id);In the TRANSFER webhook, look up by data.idempotencyKey. Save the key before calling the API, so you can correlate even if the synchronous response is lost.
Error Handling
When status is ERROR, record the errorCode and treat the transfer as a definitive failure. Codes vary by provider; do not rely on a fixed list. To try again, use a new x-idempotency-key.
if (data.status === 'ERROR') {
await markTransferFailed(data.idempotencyKey, data.errorCode);
await notifyUser(data.idempotencyKey, data.errorCode);
}Idempotency
The same webhook may arrive more than once. Deduplicate by idempotency key and status:
async function handleWebhook(webhook: TransferWebhook) {
const key = `transfer:${webhook.data.idempotencyKey}:${webhook.data.status}`;
const isNew = await redis.set(`webhook:${key}`, '1', 'NX', 'EX', 60 * 60 * 24 * 7);
if (!isNew) return;
await handleTransfer(webhook);
}