Payout · direct PIX in reais
PIX payout API
Paying in reais with no crypto leg: you send the amount and the key, the PIX goes out and you get the receipt. Use it for users, suppliers, commissions and refunds.
Who this is for
- A platform paying many people that today does it by spreadsheet and internet banking.
- A marketplace that needs to pay sellers with a trail and a receipt.
- An operation that already holds a BRL balance with us and wants to distribute it over API.
The problem
Mass payouts usually live outside the system: someone exports a spreadsheet, uploads it to a bank, checks it by hand and then tries to explain each line. There is no idempotency, no state, and the receipt is an image.
A payout API fixes all three at once: external_id stops double payment, the state is queryable, and the receipt
is the EndToEndId — which the other side verifies on their own in the open verifier.
The flow, end to end
- You hold a BRL balance on the key (from a PIX deposit with
destino: "saldo", or from selling crypto). - Before paying, check the key owner with
GET /pix/keys/lookup: the bank refuses a key that does not belong to the stated document. POST /payoutswith amount, key, type, the receiver's tax id and yourexternal_id.- The operation is recorded and the balance debited in the same transaction, before any provider call — there is no debit without an order.
payout.sentarrives withe2eandverify_url. An explicit failure refunds the amount and the fee.
Endpoints used
| Endpoint | What for |
|---|---|
GET /pix/keys/lookup?key= | Key owner (name, masked document, bank) before sending. |
POST /payouts | Sends the PIX, debiting the balance. Idempotent by external_id. |
GET /payouts/{payout_id} | State, itemised fees, EndToEndId and receipt link. |
GET /saldo | Available, held, holding period and your key's current fees. |
GET /v1/verificar/{e2e} | Public verification of the paid PIX — the receiver checks with no key. |
A example that runs
# 1. confira o titular da chave ANTES de mandar
curl "https://api.luniumpay.com/pix/keys/lookup?key=11122233344&type=cpf" -H "X-API-Key: $LUNIUM_KEY"
# 2. envie o PIX
curl -X POST https://api.luniumpay.com/payouts -H "X-API-Key: $LUNIUM_KEY" \
-H "Content-Type: application/json" \
-d '{"amount_cents":50000,"pix_key":"11122233344","pix_key_type":"cpf",
"tax_number":"11122233344","beneficiary_name":"Joao P.",
"customer_ref":"fornecedor_31","external_id":"pgto-2026-0918"}'async function pagarPix({ cents, chave, tipo, doc, nome, ref, idempotencia }) {
const h = { "X-API-Key": process.env.LUNIUM_KEY, "Content-Type": "application/json" };
const titular = await (await fetch(
`${API}/pix/keys/lookup?key=${encodeURIComponent(chave)}&type=${tipo}`, { headers: h })).json();
if (titular.verified && titular.owner_tax_number && !bate(titular.owner_tax_number, doc)) {
throw new Error("a chave não pertence a este CPF/CNPJ");
}
const r = await fetch(`${API}/payouts`, { method: "POST", headers: h, body: JSON.stringify({
amount_cents: cents, pix_key: chave, pix_key_type: tipo,
tax_number: doc, beneficiary_name: nome,
customer_ref: ref, external_id: idempotencia,
})});
const p = await r.json();
if (r.status === 502 && p.erro === "provedor_sem_resposta") {
return { indefinido: true, consultar: `/payouts/${p.payout_id ?? idempotencia}` };
}
return p;
}import os, requests
API, H = "https://api.luniumpay.com", {"X-API-Key": os.environ["LUNIUM_KEY"]}
def pagar_pix(cents, chave, tipo, doc, nome, ref, idempotencia):
titular = requests.get(f"{API}/pix/keys/lookup", headers=H, timeout=20,
params={"key": chave, "type": tipo}).json()
if titular.get("verified") and titular.get("owner_tax_number") and not bate(titular["owner_tax_number"], doc):
raise RuntimeError("a chave nao pertence a este CPF/CNPJ")
r = requests.post(f"{API}/payouts", headers=H, timeout=60, json={
"amount_cents": cents, "pix_key": chave, "pix_key_type": tipo,
"tax_number": doc, "beneficiary_name": nome,
"customer_ref": ref, "external_id": idempotencia})
p = r.json()
if r.status_code == 502 and p.get("erro") == "provedor_sem_resposta":
return {"indefinido": True} # consulte, nao repita
return pSandbox: test before spending
One call, no signup, no card. The lun_test_ key exercises the whole surface without
moving any money.
curl -X POST https://api.luniumpay.com/keys/sandbox \
-H "Content-Type: application/json" \
-d '{"name":"my-test"}'
What the sandbox does not prove: real settlement. No crypto moves and no PIX is paid — it is there to get the contract right, not to measure the rail.
Assets and networks
Instant (our own rail): USDT on Polygon and USDC na Polygon — settled in seconds, no exchange in the path.
Through the exchange: 1,478 assets across 241 networks on the sell side (1,668 asset×network pairs) and 1,795 routes on the buy side. The wait is the number of confirmations each chain requires, not a choice of ours.
Shared-address networks (ALGO, ATOM, EOS, HBAR, KAVA, LUNA, TON, XLM, XRP): the deposit must carry the memo. On the sell side it comes
as deposit_tag in the accept; on the buy side you send payout_tag with the address.
The catalog changes on its own as networks come and go. Read it from GET /catalog and GET /cashin/catalog instead of keeping a list in code.
Limits and holds
| What | Value |
|---|---|
| Cash-in and PIX payout, per operation | BRL 1.00 to BRL 6,000.00 |
| Cash-out, per operation | from BRL 6.00 |
| QR validity | 15 minutes |
| Payer ladder (CPF/CNPJ of whoever pays) | BRL 60.00 first ever → BRL 200.00 in the first 24 h → BRL 6,000.00/day |
| Above the ladder | the QR is still accepted and the provider holds it for 24 h (D+1), up to BRL 6,000.00/day per payer |
The ladder is per paying document, not per receiving account: ten payers are ten independent
limits. That is what makes this work for e-commerce and marketplaces. Check a payer's headroom before
charging with GET /cashin/limits?payer_tax=, and read your key's limits from
GET /keys/me — ceilings above the tier are contracted.
Measurements, not promises
Measured in production, 90-day window, on 2026-09-07:
| Leg | Median | p90 | Sample |
|---|---|---|---|
| PIX received → stablecoin delivered (Polygon) | 6 s | 70 s | 112 operations |
| Quote accepted → PIX paid (Polygon) | 81 s | 580 s | 27 operations |
Off Polygon the clock is the network's: the wait is the number of confirmations the exchange
requires, and it is in eta in the catalog. We do not promise "instant" off our own rail.
Idempotency
Send external_id on every money-moving call. Repeating the same call returns the
same operation, never a second one; the same parameters with a different id create two, and the
same id with different parameters answers 409 external_id_divergente instead of guessing.
A timeout is not a refusal. If the call did not answer, look the operation up by
external_id before repeating — it may already exist.
Webhooks and reconciliation
Every transition fires an event signed with HMAC SHA-256 plus a timestamp (header
X-Lunium-Signature, format t=…,v1=…) — the timestamp is what stops a captured
delivery from being replayed. Verify the signature before trusting the body.
The webhook is the primary signal; polling is optional. When you do poll, the status carries a
timeline stamped by the server — draw the stage from it, because your polling stopwatch
measures your network, not the operation.
Did not answer 2xx? Delivery is retried with backoff. You can resend by hand at
POST /webhooks/deliveries/{event_id}/retry and test your URL at POST /webhooks/test.
When it goes wrong
Insufficient balance: 402 saldo_insuficiente with available and held amounts. Nothing is debited.
No answer from the provider: 502 provedor_sem_resposta with acao: "parar". The PIX may have
gone out: query GET /payouts/{id}, do not repeat. The order exists on our side even then — that is what makes
reconciliation possible instead of guesswork.
Explicit bank refusal: amount and fee return to the balance, and the reason stays in error.
Sub-account's first deposit: 423 carencia_primeiro_deposito with libera_em.
Security
- webhook assinado — Todo webhook vai assinado em HMAC SHA-256 com timestamp (header X-Lunium-Signature, formato t=…,v1=…), o que impede replay de uma entrega capturada.
- idempotencia — external_id em cash-in, cash-out e payout: repetir a mesma chamada devolve a MESMA operação, nunca uma segunda. Parâmetros diferentes no mesmo external_id respondem 409.
- egress validado — A URL de webhook é resolvida e validada antes de cada entrega: endereço interno, metadata de nuvem e redirect para rede privada são recusados, e a conexão é fixada no IP validado.
- retencao fail closed — A retenção é verificada na liquidação e repetida na reivindicação SQL: nenhum caminho (worker, consulta de status, fila) consegue liquidar antes da hora.
- payout duravel — O saque grava a operação e debita o saldo na MESMA transação, antes de qualquer chamada ao provedor: não existe débito sem ordem para reconciliar.
- verificacao aberta — GET /v1/verificar/{e2e} confere um PIX liquidado sem chave nenhuma — a contraparte não precisa confiar na nossa palavra.
Open verification is the one that matters in a negotiation: your counterparty checks the PIX themselves, with no key and without taking our word for it.
Time to first integration
One call, plus the owner lookup that prevents the most common refusal. In production the PIX usually lands within a few minutes; the declared maximum is one hour.
Questions integrators ask
How do fees show up?
Separately: fee_service_cents is your key's fee and fee_provider_cents is the provider tariff. Their sum is fee_cents, debited with the amount. A refusal refunds both.
Can I pay third parties?
Yes. tax_number is the receiver's document and must own the key — hence the owner lookup before sending.
What happens if I repeat the call?
With the same external_id, you get the same operation back. Without it, you would create a second payment — always send it.
Where does the money come from?
From your key's custodial BRL balance. You fund it with a PIX deposit using destino: "saldo", or by selling crypto into the balance.
OpenAPI contract 1.25.0 · facts on this page in product-truth.json · status