Cash-out · stablecoin → PIX
USDT to PIX API
You send crypto to an address created per order. A PIX key receives reais, with a receipt the counterparty verifies on their own. Quote, accept, deposit — three calls.
Who this is for
- Anyone who needs stablecoin balance to become reais in someone's account, without a manual exchange step.
- A platform paying users, contractors or partners in Brazil out of a crypto treasury.
- A product that already has an off-ramp and wants one with a locked quote, explicit states and automatic refund on failure.
The problem
Selling crypto and getting the PIX into the right account is usually a manual process: someone sends to an exchange, waits for the sale, withdraws to a bank and transfers. Each step has a cut-off, a limit and a human. When it fails, the money sits somewhere nobody can point at.
Here the order has state. You know where it is, the amount is locked by the quote you accepted, and a failure has a defined destination: the crypto goes back to the refund address you supplied.
The flow, end to end
- Quote with
POST /cash-outs/preview, in either direction: by amount in reais or by crypto quantity. Nothing is created. - Create the order with
POST /cash-outs, naming the PIX key that receives — or the copy-and-paste code of a charge. - Accept with
POST /cash-outs/{id}/accept. The response carries the deposit address, the exact amount andexpires_at. - Send exactly the quoted amount, on the quoted network. On shared-address networks, include the
deposit_tag. - The PIX goes out on its own.
cashout.completedbrings thepix_e2eand the receipt URL.
Endpoints used
| Endpoint | What for |
|---|---|
POST /cash-outs/preview | Quote both ways, without creating an order or consuming a limit. Built for screens that quote on every keystroke. |
POST /cash-outs | Creates the quote with the destination PIX key, or with br_code to pay a charge. |
POST /cash-outs/{id}/accept | Locks the quote and returns the deposit address, plus the memo when the network requires one. |
GET /cash-outs/{id} | State, server-stamped timeline and receipt. |
GET /pix/keys/lookup?key= | Key owner before the accept: show "you are paying John · Bank" and avoid a refusal. |
GET /catalog | Assets, networks and per-route minimums. Source of truth, changes on its own. |
A example that runs
# 1. cotação (não cria ordem)
curl -X POST https://api.luniumpay.com/cash-outs/preview \
-H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
-d '{"asset":"USDT","network":"polygon","brl_amount":"500.00"}'
# 2. cria a ordem e aceita
ID=$(curl -sX POST https://api.luniumpay.com/cash-outs \
-H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
-d '{"asset":"USDT","network":"polygon","brl_amount":"500.00",
"pix_key":"loja@exemplo.com","pix_key_type":"email",
"external_id":"saque-4412"}' | jq -r .cashout_id)
curl -X POST https://api.luniumpay.com/cash-outs/$ID/accept -H "X-API-Key: $LUNIUM_KEY"const cabecalho = { "X-API-Key": process.env.LUNIUM_KEY, "Content-Type": "application/json" };
const previa = await (await fetch(`${API}/cash-outs/preview`, {
method: "POST", headers: cabecalho,
body: JSON.stringify({ asset: "USDT", network: "polygon", brl_amount: "500.00" }),
})).json();
const ordem = await (await fetch(`${API}/cash-outs`, {
method: "POST", headers: cabecalho,
body: JSON.stringify({
asset: "USDT", network: "polygon", brl_amount: "500.00",
pix_key: "loja@exemplo.com", pix_key_type: "email", external_id: "saque-4412",
}),
})).json();
const aceita = await (await fetch(`${API}/cash-outs/${ordem.cashout_id}/accept`, {
method: "POST", headers: cabecalho,
})).json();
console.log(aceita.deposit_address, aceita.deposit_tag, aceita.expires_at);import os, requests
API = "https://api.luniumpay.com"
h = {"X-API-Key": os.environ["LUNIUM_KEY"]}
previa = requests.post(f"{API}/cash-outs/preview", headers=h, timeout=30, json={
"asset": "USDT", "network": "polygon", "brl_amount": "500.00"}).json()
ordem = requests.post(f"{API}/cash-outs", headers=h, timeout=30, json={
"asset": "USDT", "network": "polygon", "brl_amount": "500.00",
"pix_key": "loja@exemplo.com", "pix_key_type": "email",
"external_id": "saque-4412"}).json()
aceita = requests.post(f"{API}/cash-outs/{ordem['cashout_id']}/accept",
headers=h, timeout=60).json()
print(aceita["deposit_address"], aceita.get("deposit_tag"), aceita["expires_at"])Sandbox: 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
Deposit with an amount different from the quote: the order does not match and goes to review instead of
paying an amount nobody recognises. Send the exact amount from the response.
Wrong network, or after expires_at: the deposit arrives orphaned and becomes a manual review.
Always deposit before expiry, on the quoted network.
The bank refuses the key: the crypto goes back to the refund_address you supplied, with state
REFUNDED and the refund hash — no balance left hanging with us.
A memo network without the memo: the funds land at the exchange with no owner. That is why the accept returns
deposit_tag on those networks and the docs insist: if it came filled, it is mandatory.
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
Three calls and a deposit. Anyone who only wants the price stops at the first, which creates nothing and consumes no limit.
Our own rail (Polygon) is the predictable one: 81 s median and 580 s p90 from accept to PIX
paid. On other networks the clock is the confirmations — it is in eta in the catalog.
Questions integrators ask
Can the amount change between quote and deposit?
No. The accept locks the quote and expires_at says how long it holds. Depositing after that is what breaks the match — not a price move.
Can I pay a QR code instead of a key?
Yes. Send br_code (the whole copy-and-paste string) instead of pix_key, with USDT or USDC on any catalog network. The order's brl_amount becomes exactly the QR amount, and you can see the merchant before confirming by sending the same br_code to the preview.
How does the other side verify the PIX was paid?
By the EndToEndId, in the open verifier: GET /v1/verificar/{e2e} answers with no key at all. The counterparty does not have to take your word, or ours.
Can I pay third parties?
Yes, the key does not have to be yours. The daily receiving limit is per receiving tax id and is in GET /keys/me.
Which networks settle fastest?
Polygon, on our own rail, in seconds. Off it, the wait is the confirmations the exchange requires, listed per route in GET /catalog.
OpenAPI contract 1.25.0 · facts on this page in product-truth.json · status