Skip to content
QAdocs
PTEN
Go to dashboard

Payouts — what Neozentry QA 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. (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-infono 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.failed webhook;
  • or by polling GET /v1/withdrawals/{id}.

The fields that matter:

FieldWhat it is
statusFAILED, REJECTED or CANCELLED
providerErrorCodeStable, machine-readable code. Branch your flow on this one.
rejectionReasonFree text for logs/support. Do not write ifs against it.

#providerErrorCode values

CodeMeaningYour walletWorth retrying?
INVALID_PIX_KEYPIX key invalid, unknown or blockedRefundedOnly with a different key
ACCOUNT_CLOSEDRecipient account closedRefundedOnly with another account
ACCOUNT_BLOCKEDRecipient account blockedRefundedOnly with another account
KYC_REJECTEDRecipient failed the bank's screeningRefundedNo
LIMIT_REJECTEDOver a scheme/bank limitRefundedYes, with a lower amount
PROVIDER_REJECTEDGeneric processor refusalRefundedYes, but investigate first
PROVIDER_INSUFFICIENT_BALANCEProcessor liquidity — see section 3NOT refundedAutomatic, 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;
  • Neozentry QA 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 FAILED with an automatic refund to your wallet, and you get withdrawal.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_BALANCE is 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

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
    └── 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.