Payouts — what Liqfy returns when one doesn't go through
This document closes the payout failure contract: what you get, when, and what to do with each case. Implement once; you shouldn't need to revisit it.
There are two moments where a payout can fail, and they behave very differently. Don't handle them in the same code path. A third case is rarer: a payout that was paid and then returned by the receiving institution (see section 4).
#1. Immediate refusal — your account balance doesn't cover it
Happens synchronously, in the POST /v1/withdrawals response. No payout is
created, no money leaves your wallet, and your idempotencyKey is NOT spent.
POST /v1/withdrawals
HTTP/1.1 400 Bad Request
{
"error": {
"type": "invalid_request_error",
"code": "INSUFFICIENT_BALANCE",
"message": "INSUFFICIENT_BALANCE",
"details": { "available": 1000, "requested": 100005, "currency": "BRL" }
},
"request_id": "req_..."
}Branch on error.code. The message is for your logs; the code is the
contract.
| Field | What it is |
|---|---|
error.code | Always INSUFFICIENT_BALANCE here. Stable constant. |
details.available | Spendable balance, in the currency's minor unit (cents for BRL). |
details.requested | What would leave the wallet: amount + fee. Not the amount you sent. |
details.currency | ISO 4217 of the wallet that was evaluated. |
#Three things that save you a support ticket
The fee is charged ON TOP. To withdraw amount, the wallet needs
amount + fee (amountType defaults to "gross"). That's why requested
comes pre-summed — without it you'd see "asked for 1,000, have 1,000" and the
refusal would look wrong. (The legacy amountType: "net" mode is still
accepted on POST /v1/withdrawals — see Create the payout:
the recipient gets amount − fee, and there requested is just the amount.
It is not accepted for CRYPTO withdrawals.)
Only available counts. It's the spendable balance. pending (settled but
still held) does not count toward a payout and never covers one. If the
account has more than one wallet in the same currency, available is the
largest single balance, not the sum — the reserve comes out of one wallet.
You can pin the wallet with the optional walletId field; if you omit it, the
currency's default wallet is used when it has balance, otherwise any spendable
wallet with enough balance.
A refusal does not burn the idempotencyKey. You can top up and re-send with
the SAME key; the payout is processed normally. (This already held for the common
refusal, and as of 2026-08-19 it also holds for the rare race where the balance
disappears between the check and the reserve.)
#How to avoid hitting this at all
GET /v1/withdrawals/payout-info?currency= — no side effects, built for
this. Returns { currency, available, pendingAdjustment, fee: { fixed, variablePct, minimum }, maxWithdrawable }: available is the spendable
balance, fee is the parameter set applied today, and maxWithdrawable is
the largest gross amount you can request with the fee already accounted —
the right number for a "withdraw everything" button. Full contract and example:
Quote, fee and withdrawable balance.
#Other immediate refusals
The same rule applies to every synchronous 4xx: nothing is created and nothing leaves the wallet.
| HTTP | Code / message | When |
|---|---|---|
| 400 | validation errors | The body fails the schema (e.g. type outside PIX, TED, CRYPTO; amount not a positive integer). |
| 400 | Idempotency-Key must be at most 255 characters | The Idempotency-Key header is longer than 255 characters. |
| 4xx | TENANT_KYC_REQUIRED | The account's registration (KYC) is not approved. |
| 4xx | withdrawal rule refusal | A withdrawal rule configured for the account refused the request. The refusal may carry ruleId and reasonKey (withdrawal.rule.pixKeyTypeNotAllowed or withdrawal.rule.notAllowed). |
| 403 | CRYPTO_PANEL_ONLY | A CRYPTO withdrawal sent with an API key — crypto is panel-only. |
| 400 | INVALID_CRYPTO_WITHDRAWAL | A CRYPTO withdrawal outside its rules (BRL only, minimum R$ 5.000,00, no net, wallet address and estimate required). |
Every 4xx refusal leaves a trail. It is recorded with amount, reason and
origin (API or panel) and shows up on the withdrawals screen and in the audit
log (WITHDRAWAL_REJECTED). The same request repeated with the same
idempotency key and the same code is recorded at most once per hour. A 5xx is
our failure, not a refusal, and is not recorded this way.
#2. Later refusal — the payout was accepted and failed in processing
Here the POST returned 201 with status: "PENDING", and the money has
already left your wallet (it's reserved). The outcome arrives later, two ways:
- via the
payout.failedwebhook; - or by polling
GET /v1/withdrawals/{id}(po_...or the bare id). The listGET /v1/withdrawals?status=failedaccepts the same lowercase words as thepayout.*webhooks:pending,processing,paid,failed(failed=FAILED,REJECTEDandCANCELLED). An unknown value returns 400.
The fields that matter:
| Field | What it is |
|---|---|
status | FAILED, REJECTED or CANCELLED (failed on the canonical payout) |
failure_code | Stable, lowercase, machine-readable code. Branch your flow on this one. Present when the payout is FAILED. |
failure_message | Neutral Portuguese sentence for that code. Safe to show your end user. |
rejectionReason | Free text for logs/support (reason on the legacy webhook). On FAILED the value is the public failure_message (since 2026-10-21); on REJECTED/CANCELLED it may still carry the receiving institution's technical text. Do not write ifs against it. |
legs[].failureCode | Split payouts only: the reason of each part. Closed vocabulary: INVALID_PIX_KEY, ACCOUNT_CLOSED, ACCOUNT_BLOCKED, KYC_REJECTED, LIMIT_REJECTED, DESTINATION_UNAVAILABLE, DESTINATION_NOT_ALLOWED, INSUFFICIENT_BALANCE, TIMEOUT, PROVIDER_CANCELLED, PROVIDER_REJECTED, UNKNOWN. |
Where each one shows up:
| Surface | failure_code / failure_message | Free text |
|---|---|---|
GET /v1/withdrawals/{id} (and the list) | top-level failure_code, failure_message | rejectionReason |
payout.failed | data.object.failure_code, data.object.failure_message | never |
{
"id": "evt_...",
"object": "event",
"type": "payout.failed",
"data": {
"object": {
"id": "po_...",
"object": "payout",
"status": "failed",
"failure_code": "destination_unavailable",
"failure_message": "O banco de destino não confirmou o PIX a tempo. Tente novamente mais tarde."
}
}
}#failure_code values
failure_code | failure_message | Worth retrying? |
|---|---|---|
invalid_pix_key | A chave PIX de destino é inválida ou não foi encontrada. | Only with a different key |
account_blocked | A conta de destino está bloqueada para receber PIX. | Only with another account |
account_closed | A conta de destino está encerrada ou inativa. | Only with another account |
limit_rejected | O valor excede um limite permitido para esta transferência. | Yes, with a lower amount |
kyc_rejected | A transferência foi recusada na verificação cadastral do destinatário. | No |
destination_unavailable | O banco de destino não confirmou o PIX a tempo. Tente novamente mais tarde. | Yes, later: the receiving bank did not settle in time |
destination_not_allowed | O provedor não aceita esse tipo de destino; use outra chave Pix. | Only with a different key type or destination |
payout_cancelled | A transferência foi cancelada antes de ser concluída. | Yes |
provider_rejected | A transferência PIX foi recusada e não foi concluída. | Yes, but investigate first |
provider_rejected is the generic code: anything we can't attribute more
precisely lands there. Treat an unknown failure_code as provider_rejected
— new codes may be added, never renamed or removed.
Rule of thumb: a FAILED payout is definitive and the money is
refunded automatically to your wallet; you don't need to request anything.
When payout.failed arrives depends on an account setting:
- Final failures only (recommended if you refund your users on
payout.failed): everypayout.failedarrives with the refund already booked, so you can refund your user on receipt. If the refund needs confirmation with the bank,payout.failedwaits until it is booked; if the PIX did go out, you getpayout.paidinstead. Ask support to enable it. - Default:
payout.failedis sent at once. The refund is almost always booked by then. When it cannot be proven that the PIX never left, the refund is held for confirmation with the bank:failure_messageis "Estamos confirmando com o banco; você será avisado." andlegs[].refundedisfalse. Don't refund your user yet: checkGET /v1/withdrawals/{id}or the wallet statement until the refund lands. Releasing it sends no other webhook.
(failure_message does not promise a refund — the wallet movement does.)
#Payouts split into legs
Each PIX has a ceiling (R$ 15.000,00 by default). A payout above it is split into several PIX transfers — the legs — each filled up to the ceiling, with the remainder in the last one (R$ 110.000,00 → 7 × R$ 15.000,00 + 1 × R$ 5.000,00). Each leg has its own E2E and can fail on its own.
When some legs are paid and others fail, the payout ends
PARTIALLY_COMPLETED — paid in the public vocabulary — and the failed legs'
amounts go back to your wallet once the refund lands. Look at legs[] to know
exactly what happened:
| Field | What it is |
|---|---|
legIndex | Leg number, starting at 1. |
amount | Leg amount, in cents. |
status | PENDING, PROCESSING, COMPLETED, FAILED or REVERSED. |
endToEndId | The leg's E2E, to reconcile with the bank statement. |
failureCode | Closed vocabulary above; null when the leg did not fail. |
refunded | FAILED leg only: true when its refund has already landed in the wallet. |
reversalCredited | REVERSED leg only: true when the returned amount was credited to you (section 4). |
reversalEndToEndId, reversedAmount, reversedAt | Data of the return, on a REVERSED leg. |
A leg replaced by a re-send never shows up in legs[]; its amount is already
in the leg that replaced it.
#Cancelling a payout that is still PENDING
While the payout is PENDING (nothing sent yet — for example, waiting for
approval), you can cancel it yourself:
POST /v1/withdrawals/{id}/cancel
{ "reason": "optional, up to 500 characters" }It returns 200 with the payout as CANCELLED, and the amount returns to the
balance. Outside PENDING it returns 409 WITHDRAWAL_NOT_CANCELLABLE. From
the panel, only the account owner or an admin can cancel.
#3. Processor liquidity
When the processor that would send the PIX has no balance to pay it, that is
not your fault. Today the payout reaches you as FAILED with
failure_code: "provider_rejected", and the money is refunded automatically
to your wallet once the wallet movement lands (a held refund applies here too —
see the rule of thumb above). There is no automatic retry and no waiting window:
if you want, request the payout again — preferably a little later.
What to do: while a payout is PROCESSING, do not repeat the request. A
new POST would create a second payout and debit the wallet again. Show your end
user something like "payment processing" and wait for the final webhook.
PROCESSING can last longer than usual when the result of the PIX send is not
known yet. In that case the payout stays PROCESSING while Liqfy
confirms the outcome with the provider: it is never failed and refunded just
because time passed — only with positive evidence that the PIX did not leave.
#4. Paid, then returned (REVERSED)
A PIX that was already paid can be returned by the receiving institution. The
payout (or the leg) then becomes REVERSED. This status has no word in the
public payout.* vocabulary: to list it, filter with ?status=REVERSED.
The returned money does not go straight to your balance. After the return is
confirmed in the provider's statement, it enters a Liqfy reversals box
and is credited to you automatically only when the brand policy allows it and
the return is not flagged as fraud or suspicion; otherwise a person decides —
credit it to you or retain it. Until then, legs[].reversalCredited is false:
treat it as "not back in your balance".
#Implementation summary
POST /v1/withdrawals
├── 400 INSUFFICIENT_BALANCE ....... your wallet doesn't cover it. Nothing created.
│ → check /v1/withdrawals/payout-info first
├── other 4xx ...................... refused at entry. Nothing created.
└── 201 PENDING .................... accepted, funds reserved
├── still PENDING .............. you can cancel: POST /v1/withdrawals/{id}/cancel
├── stays PROCESSING ........... do NOT retry. Wait for the webhook.
├── PARTIALLY_COMPLETED (paid) . some legs paid, failed legs refunded
│ (legs[].refunded tells which already landed)
├── REVERSED ................... paid and returned; credit only when
│ legs[].reversalCredited is true
└── webhook payout.failed
└── failure_code = invalid_pix_key | account_* | limit_rejected |
kyc_rejected | destination_unavailable | destination_not_allowed |
payout_cancelled |
provider_rejected (generic / unknown)
→ definitive; the refund is automatic once the wallet
movement lands. If failure_message is "Estamos confirmando
com o banco; você será avisado." the refund is held
(legs[].refunded is false): check the payout or the
statement until it lands. Does not happen with
"final failures only".
Retry only after fixing the cause (e.g. the PIX key), or later
for destination_unavailable.Idempotency: always send the Idempotency-Key header on
POST /v1/withdrawals (the body field idempotencyKey is a fallback; the header
wins; at most 255 characters). It's your protection against a duplicate payout
on any network retry — re-sending the same key returns the original payout
instead of creating another. If a request timed out and you don't know whether
the payout was created, look it up by key: GET /v1/withdrawals/by-idempotency-key/{key} or GET /v1/withdrawals?idempotency_key={key}.