Payouts — what Orbita Pay 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. (There is a
legacy amountType: "net" mode where the recipient gets amount − fee; 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 — no side effects, built for this. Returns
available, the applicable fee, and maxWithdrawable: the largest amount you
can request with the fee already deducted. That's the right number for a
"withdraw everything" button.
#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
withdrawal.failedwebhook; - or by polling
GET /v1/withdrawals/{id}.
The fields that matter:
| Field | What it is |
|---|---|
status | FAILED, REJECTED or CANCELLED |
providerErrorCode | Stable, machine-readable code. Branch your flow on this one. |
rejectionReason | Free text for logs/support. Do not write ifs against it. |
#providerErrorCode values
| Code | Meaning | Your wallet | Worth retrying? |
|---|---|---|---|
INVALID_PIX_KEY | PIX key invalid, unknown or blocked | Refunded | Only with a different key |
ACCOUNT_CLOSED | Recipient account closed | Refunded | Only with another account |
ACCOUNT_BLOCKED | Recipient account blocked | Refunded | Only with another account |
KYC_REJECTED | Recipient failed the bank's screening | Refunded | No |
LIMIT_REJECTED | Over a scheme/bank limit | Refunded | Yes, with a lower amount |
PROVIDER_REJECTED | Generic processor refusal | Refunded | Yes, but investigate first |
PROVIDER_INSUFFICIENT_BALANCE | Processor liquidity — see section 3 | NOT refunded | Automatic, don't retry |
Rule of thumb: for every code above except the last, the failure is definitive and the money is refunded automatically to your wallet. You don't need to request anything; just react to the webhook.
#3. PROVIDER_INSUFFICIENT_BALANCE — the exception, and the 24h window
This code is not your fault and not a failure of your payout. It means the processor that would send the PIX was momentarily out of liquidity. The payout is still valid.
That makes it the only transient code in the table, and it behaves differently:
- the money is not refunded — the payout is held, waiting;
- Orbita Pay retries on its own, with no action from you;
- the window is 24 hours from the first refusal;
- if the processor recovers within the window, the payout goes through normally
and you get
withdrawal.completed; - if 24 hours pass without recovery, it becomes
FAILEDwith an automatic refund to your wallet, and you getwithdrawal.failed.
What to do: when you see this code, 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. There
are exactly two possible outcomes and both arrive by webhook: completed or
failed.
Current status of this behavior. The 24h retry is implemented and tested, but not yet switched on in production — it sits behind a flag that is currently off. While that's the case, a refusal caused by processor liquidity reaches you as
PROVIDER_REJECTED, with an immediate refund (the definitive behavior in the table).Implement the handling now anyway.
PROVIDER_INSUFFICIENT_BALANCEis already part of the contract; the day the flag is turned on, your integration starts receiving the new behavior with no change on your side. We'll announce the date.
#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
└── webhook withdrawal.failed
├── providerErrorCode = PROVIDER_INSUFFICIENT_BALANCE
│ → do NOT retry. Held, auto-retried for up to 24h.
│ Outcome arrives by webhook (completed or failed).
└── any other code
→ definitive, wallet ALREADY refunded.
Retry only after fixing the cause (e.g. the PIX key).Idempotency: always send idempotencyKey on POST /v1/withdrawals. It's your
protection against a duplicate payout on any network retry — re-sending the same
idempotencyKey returns the original payout instead of creating another.