Wallets and apps · replacing the ramp
PIX and stablecoin for wallets and apps
Your app is already live and the BRL deposit is the bottleneck. Here each user has their own limit, each operation has state, and the screen can show which stage it is in — without you storing anything.
Who this is for
- A wallet or app already published, replacing its current Brazilian on/off-ramp.
- A product that needs deposit and withdrawal in one integration, not two vendors.
- A team that wants to show the user where the operation is, not just a spinner.
The problem
In a wallet app, the deposit is where users give up. They type an amount, the provider refuses without saying what would fit, and the screen shows "error". After paying, they stare at a spinner with no idea whether it is five seconds or five minutes. Those two moments cost more users than any fee.
Both have an API answer. Each payer's limit is queryable before the keypad appears, and the status carries a server-stamped timeline with the measured median of each stage — you can write "step 3 of 5, usually 39 s" without making a number up.
The flow, end to end
- The user enters their tax id once. You call
GET /cashin/limits?payer_tax=and already show how much they can deposit right now and how much fits with a hold. - They pick an amount.
POST /cashin/chargewithcustomer_ref(their id in your system) returns the copy-and-paste code. - They pay. The
cashin.paidwebhook arrives; the screen changes stage without polling. - Delivery happens and
cashin.settledbrings the hash. If the destination issaldo, the credit shows withliberar_em. - To withdraw:
POST /payouts(PIX) orPOST /saldo/sacar-cripto(any catalog coin), always with the samecustomer_ref.
Endpoints used
| Endpoint | What for |
|---|---|
GET /cashin/limits?payer_tax= | How much this user can pay right now — before the screen asks for an amount. |
POST /cashin/charge | Deposit, with customer_ref tying the operation to the user. |
GET /cashin/{cashin_id}/status | Timeline and typical times per stage: this is what feeds the tracker. |
GET /saldo?customer_ref= | That user's balance, hold and upcoming releases. |
POST /saldo/sacar-cripto | Withdrawal in any catalog coin, debited from the user's balance. |
POST /payouts | PIX withdrawal to the user's own key or a third party's. |
A example that runs
# depósito do usuário: PIX identificado pelo CPF dele, cripto na carteira dele
curl -X POST https://api.luniumpay.com/cashin/charge -H "X-API-Key: $LUNIUM_KEY" \
-H "Content-Type: application/json" \
-d '{"amount_cents":15000,"asset":"usdt","chain":"polygon",
"payout_address":"0xCarteiraDoUsuario",
"payer_tax_number":"12345678909",
"customer_ref":"user_8842","external_id":"dep-8842-17"}'
# limite daquele usuário ANTES de mostrar o teclado de valor
curl "https://api.luniumpay.com/cashin/limits?payer_tax=12345678909" -H "X-API-Key: $LUNIUM_KEY"async function limiteDoUsuario(cpf) {
const r = await fetch(`${API}/cashin/limits?payer_tax=${cpf}`, {
headers: { "X-API-Key": process.env.LUNIUM_KEY },
});
const l = await r.json();
return { naHora: l.instant_available_cents, comRetencao: l.held_qr?.available_cents ?? 0 };
}
async function depositar({ cents, cpf, carteira, userId, idempotencia }) {
const r = await fetch(`${API}/cashin/charge`, {
method: "POST",
headers: { "X-API-Key": process.env.LUNIUM_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
amount_cents: cents, asset: "usdt", chain: "polygon",
payout_address: carteira, payer_tax_number: cpf,
customer_ref: userId, external_id: idempotencia,
}),
});
if (r.status === 403) throw new Error("acima do limite deste pagador");
return r.json();
}import os, requests
API, H = "https://api.luniumpay.com", {"X-API-Key": os.environ["LUNIUM_KEY"]}
def limite_do_usuario(cpf):
l = requests.get(f"{API}/cashin/limits", headers=H, params={"payer_tax": cpf}, timeout=20).json()
return l["instant_available_cents"], (l.get("held_qr") or {}).get("available_cents", 0)
def depositar(cents, cpf, carteira, user_id, idempotencia):
r = requests.post(f"{API}/cashin/charge", headers=H, timeout=30, json={
"amount_cents": cents, "asset": "usdt", "chain": "polygon",
"payout_address": carteira, "payer_tax_number": cpf,
"customer_ref": user_id, "external_id": idempotencia})
if r.status_code == 403:
raise RuntimeError("acima do limite deste pagador")
return r.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
Above the payer's limit: 403 limite_do_pagador with acao: "corrigir" and how much
fits. Show the number, not the word "error" — that is what converted best in the apps already using it.
Below the route minimum: the response carries the estimated minimum in reais and nothing is debited. Offer the right amount in one tap.
A sub-account's first deposit: withdrawals are locked for 24 h (423 carencia_primeiro_deposito with
libera_em). Show the time, not "try later".
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 app's critical path is two calls: limit and charge. Both exist in the sandbox with the same contract — you can build the whole screen before a commercial contract exists.
Questions integrators ask
Do I need to store operation state?
No. GET /cashin/{id}/status carries the server-stamped timeline and typical times per stage; the webhook announces each transition. Your database keeps the external_id and the rest comes from here.
How do I separate users?
By customer_ref. It travels on the charge, the balance, the withdrawal and the statement — the ledger keeps each customer apart without you creating a key per user.
Can users withdraw coins other than USDT?
Yes. POST /saldo/sacar-cripto delivers any catalog coin through the same rail as a purchase, with the route minimum checked before the debit.
Can a user deposit more than the initial limit?
Yes: above the instant ladder the QR is still accepted and the provider holds it for 24 h. If your product cannot wait, send allow_hold: false and get a 403 stating how much goes out immediately.
OpenAPI contract 1.25.0 · facts on this page in product-truth.json · status