Skip to content
MOONIPdocs
IntegrationManual
PTEN
Go to dashboard

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.

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. 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.failed webhook;
  • or by polling GET /v1/withdrawals/{id}.

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, 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
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 — 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

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