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
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 endpointPayouts 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).
#1. Validate a PIX key (recommended)
Before creating the payout, check the destination key is well-formed.
POST /v1/withdrawals/validate-pix-key
{
"key": "12345678909",
"type": "CPF"
}Response 201 Created
{
"isValid": true,
"type": "CPF",
"normalized": "12345678909"
}Supported type values
| Type | Format |
|---|---|
CPF | 11 digits, mod-11 checksum. |
CNPJ | 14 digits, mod-11 checksum. |
EMAIL | RFC 5322, max 77 chars. |
PHONE | E.164 with +55 country code (+5511999999999). |
RANDOM | Random 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
{
"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
{
"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.
curl -X POST https://api.moonip.io/v1/withdrawals/quote \
-H "Authorization: Bearer $MOONIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "value": 50000 }'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
| Query | Required | Description |
|---|---|---|
amount | yes | Integer, 1 to 10.000.000.000 (smallest unit). |
currency | no | Default BRL. |
type | no | PIX (default) or TED. |
Response 200 OK
{
"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.
curl "https://api.moonip.io/v1/withdrawals/payout-fee?amount=50000¤cy=BRL&type=PIX" \
-H "Authorization: Bearer $MOONIP_API_KEY"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
{
"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).
curl "https://api.moonip.io/v1/withdrawals/payout-info?currency=BRL" \
-H "Authorization: Bearer $MOONIP_API_KEY"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.
| Field | Type | Required | Description |
|---|---|---|---|
type | enum | yes | PIX or TED. |
amount | integer | yes | Smallest currency unit. In the default gross mode it is what the recipient receives; the fee is debited on top. |
currency | string | no | Default BRL. |
pixKey | string | on PIX | Destination PIX key. |
pixKeyType | enum | no | CPF, CNPJ, EMAIL, PHONE, RANDOM. Omitted or unknown (for example EVP): ignored, key auto-detected. |
bankCode | string | on TED | Bank code (FEBRABAN). |
bankAgency | string | on TED | Branch (agência). |
bankAccount | string | on TED | Account number. |
walletId | string | no | Explicit spendable OPERATIONAL wallet in currency. Omitted → auto-select. Unknown → WALLET_NOT_FOUND; not spendable → WALLET_NOT_SPENDABLE; nothing covers the debit → INSUFFICIENT_BALANCE. |
amountType | enum | no | gross (default) or net. net is legacy and still accepted: the debit is amount and the recipient gets amount − fee (fee >= amount → 400). |
idempotencyKey | string | no | Fallback — 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
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)
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"
}'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
{
"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.codefor an immediate balance refusal, the failure codes (failure_code), and which of them refund automatically — is in Payout failures.
#4. Lifecycle
PENDING ─▶ APPROVED ─▶ PROCESSING ─▶ CONFIRMING ─▶ COMPLETED ✓ funds delivered
└▶ PARTIALLY_COMPLETED delivered in partsTerminal outcomes also include FAILED, REJECTED, CANCELLED and REVERSED. Ten statuses in total.
| Status | Wallet effect |
|---|---|
PENDING | Debited at create (amount + fee reserved) |
APPROVED | Reserved — approved, waiting dispatch |
PROCESSING | Reserved — sent for settlement |
CONFIRMING | Reserved — waiting the receiving institution's confirmation |
COMPLETED | Debited (terminal) — delivered |
PARTIALLY_COMPLETED | Debited (terminal) — delivered in parts (see legs) |
FAILED | Auto-refunded — dispatch failed (failure_code). The refund can be held until confirmed (see Payout failures) |
REJECTED | Refunded — refused at review, or by the reserve (system:reserve-failed) |
CANCELLED | Refunded (amount + fee) — cancelled by you before dispatch |
REVERSED | Debited, 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:
| Field | What 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. |
legSummary | Aggregate over legs: { count, paidOut, returned, awaitingReturn, pendingDecision, inFlight, total }. |
recipientName | Name matched at the destination, when available. |
reversalReason, reversalAmount, reversalStatus | Present 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 becomesCANCELLED,amount + feego back to the wallet in the same transaction, and the outcome fires the webhooks below. Returns200with the payout (retrieve shape). - Already approved, dispatched or terminal →
409,code=WITHDRAWAL_NOT_CANCELLABLE. Rows that only our support team can cancel (sourceMANUAL, above the manual-approval floor, non-BRL) also return409: 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 the404never confirms the id exists.
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" }'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
{
"contentType": "application/pdf",
"base64": "JVBERi0xLjQKJcTl8uXrp/Og0MTGCg=="
}Only COMPLETED is titled "Comprovante de Transferência PIX"; the other documents record the outcome.
curl https://api.moonip.io/v1/withdrawals/f6a7b8c9-d0e1-4234-9f01-234567890123/receipt \
-H "Authorization: Bearer $MOONIP_API_KEY"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.
{
"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.
| Query | Default | Description |
|---|---|---|
page | 1 | Page number. |
limit | 20 | Hard-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. |
order | desc | asc 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 filter | Internal wire values |
|---|---|
pending | PENDING |
processing | APPROVED, PROCESSING, CONFIRMING |
paid | COMPLETED, PARTIALLY_COMPLETED |
failed | FAILED, REJECTED, CANCELLED |
| (none) | REVERSED |
curl "https://api.moonip.io/v1/withdrawals?page=1&limit=20&status=paid&source=API&order=desc" \
-H "Authorization: Bearer $MOONIP_API_KEY"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
{
"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
legsat approval, each leg up to the cap. - Per-account withdrawal bounds —
minWithdrawalAmount/maxWithdrawalAmountper request,dailyPayoutMaxAmount/dailyPayoutMaxCountper day (null = no limit). Out of bounds answersAMOUNT_BELOW_MINIMUM,AMOUNT_ABOVE_MAXIMUMorDAILY_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
bankCodelist). 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.
| HTTP | error.code | When |
|---|---|---|
| 400 | INSUFFICIENT_BALANCE | No wallet covers the debit (details: available, requested, currency). 400 on this path — not 422. |
| 400 | invalid_request | The explicit walletId is unknown, inactive, of another currency or not spendable. Token in error.message: WALLET_NOT_FOUND / WALLET_NOT_SPENDABLE. |
| 400 | AMOUNT_BELOW_MINIMUM / AMOUNT_ABOVE_MAXIMUM | Outside the account's withdrawal bounds. |
| 422 | DAILY_PAYOUT_LIMIT_EXCEEDED | The account's daily amount or count cap was reached. |
| 403 | account_suspended | Account suspended or blocked. |
| 403 | forbidden | Account KYC is not active (when the KYC gate is enforced). Token in error.message: TENANT_KYC_REQUIRED. |
| 403 | WITHDRAWAL_CANCEL_ROLE_REQUIRED | Dashboard cancel by a member who is not the owner or an admin. |
| 404 | not_found | Unknown id, or a payout of another account — the 404 never confirms the id exists. |
| 409 | WITHDRAWAL_NOT_CANCELLABLE | Cancel 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.