Skip to content
MOONIPdocs
IntegrationManual
PTEN
Go to dashboard

Payouts (Withdrawals / Saque)

O termo de produto é Saque (payout); o endpoint atual /v1/withdrawals é o nome de fio (wire format) em uso hoje e ainda não foi renomeado para /v1/payouts.

Move balance out of your Moonip wallet to a real-world destination — a PIX key (Brazil) or a bank account (TED). This is the inverse of PIX payments: payments credit your wallet, payouts debit it.

Tracks are per account. TED depends on the corresponding module being enabled for you. Confirm with your Moonip contact before building on top of one of them.

#Flow at a glance

text
1. POST /v1/withdrawals/validate-pix-key   (optional — pre-flight)
2. POST /v1/withdrawals                    (creates the request and reserves the funds right away)
3. Moonip dispatches — automatically or after approval
4. Webhook payout.paid (or payout.failed) hits your endpoint

Payouts use the same API key as charges — Authorization: Bearer <key>, called from your server, never from the end customer's browser or app. (The apikey header is still accepted.) The wallet is debited at create time; whether the payout also needs approval depends on your account settings.

Always send the Idempotency-Key header on create — it is the primary idempotency channel (see Create the payout).

Before creating the payout, check the destination key is well-formed.

POST /v1/withdrawals/validate-pix-key

json
{
  "key": "12345678909",
  "type": "CPF"
}

Response 201 Created

json
{
  "isValid": true,
  "type": "CPF",
  "normalized": "12345678909"
}

Supported type values

TypeFormat
CPF11 digits, mod-11 checksum.
CNPJ14 digits, mod-11 checksum.
EMAILRFC 5322, max 77 chars.
PHONEE.164 with +55 country code (+5511999999999).
RANDOMRandom key — UUID v4.
(omitted)Auto-detected from the key value.

Declaring any other type (for example EVP) is ignored: the key is auto-detected and the response reports the detected type. The endpoint answers 201 even when the key is invalid — a failure lives in the body, never in the HTTP status:

Failure 201 Created

json
{
  "isValid": false,
  "type": "CPF",
  "normalized": "12345678900",
  "error": "CPF checksum invalid"
}

#2. Quote, fee and withdrawable balance

Three read-only helpers, same API key as the create endpoint. currency is optional in all of them (default BRL).

#Quote — what to send so the recipient gets an exact amount

POST /v1/withdrawals/quote — body { value, currency? }, value an integer greater than zero. Priced as PIX.

Response 201 Created

json
{
  "value": 50000,
  "fee": 100,
  "total": 50100
}

total is the gross you pay so that the recipient receives exactly value — the fee is grossed up so the destination never absorbs it.

bash
curl -X POST https://api.moonip.io/v1/withdrawals/quote \
  -H "Authorization: Bearer $MOONIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "value": 50000 }'
ts
import { MoonipClient } from '@moonip/node';

const client = new MoonipClient({ apiKey: process.env.MOONIP_API_KEY! });

const quote = await client.payouts.quote({ value: 50000 });

Python client.payouts.quote(value=50000), PHP $client->payouts->quote(50000), .NET client.Payouts.QuoteAsync(50000).

#Fee — what a create would charge

GET /v1/withdrawals/payout-fee

QueryRequiredDescription
amountyesInteger, 1 to 10.000.000.000 (smallest unit).
currencynoDefault BRL.
typenoPIX (default) or TED.

Response 200 OK

json
{
  "amount": 50000,
  "currency": "BRL",
  "fee": 100,
  "total": 50100
}

fee is what POST /v1/withdrawals would charge for that amount (already including the split-leg complement above the PIX cap); total = amount + fee.

bash
curl "https://api.moonip.io/v1/withdrawals/payout-fee?amount=50000&currency=BRL&type=PIX" \
  -H "Authorization: Bearer $MOONIP_API_KEY"
ts
const fee = await client.payouts.payoutFee({ amount: 50000 });

Python client.payouts.payout_fee(amount=50000), PHP $client->payouts->payoutFee(50000), .NET client.Payouts.PayoutFeeAsync(50000).

#Withdrawable balance and fee parameters

GET /v1/withdrawals/payout-info — no side effects, built for the "withdraw everything" button.

Response 200 OK

json
{
  "currency": "BRL",
  "available": 1000000,
  "pendingAdjustment": 0,
  "fee": { "fixed": 100, "variablePct": 0, "minimum": 100 },
  "maxWithdrawable": 999900
}

available is the spendable balance. maxWithdrawable is the largest gross amount requestable with the fee already accounted. fee is the parameter set applied today (fixed, variablePct, minimum).

bash
curl "https://api.moonip.io/v1/withdrawals/payout-info?currency=BRL" \
  -H "Authorization: Bearer $MOONIP_API_KEY"
ts
const info = await client.payouts.payoutInfo();

Python client.payouts.payout_info(), PHP $client->payouts->payoutInfo(), .NET client.Payouts.PayoutInfoAsync().

#3. Create the payout

POST /v1/withdrawals

Idempotency. Send the Idempotency-Key header — the primary channel, trimmed, at most 255 characters. The body field idempotencyKey is a fallback for non-SDK callers; when both are present the header wins. The same key returns the same payout: 201 again, no second debit. If the reserve fails, the row is marked REJECTED and the key is released — after topping up you can retry with the same key.

Without a key, the only protection is a near-duplicate guard: the same type, amount and pixKey within 90 seconds answers 409 with no stable error.code. Send an idempotency key and you never depend on it.

FieldTypeRequiredDescription
typeenumyesPIX or TED.
amountintegeryesSmallest currency unit. In the default gross mode it is what the recipient receives; the fee is debited on top.
currencystringnoDefault BRL.
pixKeystringon PIXDestination PIX key.
pixKeyTypeenumnoCPF, CNPJ, EMAIL, PHONE, RANDOM. Omitted or unknown (for example EVP): ignored, key auto-detected.
bankCodestringon TEDBank code (FEBRABAN).
bankAgencystringon TEDBranch (agência).
bankAccountstringon TEDAccount number.
walletIdstringnoExplicit spendable OPERATIONAL wallet in currency. Omitted → auto-select. Unknown → WALLET_NOT_FOUND; not spendable → WALLET_NOT_SPENDABLE; nothing covers the debit → INSUFFICIENT_BALANCE.
amountTypeenumnogross (default) or net. net is legacy and still accepted: the debit is amount and the recipient gets amount − fee (fee >= amount → 400).
idempotencyKeystringnoFallback — prefer the Idempotency-Key header.

Unknown keys (description, cryptoAddress, cryptoNetwork, cryptoCurrency, anything else) are silently stripped — the API never stores or returns them. cryptoAddress, cryptoNetwork and cryptoCurrency can still appear as read-only legacy response fields; they are not create inputs.

Example — PIX payout

bash
curl -X POST https://api.moonip.io/v1/withdrawals \
  -H "Authorization: Bearer $MOONIP_API_KEY" \
  -H "Idempotency-Key: payout-2026-W17" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "PIX",
    "amount": 50000,
    "pixKey": "12345678909",
    "pixKeyType": "CPF"
  }'

Example — TED payout (the bank details are three flat strings)

bash
curl -X POST https://api.moonip.io/v1/withdrawals \
  -H "Authorization: Bearer $MOONIP_API_KEY" \
  -H "Idempotency-Key: payout-2026-W17-ted" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "TED",
    "amount": 50000,
    "bankCode": "001",
    "bankAgency": "1234",
    "bankAccount": "56789012"
  }'
ts
const payout = await client.payouts.create(
  { type: 'PIX', amount: 50000, pixKey: '12345678909', pixKeyType: 'CPF' },
  { idempotencyKey: 'payout-2026-W17' },
);
// TED: { type: 'TED', amount: 50000, bankCode: '001', bankAgency: '1234', bankAccount: '56789012' }

Python client.payouts.create(input, idempotency_key=…), PHP $client->payouts->create($input, $idempotencyKey), .NET client.Payouts.CreateAsync(input, idempotencyKey).

Response 201 Created

json
{
  "id": "f6a7b8c9-d0e1-4234-9f01-234567890123",
  "type": "PIX",
  "status": "PENDING",
  "amount": 50000,
  "fee": 100,
  "netAmount": 50000,
  "currency": "BRL",
  "source": "API",
  "walletId": "2c9f8e7d-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
  "pixKey": "12345678909",
  "pixKeyType": "CPF",
  "idempotencyKey": "payout-2026-W17",
  "createdAt": "2026-04-25T16:10:00.000Z",
  "updatedAt": "2026-04-25T16:10:00.000Z"
}

The wallet is debited at create. In gross mode (the default) the debit is amount + fee (here 50.100) and the recipient receives exactly amount — that is why netAmount equals amount. The funds are reserved from this moment on; approval only decides when the payout is dispatched.

A 201 can already come back APPROVED or PROCESSING: auto-payout approves and dispatches immediately when your account policy allows it. "Always starts PENDING" is not guaranteed.

When a payout doesn't go through. The full refusal contract — the error.code for an immediate balance refusal, the failure codes (failure_code), and which of them refund automatically — is in Payout failures.

#4. Lifecycle

text
PENDING ─▶ APPROVED ─▶ PROCESSING ─▶ CONFIRMING ─▶ COMPLETED            ✓ funds delivered
                                                    └▶ PARTIALLY_COMPLETED   delivered in parts

Terminal outcomes also include FAILED, REJECTED, CANCELLED and REVERSED. Ten statuses in total.

StatusWallet effect
PENDINGDebited at create (amount + fee reserved)
APPROVEDReserved — approved, waiting dispatch
PROCESSINGReserved — sent for settlement
CONFIRMINGReserved — waiting the receiving institution's confirmation
COMPLETEDDebited (terminal) — delivered
PARTIALLY_COMPLETEDDebited (terminal) — delivered in parts (see legs)
FAILEDAuto-refunded — dispatch failed (failure_code). The refund can be held until confirmed (see Payout failures)
REJECTEDRefunded — refused at review, or by the reserve (system:reserve-failed)
CANCELLEDRefunded (amount + fee) — cancelled by you before dispatch
REVERSEDDebited, then reversed (reversalReason, reversalAmount, reversalStatus)

REJECTED is not only "admin denied": a create whose reserve fails is also marked REJECTED, with the funds returned.

#Response fields beyond the create body

GET /v1/withdrawals/{id}, the list and the cancel response add:

FieldWhat it is
legs[]{ id, legIndex, amount, status, endToEndId, failureCode, refunded }. A PIX payout above the per-transaction cap is split into N legs at approval; legIndex is 1-based.
legSummaryAggregate over legs: { count, paidOut, returned, awaitingReturn, pendingDecision, inFlight, total }.
recipientNameName matched at the destination, when available.
reversalReason, reversalAmount, reversalStatusPresent when the payout was reversed.

#Cancel a pending payout

POST /v1/withdrawals/{id}/cancel — optional body { "reason": "..." } (trimmed, up to 500 characters; default "Cancelado pelo lojista."). Same auth as the create endpoint.

  • Only while the payout is PENDING (nothing dispatched yet). It becomes CANCELLED, amount + fee go back to the wallet in the same transaction, and the outcome fires the webhooks below. Returns 200 with the payout (retrieve shape).
  • Already approved, dispatched or terminal → 409, code = WITHDRAWAL_NOT_CANCELLABLE. Rows that only our support team can cancel (source MANUAL, above the manual-approval floor, non-BRL) also return 409: cancel those through support.
  • In the dashboard, only the account owner or an admin can cancel (a regular member gets 403, code = WITHDRAWAL_CANCEL_ROLE_REQUIRED). Via the API, the account key is enough. The account receives a "Withdrawal cancelled" email.
  • Another account's payout → 404 — and the 404 never confirms the id exists.
bash
curl -X POST https://api.moonip.io/v1/withdrawals/f6a7b8c9-d0e1-4234-9f01-234567890123/cancel \
  -H "Authorization: Bearer $MOONIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Duplicate request" }'
ts
await client.payouts.cancel('f6a7b8c9-d0e1-4234-9f01-234567890123', { reason: 'Duplicate request' });

Python client.payouts.cancel(id, reason=…), PHP $client->payouts->cancel($id, $reason), .NET client.Payouts.CancelAsync(id, reason).

#Receipt

GET /v1/withdrawals/{id}/receipt — not only for COMPLETED: the document exists for every terminal status (COMPLETED, PARTIALLY_COMPLETED, FAILED, REJECTED, CANCELLED, REVERSED). Non-terminal → 400.

Response 200 OK

json
{
  "contentType": "application/pdf",
  "base64": "JVBERi0xLjQKJcTl8uXrp/Og0MTGCg=="
}

Only COMPLETED is titled "Comprovante de Transferência PIX"; the other documents record the outcome.

bash
curl https://api.moonip.io/v1/withdrawals/f6a7b8c9-d0e1-4234-9f01-234567890123/receipt \
  -H "Authorization: Bearer $MOONIP_API_KEY"
ts
const receipt = await client.payouts.receipt('f6a7b8c9-d0e1-4234-9f01-234567890123');

Python client.payouts.receipt(id), PHP $client->payouts->receipt($id), .NET client.Payouts.ReceiptAsync(id).

#5. Webhooks

Subscribe to payout.created, payout.approved, payout.paid and payout.failed. All use the versioned evt_ envelope and timestamped signature. Approval means processing, before settlement. See Webhooks.

json
{
  "id": "evt_5f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c",
  "object": "event",
  "api_version": "2026-07-23",
  "type": "payout.paid",
  "created_at": "2026-04-25T16:11:42.000Z",
  "data": {
    "object": {
      "id": "po_f6a7b8c9-d0e1-4234-9f01-234567890123",
      "object": "payout",
      "status": "paid"
    }
  }
}

data.object.status uses the public vocabulary: pending, processing, paid, failed. FAILED, REJECTED and CANCELLED all arrive as payout.failed with status: "failed".

payout.failed carries data.object.failure_code and data.object.failure_message when available. Use the stable code to decide what to do; see Payout failures.

#6. List your payouts

GET /v1/withdrawals — same API key as the create endpoint.

QueryDefaultDescription
page1Page number.
limit20Hard-capped at 100 — a larger value is silently reduced, no error.
status—CSV of either vocabulary (below). Unknown token → 400.
source—API, PANEL, MANUAL or INDEFINIDO (rows with no source). Any other value is silently ignored and the list is not filtered.
orderdescasc for oldest first; anything else is desc.

Both vocabularies are accepted in status, and the wire values in the response are the internal uppercase ones:

Public filterInternal wire values
pendingPENDING
processingAPPROVED, PROCESSING, CONFIRMING
paidCOMPLETED, PARTIALLY_COMPLETED
failedFAILED, REJECTED, CANCELLED
(none)REVERSED
bash
curl "https://api.moonip.io/v1/withdrawals?page=1&limit=20&status=paid&source=API&order=desc" \
  -H "Authorization: Bearer $MOONIP_API_KEY"
ts
const page = await client.payouts.list({ limit: 20, status: 'paid', page: 1, source: 'API', order: 'desc' });

Python client.payouts.list(limit=…, status=…, page=…, source=…, order=…), PHP $client->payouts->list([...]), .NET client.Payouts.ListAsync(...).

Response 200 OK

json
{
  "data": [
    {
      "id": "f6a7b8c9-d0e1-4234-9f01-234567890123",
      "type": "PIX",
      "status": "COMPLETED",
      "amount": 50000,
      "fee": 100,
      "netAmount": 50000,
      "currency": "BRL",
      "source": "API",
      "completedAt": "2026-04-25T16:11:42.000Z",
      "recipientName": "Maria Silva",
      "legs": [
        {
          "id": "c1d2e3f4-0000-0000-0000-000000000001",
          "legIndex": 1,
          "amount": 50000,
          "status": "COMPLETED",
          "endToEndId": "E12345678202604251611abcdef123456"
        }
      ],
      "legSummary": { "count": 1, "paidOut": 50000, "returned": 0, "awaitingReturn": 0, "pendingDecision": 0, "inFlight": 0, "total": 50000 },
      "...": "..."
    }
  ],
  "total": 17,
  "page": 1,
  "limit": 20
}

#7. Limits & rules

  • Per-transaction PIX cap — R$ 15.000,00 by default, per account (your contract may extend it). Above the cap the payout is split into N legs at approval, each leg up to the cap.
  • Per-account withdrawal bounds — minWithdrawalAmount / maxWithdrawalAmount per request, dailyPayoutMaxAmount / dailyPayoutMaxCount per day (null = no limit). Out of bounds answers AMOUNT_BELOW_MINIMUM, AMOUNT_ABOVE_MAXIMUM or DAILY_PAYOUT_LIMIT_EXCEEDED.
  • Wallet must cover amount + fee — partial debits never happen; the request is rejected outright.
  • Approval — payouts at or above manualApprovalThresholdCents (default R$ 15.001,00 — one cent above the PIX cap, so any single-leg PIX up to the cap auto-approves), every non-BRL payout and rows sent to manual review need approval. Auto-payout is account policy (autoPayoutEnabled, autoWithdrawPct, AUTO_PAYOUT_TYPES, AUTO_PAYOUT_MAX_AMOUNT_CENTS, PILOT_MANUAL_WITHDRAWALS) — ask support to review yours.
  • TED — restricted to Brazilian banks (FEBRABAN bankCode list). Settlement: same business day if approved before 16:30 BRT.

#Error codes

Branch on error.code. The message is for your logs; the code is the contract. Exception: three tokens (WALLET_NOT_FOUND, WALLET_NOT_SPENDABLE, TENANT_KYC_REQUIRED) are plain message text — their rows show the fallback error.code, and you match the token in error.message. Full map: Errors.

HTTPerror.codeWhen
400INSUFFICIENT_BALANCENo wallet covers the debit (details: available, requested, currency). 400 on this path — not 422.
400invalid_requestThe explicit walletId is unknown, inactive, of another currency or not spendable. Token in error.message: WALLET_NOT_FOUND / WALLET_NOT_SPENDABLE.
400AMOUNT_BELOW_MINIMUM / AMOUNT_ABOVE_MAXIMUMOutside the account's withdrawal bounds.
422DAILY_PAYOUT_LIMIT_EXCEEDEDThe account's daily amount or count cap was reached.
403account_suspendedAccount suspended or blocked.
403forbiddenAccount KYC is not active (when the KYC gate is enforced). Token in error.message: TENANT_KYC_REQUIRED.
403WITHDRAWAL_CANCEL_ROLE_REQUIREDDashboard cancel by a member who is not the owner or an admin.
404not_foundUnknown id, or a payout of another account — the 404 never confirms the id exists.
409WITHDRAWAL_NOT_CANCELLABLECancel of a non-PENDING payout, or a row only support can cancel.
409(no stable code)Keyless create near-duplicate within 90 s. Send an Idempotency-Key and retry.

#FAQ

Q: I created a payout but it's stuck on PENDING. A: It is waiting for approval — immediate dispatch only covers what your account policy allows (manualApprovalThresholdCents, default R$ 15.001,00). Approve via the dashboard or ask support about auto-payout.

Q: My payout FAILED — was I charged the fee? A: No. FAILED triggers an automatic, atomic wallet refund of amount + fee. The refund lands as soon as the wallet movement happens; when failure_message is "Estamos confirmando com o banco; você será avisado.", the refund is held (legs[].refunded is false) — the money is not back yet, wait for the credit and notice.

Q: Can I cancel a payout? A: Only while PENDING, with POST /v1/withdrawals/{id}/cancel or the Cancel withdrawal button in the dashboard. Once approved, the funds are in flight.

Q: My CNPJ key was rejected as invalid even though my bank accepts it. A: The mod-11 checksum is enforced and there is no bypass parameter. If your key fails the check, contact support.

Q: TED bank not in my dropdown. A: We use the Bacen FEBRABAN code list. Open a ticket if a code is missing — usually fixed within 24h.