Payouts — what Moonip 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.
#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. 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.)
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.
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.
#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}.
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, 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 |
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 — as soon as the wallet movement
lands. You don't need to request anything; just react to the webhook.
(failure_message does not promise a refund — the wallet movement does.) The
refund is not always immediate: when it cannot be proven that the PIX never
left, it is held for confirmation with the bank. Then failure_message is
"Estamos confirmando com o banco; você será avisado." and legs[].refunded is
false — treat that as "not back yet" and wait for the later credit and notice.
#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.
#Implementation summary
POST /v1/withdrawals
├── 400 INSUFFICIENT_BALANCE ....... your wallet doesn't cover it. Nothing created.
│ → check /v1/withdrawals/payout-info first
└── 201 PENDING .................... accepted, funds reserved
├── stays PROCESSING ........... do NOT retry. Wait for the webhook.
└── webhook payout.failed
└── failure_code = invalid_pix_key | account_* | limit_rejected |
kyc_rejected | destination_unavailable | 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): wait for the credit/notice.
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). 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.