Saque (payout) — o que a Moonip responde quando não dá certo
Este documento fecha o contrato de falha de saque: o que você recebe, quando, e o que fazer com cada caso. Serve para você implementar uma vez e não precisar mexer de novo.
Há dois momentos em que um saque pode não acontecer, e eles se comportam de forma bem diferente. Não trate os dois no mesmo caminho de código.
#1. Recusa imediata — o saldo da SUA conta não cobre
Acontece na hora, na resposta do POST /v1/withdrawals. Nenhum saque é
criado, nenhum valor sai da sua carteira, e o idempotencyKey NÃO é gasto.
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_..."
}Ramifique em error.code. A message é para log; o código é o contrato.
| Campo | O que é |
|---|---|
error.code | Sempre INSUFFICIENT_BALANCE neste caso. Constante estável. |
details.available | Saldo gastável, na menor unidade (centavos no BRL). |
details.requested | O que sairia da carteira: amount + taxa. Não é o amount que você mandou |
details.currency | ISO 4217 da carteira avaliada. |
#Três coisas que evitam suporte depois
A taxa é cobrada POR CIMA. Para sacar amount, a carteira precisa de
amount + taxa. Por isso requested vem somado — sem ele você veria
"pedi 1000, tenho 1000" e a recusa pareceria errada. (O modo legado
amountType: "net" ainda é aceito no POST /v1/withdrawals — veja
Criar o saque: o recebedor recebe amount − taxa, e aí
requested é só o amount.)
Só available conta. É o saldo gastável. O pending (liquidado mas ainda
retido) não entra na conta e nunca cobre um saque. Se a conta tiver mais de
uma carteira na mesma moeda, available é o maior saldo entre elas, não a
soma — a reserva sai de uma carteira só.
A recusa não queima o idempotencyKey. Você pode depositar e reenviar com
a MESMA chave; o saque é processado normalmente. (Isso valia para a recusa
comum e passou a valer também para a corrida rara em que o saldo some entre a
checagem e a reserva — corrigido em 19/08/2026.)
#Como não bater neste erro
GET /v1/withdrawals/payout-info?currency= — sem efeito colateral, feito
para isso. Devolve { currency, available, pendingAdjustment, fee: { fixed, variablePct, minimum }, maxWithdrawable }: available é o saldo gastável,
fee é o conjunto de parâmetros aplicado hoje e maxWithdrawable é o
maior valor bruto que você pode pedir com a taxa já descontada — o número
certo para o botão "sacar tudo". Contrato completo e exemplo:
Cotação, taxa e saldo sacável.
#2. Recusa depois — o saque foi aceito e falhou no processamento
Aqui o POST respondeu 201 com status: "PENDING", e o valor já saiu da
sua carteira (fica reservado). O desfecho chega depois, de duas formas:
- pelo webhook
payout.failed; - ou consultando
GET /v1/withdrawals/{id}.
Os campos que interessam:
| Campo | O que é |
|---|---|
status | FAILED, REJECTED ou CANCELLED (failed no payout canônico) |
failure_code | Código estável, minúsculo, legível por máquina. É por ele que você decide o fluxo. Vem quando o saque é FAILED. |
failure_message | Frase neutra em português para aquele código. Pode ir para o seu usuário. |
rejectionReason | Texto livre para log/suporte (reason no webhook legado). No FAILED o valor é a failure_message pública (desde 2026-10-21); em REJECTED/CANCELLED ainda pode trazer o texto técnico da instituição de destino. Não faça if em cima dele. |
legs[].failureCode | Só em saque dividido em partes: o motivo de cada parte. Vocabulário fechado: INVALID_PIX_KEY, ACCOUNT_CLOSED, ACCOUNT_BLOCKED, KYC_REJECTED, LIMIT_REJECTED, DESTINATION_UNAVAILABLE, INSUFFICIENT_BALANCE, TIMEOUT, PROVIDER_CANCELLED, PROVIDER_REJECTED, UNKNOWN. |
Onde cada um aparece:
| Onde | failure_code / failure_message | Texto livre |
|---|---|---|
GET /v1/withdrawals/{id} (e a lista) | failure_code, failure_message na raiz | rejectionReason |
payout.failed | data.object.failure_code, data.object.failure_message | nunca |
{
"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."
}
}
}#Valores de failure_code
failure_code | failure_message | Vale tentar de novo? |
|---|---|---|
invalid_pix_key | A chave PIX de destino é inválida ou não foi encontrada. | Só com outra chave |
account_blocked | A conta de destino está bloqueada para receber PIX. | Só com outra conta |
account_closed | A conta de destino está encerrada ou inativa. | Só com outra conta |
limit_rejected | O valor excede um limite permitido para esta transferência. | Sim, com valor menor |
kyc_rejected | A transferência foi recusada na verificação cadastral do destinatário. | Não |
destination_unavailable | O banco de destino não confirmou o PIX a tempo. Tente novamente mais tarde. | Sim, mais tarde: o banco de destino não liquidou a tempo |
payout_cancelled | A transferência foi cancelada antes de ser concluída. | Sim |
provider_rejected | A transferência PIX foi recusada e não foi concluída. | Sim, mas investigue antes |
provider_rejected é o código genérico: o que não dá para atribuir com mais
precisão cai nele. Trate um failure_code desconhecido como
provider_rejected — códigos novos podem ser acrescentados; nenhum é
renomeado ou removido.
Regra geral: um saque FAILED é definitivo e o valor volta
automaticamente para a sua carteira — assim que o lançamento na carteira cai.
Você não precisa pedir estorno; basta reagir ao webhook. (A failure_message
não promete estorno — quem prova é o lançamento na carteira.) O estorno não é
sempre imediato: quando não dá para provar que o PIX nunca saiu, ele fica
retido para confirmação com o banco. Aí a failure_message é "Estamos
confirmando com o banco; você será avisado." e legs[].refunded é false —
trate como "ainda não voltou" e aguarde o crédito e o aviso.
#3. Liquidez do processador
Quando o processador que faria o PIX está sem saldo para pagar, isso não é
culpa sua. Hoje o saque chega para você como FAILED com
failure_code: "provider_rejected", e o valor volta automaticamente para a
sua carteira assim que o lançamento na carteira cai (o estorno retido também
vale aqui — veja a regra geral acima). Não há retentativa automática nem janela
de espera: se quiser, peça o saque de novo — de preferência um pouco depois.
O que fazer: enquanto o saque estiver PROCESSING, não repita o pedido.
Um novo POST criaria um segundo saque e debitaria a carteira de novo. Mostre ao
seu usuário algo como "pagamento em processamento" e aguarde o webhook final.
#Resumo para quem vai implementar
POST /v1/withdrawals
├── 400 INSUFFICIENT_BALANCE ....... sua carteira não cobre. Nada foi criado.
│ → cheque /v1/withdrawals/payout-info antes
└── 201 PENDING .................... aceito, valor reservado
├── segue PROCESSING ........... NÃO repita. Aguarde o webhook.
└── webhook payout.failed
└── failure_code = invalid_pix_key | account_* | limit_rejected |
kyc_rejected | destination_unavailable | payout_cancelled |
provider_rejected (genérico / desconhecido)
→ definitivo; o estorno é automático assim que o lançamento
na carteira cai. Se a failure_message for "Estamos confirmando
com o banco; você será avisado." o estorno está retido
(legs[].refunded = false): aguarde o crédito/aviso.
Repetir só depois de corrigir a causa (ex.: chave PIX), ou mais
tarde no destination_unavailable.Idempotência: mande sempre o cabeçalho Idempotency-Key no
POST /v1/withdrawals (o campo idempotencyKey no corpo é fallback). É a sua
proteção contra saque duplicado em qualquer retentativa de rede — repetir a
mesma chave devolve o saque original em vez de criar outro.