On-ramp and off-ramp · Brazil
Brazil on-ramp and off-ramp, in one API
PIX is the country's payment method: instant, 24/7, and everyone has it. What newcomers lack is not demand — it is the plumbing. That is what this API is.
Who this is for
- A foreign company that wants to sell, pay or collect in Brazil without building a local operation from scratch.
- An exchange or broker that needs a BRL pair without running a Brazilian bank.
- A global product with PIX on the roadmap that does not want three vendors for one thing.
The problem
Everyone arriving in Brazil hits the same sequence: PIX requires an authorised institution, the account requires a local entity, the entity requires partners and time, and converting to stablecoin requires an exchange with its own KYC. That is months before the first transaction — and each part is a separate contract, limit and reconciliation.
The alternative is to treat Brazil as a rail rather than a subsidiary: you talk to an API, money comes in and out in reais, and your balance stays in stablecoin. The teams already doing this integrated by reading the docs, with no meeting.
The flow, end to end
- In:
POST /cashin/chargecreates a PIX QR identified by the payer's tax id. Once paid, the stablecoin goes to your wallet. - Out:
POST /cash-outsquotes,/acceptlocks and returns the address; you deposit and a PIX key receives reais. - Custody (optional): with
destino: "saldo"the PIX becomes a BRL balance per sub-account, and you pay out withPOST /payoutswhenever you want. - Both directions use the same key, the same error shape (
erro+acao) and the sameexternal_id. - Every event arrives signed by webhook; nothing requires polling.
Endpoints used
| Endpoint | What for |
|---|---|
POST /cashin/charge | In: creates the PIX QR and delivers stablecoin. |
POST /cash-outs + /accept | Out: quote, lock and get the deposit address. |
POST /payouts | Direct PIX in reais from the custodial balance, no crypto leg. |
GET /keys/me | Your current limits, tier and what is enabled. Read from here, not from code. |
GET /v1/verificar/{e2e} | Public verification of a settled PIX. No key. |
A example that runs
# entrada: PIX vira stablecoin na sua carteira
curl -X POST https://api.luniumpay.com/cashin/charge -H "X-API-Key: $LUNIUM_KEY" \
-H "Content-Type: application/json" \
-d '{"amount_cents":100000,"asset":"usdc","chain":"polygon",
"payout_address":"0xTesouraria","payer_tax_number":"12345678909",
"external_id":"in-9001"}'
# saída: stablecoin vira PIX na conta de alguém
curl -X POST https://api.luniumpay.com/cash-outs -H "X-API-Key: $LUNIUM_KEY" \
-H "Content-Type: application/json" \
-d '{"asset":"USDC","network":"polygon","brl_amount":"1000.00",
"pix_key":"11122233344","pix_key_type":"cpf","external_id":"out-9001"}'const h = { "X-API-Key": process.env.LUNIUM_KEY, "Content-Type": "application/json" };
export const entrada = (cents, carteira, docPagador, id) =>
fetch(`${API}/cashin/charge`, { method: "POST", headers: h, body: JSON.stringify({
amount_cents: cents, asset: "usdc", chain: "polygon",
payout_address: carteira, payer_tax_number: docPagador, external_id: id,
})}).then((r) => r.json());
export const saida = (brl, chave, tipo, id) =>
fetch(`${API}/cash-outs`, { method: "POST", headers: h, body: JSON.stringify({
asset: "USDC", network: "polygon", brl_amount: brl,
pix_key: chave, pix_key_type: tipo, external_id: id,
})}).then((r) => r.json());import os, requests
API, H = "https://api.luniumpay.com", {"X-API-Key": os.environ["LUNIUM_KEY"]}
def entrada(cents, carteira, doc_pagador, ident):
return requests.post(f"{API}/cashin/charge", headers=H, timeout=30, json={
"amount_cents": cents, "asset": "usdc", "chain": "polygon",
"payout_address": carteira, "payer_tax_number": doc_pagador,
"external_id": ident}).json()
def saida(brl, chave, tipo, ident):
return requests.post(f"{API}/cash-outs", headers=H, timeout=30, json={
"asset": "USDC", "network": "polygon", "brl_amount": brl,
"pix_key": chave, "pix_key_type": tipo, "external_id": ident}).json()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
Every error carries erro (what happened) and acao (what to do): corrigir,
repetir, esperar or parar. It is the field an agent or an automatic retry can
follow without heuristics.
parar is the important one: on provedor_sem_resposta in a payout, the PIX may have gone out.
Look the operation up, do not repeat.
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
Sandbox in one call, no signup. The sandbox contract is identical to production — same fields, same states, same errors — so the code you wrote while testing is the code that ships.
Questions integrators ask
Do I need a Brazilian entity?
Not to integrate or to test. What exists is review proportional to volume, as on any payment rail.
How does the limit work when starting out?
The ladder is per paying tax id: BRL 60.00 on the first ever payment, BRL 200.00 in the first 24 h and BRL 6,000.00/day afterwards. Above that the QR is still accepted, held for 24 h. Because the limit belongs to the payer, many payers add up with no single ceiling.
Do you hold my money?
Only if you ask. In the default flow the stablecoin goes straight to your wallet and the PIX straight to the destination key. BRL custody is a separate product, via destino: "saldo".
Can an AI agent use it?
Yes: besides REST there is MCP, an Agent Card and llms.txt. Every error carries machine-readable guidance, and the PIX verifier is open.
OpenAPI contract 1.25.0 · facts on this page in product-truth.json · status