Pular para o conteúdo
MOONIPdocs
IntegraçãoManual
PTEN
Ir para o painel

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.

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_..."
}

Ramifique em error.code. A message é para log; o código é o contrato.

CampoO que é
error.codeSempre INSUFFICIENT_BALANCE neste caso. Constante estável.
details.availableSaldo gastável, na menor unidade (centavos no BRL).
details.requestedO que sairia da carteira: amount + taxa. Não é o amount que você mandou
details.currencyISO 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:

CampoO que é
statusFAILED, REJECTED ou CANCELLED (failed no payout canônico)
failure_codeCódigo estável, minúsculo, legível por máquina. É por ele que você decide o fluxo. Vem quando o saque é FAILED.
failure_messageFrase neutra em português para aquele código. Pode ir para o seu usuário.
rejectionReasonTexto 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[].failureCodeSó 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:

Ondefailure_code / failure_messageTexto livre
GET /v1/withdrawals/{id} (e a lista)failure_code, failure_message na raizrejectionReason
payout.faileddata.object.failure_code, data.object.failure_messagenunca
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."
    }
  }
}

#Valores de failure_code

failure_codefailure_messageVale tentar de novo?
invalid_pix_keyA chave PIX de destino é inválida ou não foi encontrada.Só com outra chave
account_blockedA conta de destino está bloqueada para receber PIX.Só com outra conta
account_closedA conta de destino está encerrada ou inativa.Só com outra conta
limit_rejectedO valor excede um limite permitido para esta transferência.Sim, com valor menor
kyc_rejectedA transferência foi recusada na verificação cadastral do destinatário.Não
destination_unavailableO 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_cancelledA transferência foi cancelada antes de ser concluída.Sim
provider_rejectedA 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

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