Infrastructure · sub-accounts and custody
PIX and stablecoin infrastructure, under your brand
The "Deposit and Withdraw" tab of your product, without you becoming a payment institution. Each end customer is a sub-account in the ledger, keyed by the id that already exists in your system.
Who this is for
- A product that wants to hold end customers' reais without building custody from scratch.
- A platform that needs deposit, balance, PIX withdrawal and crypto withdrawal in one place.
- Anyone with users who needs each person's money separated with an auditable trail.
The problem
Holding other people's money is where most products stall. It is not just receiving: it is separating per customer, holding what cannot leave yet, proving every movement and being able to explain a balance months later. Building that from scratch means a ledger, reconciliation and an audit nobody wanted to start today.
Here the separation is a field. customer_ref travels on the charge, the balance, the withdrawal and the statement;
the ledger is append-only and every line has an origin. You ship the experience under your brand and write no reconciliation.
The flow, end to end
- Deposit:
POST /cashin/chargewithdestino: "saldo"and the customer'scustomer_ref. The PIX becomes a BRL balance in their sub-account. - Read:
GET /saldo?customer_ref=returns available, held, holding period and upcoming releases.GET /saldo/extratoreturns movement by movement. - Withdraw in reais:
POST /payoutswith the samecustomer_ref— it debits the right sub-account. - Withdraw in crypto:
POST /saldo/sacar-criptodelivers any catalog coin, with the route minimum checked before the debit. - Move between customers:
POST /saldo/transferirsettles inside the ledger, touching no external rail.
Endpoints used
| Endpoint | What for |
|---|---|
POST /cashin/charge (destino: saldo) | BRL deposit into the end customer's sub-account. |
GET /saldo?customer_ref= | Balance, holding period, current fees and upcoming releases. |
GET /saldo/extrato | Movement by movement, with each line's origin. |
GET /saldo/clientes · /saldo/consolidado | All sub-accounts and the total under custody. |
POST /saldo/transferir | Transfer between sub-accounts, inside the ledger. |
POST /saldo/sacar-cripto · POST /payouts | The two exits: crypto to the customer's wallet, or PIX to their key. |
A example that runs
# depósito na subconta de um cliente final
curl -X POST https://api.luniumpay.com/cashin/charge -H "X-API-Key: $LUNIUM_KEY" \
-H "Content-Type: application/json" \
-d '{"amount_cents":50000,"destino":"saldo","customer_ref":"cliente_412",
"payer_tax_number":"12345678909","external_id":"dep-412-88"}'
# saldo daquele cliente
curl "https://api.luniumpay.com/saldo?customer_ref=cliente_412" -H "X-API-Key: $LUNIUM_KEY"
# transferência entre subcontas, sem sair do livro-razão
curl -X POST https://api.luniumpay.com/saldo/transferir -H "X-API-Key: $LUNIUM_KEY" \
-H "Content-Type: application/json" \
-d '{"from_customer_ref":"cliente_412","to_customer_ref":"cliente_907",
"amount_cents":15000,"external_id":"tr-9912"}'const h = { "X-API-Key": process.env.LUNIUM_KEY, "Content-Type": "application/json" };
const api = (rota, corpo) => fetch(`${API}${rota}`, corpo
? { method: "POST", headers: h, body: JSON.stringify(corpo) }
: { headers: h }).then((r) => r.json());
export const depositar = (cliente, cents, cpf, id) => api("/cashin/charge", {
amount_cents: cents, destino: "saldo", customer_ref: cliente,
payer_tax_number: cpf, external_id: id,
});
export const saldoDe = (cliente) => api(`/saldo?customer_ref=${encodeURIComponent(cliente)}`);
export const sacarPix = (cliente, cents, chave, tipo, doc, id) => api("/payouts", {
amount_cents: cents, pix_key: chave, pix_key_type: tipo,
tax_number: doc, customer_ref: cliente, external_id: id,
});
export const sacarCripto = (cliente, cents, ativo, rede, carteira, doc, id) => api("/saldo/sacar-cripto", {
amount_cents: cents, asset: ativo, chain: rede, payout_address: carteira,
tax_number: doc, customer_ref: cliente, external_id: id,
});import os, requests
API, H = "https://api.luniumpay.com", {"X-API-Key": os.environ["LUNIUM_KEY"]}
def depositar(cliente, cents, cpf, ident):
return requests.post(f"{API}/cashin/charge", headers=H, timeout=30, json={
"amount_cents": cents, "destino": "saldo", "customer_ref": cliente,
"payer_tax_number": cpf, "external_id": ident}).json()
def saldo_de(cliente):
return requests.get(f"{API}/saldo", headers=H, params={"customer_ref": cliente}, timeout=20).json()
def transferir(de, para, cents, ident):
return requests.post(f"{API}/saldo/transferir", headers=H, timeout=30, json={
"from_customer_ref": de, "to_customer_ref": para,
"amount_cents": cents, "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
Holding period: each deposit can only leave after carencia_horas, and a sub-account's first deposit
locks withdrawals for 24 h. Both appear in GET /saldo and in the 423 with libera_em — show the
time to the customer, not a generic error.
Insufficient balance: 402 with available and held stated separately. Nothing is debited.
Withdrawal below the coin's minimum: refused before the debit, with the estimated minimum in reais — the customer's balance never leaves for a delivery that would die at the exchange.
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
The deposit screen is one call; the balance screen is another. What usually takes longer is deciding your holding policy — ours is configurable per key and visible in the API, so your interface can explain it without hard-coding anything.
Questions integrators ask
Does this make me a payment institution?
Custody and settlement are ours; you run the experience and the end-customer relationship. Your product's regulatory framing is a decision for you and your counsel — we do not sell a licence or a legal opinion.
How do I separate each user's money?
By customer_ref, the same id you already use. The ledger is append-only and every movement has a traceable origin; GET /saldo/consolidado closes the total under custody.
Can I set the holding period?
Yes, per key. The current value shows in GET /saldo as carencia_horas, and every credit carries liberar_em — your interface reads from there.
Can the end customer withdraw in crypto?
Yes: POST /saldo/sacar-cripto delivers any catalog coin through the same rail as a purchase, with the route minimum checked before debiting.
OpenAPI contract 1.25.0 · facts on this page in product-truth.json · status