Skip to content
LIQFYdocs
PTENENEN
Go to dashboard

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.

http
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.

FieldWhat it is
error.codeAlways INSUFFICIENT_BALANCE here. Stable constant.
details.availableSpendable balance, in the currency's minor unit (cents for BRL).
details.requestedWhat would leave the wallet: amount + fee. Not the amount you sent.
details.currencyISO 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.

HTTPCode / messageWhen
400validation errorsThe body fails the schema (e.g. type outside PIX, TED, CRYPTO; amount not a positive integer).
400Idempotency-Key must be at most 255 charactersThe Idempotency-Key header is longer than 255 characters.
4xxTENANT_KYC_REQUIREDThe account's registration (KYC) is not approved.
4xxwithdrawal rule refusalA withdrawal rule configured for the account refused the request. The refusal may carry ruleId and reasonKey (withdrawal.rule.pixKeyTypeNotAllowed or withdrawal.rule.notAllowed).
403CRYPTO_PANEL_ONLYA CRYPTO withdrawal sent with an API key — crypto is panel-only.
400INVALID_CRYPTO_WITHDRAWALA 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.failed webhook;
  • or by polling GET /v1/withdrawals/{id} (po_... or the bare id). The list GET /v1/withdrawals?status=failed accepts the same lowercase words as the payout.* webhooks: pending, processing, paid, failed (failed = FAILED, REJECTED and CANCELLED). An unknown value returns 400.

The fields that matter:

FieldWhat it is
statusFAILED, REJECTED or CANCELLED (failed on the canonical payout)
failure_codeStable, lowercase, machine-readable code. Branch your flow on this one. Present when the payout is FAILED.
failure_messageNeutral Portuguese sentence for that code. Safe to show your end user.
rejectionReasonFree 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[].failureCodeSplit 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:

Surfacefailure_code / failure_messageFree text
GET /v1/withdrawals/{id} (and the list)top-level failure_code, failure_messagerejectionReason
payout.faileddata.object.failure_code, data.object.failure_messagenever
json
{
  "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_codefailure_messageWorth retrying?
invalid_pix_keyA chave PIX de destino é inválida ou não foi encontrada.Only with a different key
account_blockedA conta de destino está bloqueada para receber PIX.Only with another account
account_closedA conta de destino está encerrada ou inativa.Only with another account
limit_rejectedO valor excede um limite permitido para esta transferência.Yes, with a lower amount
kyc_rejectedA transferência foi recusada na verificação cadastral do destinatário.No
destination_unavailableO 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_allowedO provedor não aceita esse tipo de destino; use outra chave Pix.Only with a different key type or destination
payout_cancelledA transferência foi cancelada antes de ser concluída.Yes
provider_rejectedA 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): every payout.failed arrives with the refund already booked, so you can refund your user on receipt. If the refund needs confirmation with the bank, payout.failed waits until it is booked; if the PIX did go out, you get payout.paid instead. Ask support to enable it.
  • Default: payout.failed is 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_message is "Estamos confirmando com o banco; você será avisado." and legs[].refunded is false. Don't refund your user yet: check GET /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:

FieldWhat it is
legIndexLeg number, starting at 1.
amountLeg amount, in cents.
statusPENDING, PROCESSING, COMPLETED, FAILED or REVERSED.
endToEndIdThe leg's E2E, to reconcile with the bank statement.
failureCodeClosed vocabulary above; null when the leg did not fail.
refundedFAILED leg only: true when its refund has already landed in the wallet.
reversalCreditedREVERSED leg only: true when the returned amount was credited to you (section 4).
reversalEndToEndId, reversedAmount, reversedAtData 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:

http
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

text
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}.