Overview
Signed webhooks, native idempotency, a Telegram bot that reports every cent in real time and answers integration questions 24/7 — and documentation that fits on a single page.
Cash-in: PIX comes in, USDT goes out on Polygon. Cash-out: USDT and USDC on the major networks become PIX — Polygon in seconds, and Ethereum, BNB Chain, Arbitrum, Optimism, Base, Avalanche, TON, Aptos, NEAR and others within each network's confirmation time (full table).
Because here PIX already becomes stablecoin in your wallet (or the other way around) in a single call: quote, QR, confirmation, conversion and on-chain send are one pipeline of ours, with money guarantees (we never pay twice, we never send a fraction we did not receive) and automatic reconciliation 24/7 — even if your server goes down halfway through.
Integrate in 60 seconds
- Open your operations group — Sandbox MCP (agents) strongly recommended, takes 1 minute and is what guarantees you are notified when something happens to your money.
- Create your key — talk to the bot on Telegram (say "my name is YourCompany" and the credential is born in the chat) or a
POST /keys(there is a button below that does it right now). No waiting, no human in the loop — nobody generates a credential for you, you generate it yourself. - Create a charge —
POST /cashin/chargewith the amount and the payer's CPF/CNPJ. It returns a copy-and-paste PIX code ready for the screen. - Receive USDT — once it is paid, we settle on Polygon and notify you by webhook and Telegram, with the transaction hash.
Sandbox — test everything before spending anything
One call, no API key — this is the only endpoint that does not ask for a credential (IP-limited). No name, no email, no wallet, no approval.
curl -s -X POST https://api.luniumpay.com/keys/sandbox \
-H 'Content-Type: application/json' -d '{"name":"my first test"}'
You get a key lun_test_… that runs the entire cash-out without a cent moving, on the same base URL. What the sandbox covers: POST /cash-outs, the accept and every state through to the outcome. What it does not cover: cash-in (the test key refuses with
sandbox_sem_cashin — creating a charge would generate a genuinely payable QR), payouts (they depend on settled volume) and automatic webhooks: sandbox orders do not fire webhooks on their own — but a sandbox key can call POST /webhooks/test and validate your handler's signature and transport before any real operation. Code written here was designed to go to production unchanged: same shapes, same states, same error contract, same validations.
Real limits and financial behavior are those of your production key — read them from
GET /keys/me, not from this page.
The order does not complete instantly — it walks through the real states in about 15 seconds. That is on purpose: you need that tracking loop (or the webhook) in production anyway, so write it now.
Deterministic triggers
Testing only the happy path is how an integration breaks on day one. The first two decimal places of the amount choose the outcome — no luck involved, you can assert it in CI:
amount ending in | what happens | what that proves |
|---|---|---|
.01 | goes to delayed and completes on its own | your code does not call a held payment a failure |
.02 | fails | your error path runs |
.03 | the quote expires in 5s | you re-quote instead of insisting |
.04 | refused by limit, with limits populated | you read limits.min_amount instead of guessing |
.05 | completes in ~2 minutes | your polling is patient |
Also worth rehearsing: reusing an external_id with a different destination returns
409 — the case that confuses integrators most, and it is better to find it here.
The sandbox order generates a well-formed E2E with ISPB 00000000, which no real institution holds — so it can never be mistaken for a real payment.
GET /v1/verificar/{e2e} responds for it, flagged with sandbox: true.
Test orders disappear 2 hours after they are created. Never send crypto to a
deposit_address in sandbox: it has no owner and the funds would be lost.
MCP server — for AI agents
https://api.luniumpay.com/mcp
Streamable HTTP, revision 2025-06-18, stateless. Add it as a remote MCP connector in any host that speaks the protocol.
It works with no credential at all. Connect without a key and lunium_verify_pix_payment
is already available — it confirms that a PIX was settled from the E2E, including a payment that is not yours. It is how you check what the counterparty claims without having an account and without trusting them.
The other tools use the X-API-Key sent by the host as a header. Without it, they respond erro: "chave_ausente" with a acao: "parar" — they do not disappear from the list, so the agent can warn the user instead of concluding that the capability does not exist.
There are eight tools: verify payment · list what settles now · check payer limit · quote a sale · confirm · create charge · track sale · track charge.
The two that actually move money come marked with destructiveHint: true, so the host can require human approval, and confirming a sale requires a token bound to the amount, the network and the destination that were quoted. Selling takes two steps on purpose: quoting commits nothing, confirming is irreversible.
Operations group STRONGLY RECOMMENDED
There are three steps, just once:
- Create a group on Telegram named after your project. A suggestion that helps us identify you right away:
Lunium <> YourCompany. - Add both (both are required for the group to do its job):
—
@LuniumNotifyBot· the agent. Reports everything in real time, answers integration questions and runs commands (/cobrar,/status,/extrato).—@luniumB2C· the Lunium team. The human on the other side when the agent can't solve it: incident, release, commercial exception. - Link the group to your key — a group admin sends
/vincular. If that admin already created the key in the bot's DM, it's 1 tap; if not, get the code in the DM with/chaveand send/vincular lk…in the group.
/vincular. Check with /chave: if it replies with your project name, it's on.lun_… in the group. It gives access to the entire account and appears only once — we store only the hash. The link code lk… can circulate in the group; the key cannot. If it leaks, create another one and disable the old one.Do everything by chat — the agent AI
Beyond the REST API, Lunium gives you a real agent on Telegram. In a DM — or by mentioning it in your project's group — you charge, withdraw and pay by writing in plain Portuguese. It reads the real data in your account, assembles the operation and executes it with a single confirmation tap. Money never moves without your OK.
R$ 250.00 → m•••@empresa.com (email)
Comes out of your volume for the day.✅ Confirm PIX of R$ 250.00
po_a1b2… ✓"charge R$ 50 from CPF X in USDC", "withdraw 25 USDT to my PIX key", "where's charge ci_…?". The agent understands and executes — with no commands to memorize.
Charges, withdrawals and payments are proposed by the agent and only happen when you tap the confirm button. And only whoever asked can confirm.
Add the agent to the project group: the whole team gets every PIX in real time and anyone can operate (with confirmation) by mentioning the agent.
Integration question, 401 error, Node example? It answers right away — and it knows your tier, your usage and what you already have configured.
Create your key now
Right here from the docs. The key appears only once — save it right away.
Authentication
Every call carries your key in the header X-API-Key. The same key authorizes cash-in, cash-out, catalog and account.
curl https://api.luniumpay.com/keys/me \
-H "X-API-Key: lun_a1b2c3…"
Create key · self-service
Creates a new key with cash-in and cash-out already enabled. No authentication — this is the starting point. Rate-limited by IP (5/day).
| Field | Type | Description |
|---|---|---|
name required | string | Your business name (2–80 chars). Identifies the key. |
email | string | Contact for operational alerts. |
settlement_address | string | Polygon address 0x… fixed for cash-in settlement. Without it, each charge specifies its own payout_address. |
webhook_url | string | URL https for events. Generates a webhook_secret (also shown only once). |
curl -X POST https://api.luniumpay.com/keys \
-H "Content-Type: application/json" \
-d '{
"name": "Example Store",
"email": "dev@examplestore.com",
"webhook_url": "https://examplestore.com/webhooks/lunium"
}'const r = await fetch('https://api.luniumpay.com/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Example Store',
email: 'dev@examplestore.com',
webhook_url: 'https://examplestore.com/webhooks/lunium',
}),
});
const conta = await r.json();
// conta.api_key ← save it NOW (shown only here)import requests
r = requests.post("https://api.luniumpay.com/keys", json={
"name": "Example Store",
"email": "dev@examplestore.com",
"webhook_url": "https://examplestore.com/webhooks/lunium",
})
conta = r.json()
# conta["api_key"] ← save it NOW (shown only here)Response 201:
{
"api_key": "lun_9f2c40d1e8…", // ← the only time it is shown
"webhook_secret": "whk_5d1a…", // if you sent webhook_url
"key": {
"key_id": 7, "prefix": "lun_9f2c40d1", "name": "Example Store",
"cashin_enabled": true, "cashout_enabled": true,
"fee_bps": 250, "fee_fixed_cents": 100, // house default: 2.5% + R$ 1.00 — negotiable by volume
"rate_limit_per_minute": 60, …
},
"limits": { "tier": "Inicio", "daily_limit_cents": 500000, … },
"monitor_url": "https://api.luniumpay.com/cashin/monitor?token=dsh_…", // live read-only dashboard
"telegram": {
"bot": "@…",
"link": "https://t.me/…?start=lk1a2b3c…", // one tap = real-time alerts
"hint": "Crie um grupo com o time e adicione o agente."
},
"docs": "https://docs.luniumpay.com"
}
/monitor. JSON equivalent: GET /cashin/events?token=… (or with X-API-Key).Your account
Everything about your key: configuration, tier, daily limit (cash-in and payouts — the sale has none), today's usage and the Telegram link.
curl https://api.luniumpay.com/keys/me -H "X-API-Key: $LUNIUM_KEY"
{
"key": { "key_id": 7, "prefix": "lun_9f2c40d1", "name": "Example Store",
"cashin_enabled": true, "cashout_enabled": true,
"webhook_configured": true, "telegram_linked": true, … },
"limits": {
"tier": "Crescimento",
"daily_limit_cents": 2500000,
"used_today_cents": 431000,
"available_today_cents": 2069000,
"settled_volume_cents": 9174350,
"next_tier": { "tier": "Escala", "unlocks_at_settled_volume_cents": 50000000,
"daily_limit_cents": 10000000 }
}
}
Updates name, settlement_address and webhook_url
(sending null clears the field). "rotate_webhook_secret": true rotates the secret — the new one comes in the response, only once.
curl -X PATCH https://api.luniumpay.com/keys/me \
-H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
-d '{ "settlement_address": "0xAbC123…", "webhook_url": "https://examplestore.com/wh" }'
Limits & tiers — progressive by volume
Trust is built by volume. Every key starts in the Start tier and moves up automatically as it settles volume with us. The end payer is validated by our regulated PIX provider — your ceiling is only a risk gauge.
| Tier | Unlocks with | Daily ceiling (cash-in + payouts) |
|---|---|---|
| 🌱 Start | immediate, on key creation | R$ 5,000/day |
| 📈 Growth | R$ 50,000 settled (cumulative) | R$ 25,000/day |
| 🚀 Scale | R$ 500,000 settled (cumulative) | R$ 100,000/day |
| 🏛 Enterprise | talk to us | custom |
- The daily ceiling counts cash-in + payouts and resets at midnight (America/Sao_Paulo). The cash-out does not count toward this quota — it has a per-operation limit, not a daily one.
- Source of truth: the numbers on this page are reference; what the API enforces comes in
GET /keys/me→limits.per_operation(range per cash-in, cash-out and payout operation),limits.payer_limits(ladder by CPF/CNPJ) andlimits.daily_limit_applies_to(what consumes the quota). Don't hardcode limits in your code: read them from here, they change without notice. - Operations in progress count toward the ceiling; expired/refunded ones free the space back up.
- Blew past it? The API responds
403with alimitsin the body — show it to your finance team and try again the next day, or speed up the upgrade by settling volume. - Your current tier, usage and next step:
GET /keys/meor/limitesin the bot.
Limit per payer (CPF/CNPJ) NEW
Beyond the ceiling of your key, there is a second scale: the one for each CPF/CNPJ that pays. It protects you and us from the same problem — mule accounts, stolen card/PIX and fraud probing always come in through a new payer, never a recurring one. A new CPF starts small and grows on its own:
| Payer stage | Limit |
|---|---|
| First operation (never paid) | R$ 60 on that operation |
| First 24h after the 1st confirmed payment | R$ 200 cumulative |
| After 24h from the 1st payment | R$ 6,000 per day |
- The clock starts at the first confirmed payment, not at the first charge created — generating a QR is free, paying is not. That way no one "ages" a CPF just by issuing charges.
- The limit counts what has been paid and what is in flight (pending charge still valid) —
available = ceiling − paid − pending. It applies from day one: before the 1st confirmed payment, the CPF/CNPJ can have at most R$ 60 in open charges at the same time (aggregate, not R$ 60 per QR) — opening several QRs does not break the ceiling. Expired ones don't count. - Payers that already have history with you already start out mature — nothing was reset.
- It holds in both usage modes and is independent of your tier: even on Escala, a first-time CPF pays at most R$ 60 on the first one.
403 with a
erro: "limite_do_pagador", acao: "corrigir" and an object
payer_limits telling you which stage the payer is in, how much has already been used, how much is left and in how many hours the ceiling goes up — you can show it on your screen without guessing. Branch on
erro (the contract across the whole API); the field code is still returned, but it is a legacy alias with shutdown scheduled for 2026-11-09 — do not build on top of it.{
"erro": "limite_do_pagador", // ← branch out from here (the contract for the whole API)
"acao": "corrigir",
"detail": "Nas primeiras 24h o CPF/CNPJ pode movimentar R$ 200,00. Já usou R$ 120,00; disponível: R$ 80,00…",
"payer_limits": {
"stage": "first_24h",
"window_limit_cents": 20000,
"used_cents": 12000,
"available_cents": 8000,
"matures_in_hours": 23
},
"code": "payer_limit" // legacy alias — retired on 2026-11-09, branch on "erro" instead
}
Quick reference
Every endpoint in one table. Base: https://api.luniumpay.com · authentication via
X-API-Key except where noted.
| Method | Path | Auth | Function |
|---|---|---|---|
| POST | /keys | — (5/day/IP) | Create key (self-service; shown once) |
| GET | /keys/me | key | Config, tier, limits, usage for the day |
| PATCH | /keys/me | key | name, settlement_address, webhook_url, rotate_webhook_secret |
| POST | /cashin/preview | key | BRL→USDT quote (read-only) |
| POST | /cashin/charge | key | PIX charge → USDT/USDC (copy-and-paste QR) |
| GET | /cashin/{id}/status | key | State + on-chain tx |
| GET | /cashin/charges | key | List (status, start, end, limit) |
| GET | /catalog · /catalog/dex | key | cash-out assets and networks (USDT/USDC on the major ones) |
| POST | /cash-outs | key | Crypto → PIX quote |
| POST | /cash-outs/{id}/accept | key | Locks the quote → deposit address |
| GET | /cash-outs/{id} · /cash-outs | key | State · list |
| POST | /payouts | key + approval | Direct PIX in BRL to the recipient |
| GET | /payouts/{id} · /payouts | key + approval | Status · list |
| GET | /ping | — | Connectivity ({"ok":true}) |
OpenAPI · Postman & Insomnia
Every endpoint in a single file. Import the spec and you get a ready-made collection — with authentication and examples — to test the API without writing anything.
OpenAPI 3.1 spec — 22 operations, signed webhooks and every response schema, faithful to the production API.
https://docs.luniumpay.com/openapi.json
- Import (top left) → tab Link.
- Paste
https://docs.luniumpay.com/openapi.json→ Continue → Import. - In the collection, set the variable
apiKeywith yourlun_…and fire any request.
- Create → Import From → URL.
- Paste the same spec URL and confirm.
- Set the header
X-API-Keyin the environment and test.
openapi-generator at this same file.Cash-in — PIX in, USDT out
Your customer pays an ordinary PIX (copy-and-paste QR); Lunium receives it, converts it and sends USDT (Polygon) to the address you specify — yours, fixed on the key, or one per charge. No connected wallet, no friction for the payer.
amount_cents: 25000 = R$ 250.00. Per-charge limits: R$ 1.00 to R$ 5,000.00 · QR expires in 15 minutes.
Beyond these ceilings, each paying CPF/CNPJ has its own scale (per-payer limit): a first-time payer starts at R$ 60 per charge.Balance in reais (custody) — deposit and withdraw by PIX
Keys with custody enabled (custodia_ativa: true in GET /saldo) hold reais for the partner's end customers — the "Deposit and Withdraw" tab of a wallet app. The ledger is append-only: balance = sum of the movements.
| Step | Call | What happens |
|---|---|---|
| Deposit | POST /cashin/charge with a destino: "saldo" + customer_ref | PIX paid → credit in full to the sub-account (deposito_fee_bps = 0), locked until disponivel_em (D+1 by default). Webhook cashin.settled with a asset: "brl". No crypto leaves. |
| Query | GET /saldo?customer_ref=… · GET /saldo/extrato | disponivel_cents, bloqueado_cents, proximas_liberacoes, and the fees the screen needs to show (saque_fee_bps, saque_taxa_estimada_cents). |
| Withdraw in crypto | POST /saldo/sacar-cripto {amount_cents, chain, asset, payout_address, tax_number, customer_ref} | Debits the amount + house fee (saque_cripto_fee_bps) and delivers the crypto to the customer's wallet over the purchase rail (any coin/network in the catalog, except Liquid). Response = charge already paid; track settlement_status in GET /cashin/{id}/status. |
| Withdraw | POST /payouts with the same customer_ref | Debits the amount + house fee (saque_fee_bps, currently 1.8%) + bank fee (~R$ 1.00) before the PIX goes out (fee_cents = total); a refusal reverses the debit. 402 saldo_insuficiente if available < amount + fee. The PIX key must belong to the tax_number (bank rule). The PIX lands within 24h. |
curl -X POST https://api.luniumpay.com/cashin/charge -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"amount_cents":10000,"payer_tax_number":"12345678909","destino":"saldo","customer_ref":"user_42","chain":"polygon","asset":"usdt"}'
curl "https://api.luniumpay.com/saldo?customer_ref=user_42" -H "X-API-Key: $KEY"
curl -X POST https://api.luniumpay.com/payouts -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"amount_cents":5000,"pix_key":"12345678909","pix_key_type":"cpf","tax_number":"12345678909","customer_ref":"user_42","external_id":"withdrawal-1"}'
Two holding periods: the balance shows up immediately, but each deposit can only be used after carencia_horas (D+1); and the first deposit of a sub-account locks any withdrawal for 24h (carencia_ate in GET /saldo; 423 carencia_primeiro_deposito on both withdrawal routes). Charges carry fonte (pix|saldo) and destino (cripto|saldo).
Nothing is advanced and no fee is absorbed: what the provider charges is what the balance pays. Custody is enabled per key by support.
Quote (read-only)
How much of the stablecoin comes out for a given BRL amount, without creating anything. It already applies your key's fee. Accepts asset: "usdt" (default) or "usdc".
curl -X POST https://api.luniumpay.com/cashin/preview \
-H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
-d '{ "amount_cents": 25000 }'const quote = await lunium('POST', '/cashin/preview', { amount_cents: 25000 });quote = lunium("POST", "/cashin/preview", {"amount_cents": 25000}){ "asset": "usdt", "amount_cents": 25000,
"admin_fee_bps": 250, "admin_fee_fixed_cents": 100, // 2.5% + R$1 flat
"fee_total_cents": 723, "net_cents": 24277, // total fee and net amount
"brl_per_usdt": "5.10", "usdt_amount": "47.558132" }
fee_total_cents, net_cents and usdt_amount returned by the API are the source of truth — use them to display, charge and reconcile. Rebuilding the math locally is the shortest path to a cash discrepancy.
An example of why, with the real numbers above:
brl_per_usdt is the quote
rounded to 2 decimal places for readability ("5.10"), while the
usdt_amount is computed with the full quote (≈ 5.1047). If you do
net_cents ÷ brl_per_usdt you will arrive at 47,601960 instead of
47,558132 — 0.09% more crypto than you will actually receive, always in the same direction. At volume, that turns into a cash discrepancy every month.admin_fee_fixed_cents and admin_fee_bps in the response itself and in
GET /keys/me — do not hard-code 2.5%, it is negotiable by volume (contato@luniumpay.com).
Methodology (so you understand the math, does not para reconciliar dinheiro):
base = max(0, amount_cents − admin_fee_fixed_cents) // the flat part comes off first
net_cents = floor(base × (10000 − admin_fee_bps) ÷ 10000) // truncates, never rounds
fee_total_cents = amount_cents − net_cents // flat + percentage together
Conferindo com o exemplo: floor((25000 − 100) × 9750 ÷ 10000) = 24277, and the fee is
25000 − 24277 = 723. The usdt_amount already comes in net — it is exactly what the destination receives.brl_per_usdt and usdt_amount keep the name for compatibility (additive policy) — read them as "BRL per unit" and "stablecoin quantity".Create charge
| Field | Type | Description |
|---|---|---|
amount_cents required | int | PIX amount in cents. |
payer_tax_number required | string | CPF (11 digits) or CNPJ (14) of the payer. |
payout_address | string | Polygon address 0x… that receives. Optional if the key has a settlement_address fixed. |
asset | string | "usdt" (default) or "usdc" — the stablecoin delivered. USDC is the native Circle token on Polygon (not the bridged USDC.e). |
payer_name | string | Payer name (recommended). |
external_id | string | Your order id — provides idempotency. |
chain | string | Today: polygon (default). |
curl -X POST https://api.luniumpay.com/cashin/charge \
-H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
-d '{
"amount_cents": 25000,
"payer_tax_number": "12345678901",
"payer_name": "Maria Souza",
"payout_address": "0xAbC123…",
"external_id": "order-8812"
}'const charge = await lunium('POST', '/cashin/charge', {
amount_cents: 25000,
payer_tax_number: '12345678901',
payer_name: 'Maria Souza',
payout_address: '0xAbC123…',
external_id: 'order-8812',
});
mostrarQR(charge.qr_copypaste); // comes ready for the screencharge = lunium("POST", "/cashin/charge", {
"amount_cents": 25000,
"payer_tax_number": "12345678901",
"payer_name": "Maria Souza",
"payout_address": "0xAbC123…",
"external_id": "order-8812",
})
mostrar_qr(charge["qr_copypaste"]){
"cashin_id": "ci_b3f9d2a41c6e8f5a90d7",
"status": "pending",
"amount_cents": 25000,
"payout_address": "0xAbC123…", "chain": "polygon",
"qr_copypaste": "00020126580014br.gov.bcb.pix…", // PIX copy-and-paste code
"qr_image_url": "https://…/qr.png",
"external_id": "order-8812",
"expires_at": "2026-07-20T18:45:00.000Z"
}
Charge status
Full snapshot — includes the on-chain hash once the USDT goes out.
cashin.paid / cashin.settled), which arrives before any polling. When polling, use 1 request every 2–3 s: at 1×/s you alone consume the key's 60 req/min and get 429 on the other calls.curl https://api.luniumpay.com/cashin/ci_b3f9d2a41c6e8f5a90d7/status \
-H "X-API-Key: $LUNIUM_KEY"
{
"cashin_id": "ci_b3f9d2a41c6e8f5a90d7",
"status": "paid",
"settlement_status": "sent",
"amount_cents": 25000,
"depix_received_cents": 25000, // what ACTUALLY came in
"usdt_amount": "46.040515",
"settlement_tx_hash": "0x8ef1…",
"settlement_tx_url": "https://polygonscan.com/tx/0x8ef1…",
"payer_tax_number": "•••••••8901", // always masked
"paid_at": "2026-07-20T18:34:12.000Z",
"settled_at": "2026-07-20T18:34:40.000Z", …
}
List charges
The charges on your key, most recent first. Filters: status,
start/end, limit (max. 200) and external_id
(recovery). Response: { "charges": [ … ] } in the same format as the status — perfect for daily reconciliation.
criado_em), not by the payment date.
start and end are both inclusive, in ISO 8601; with no offset, the date is read in UTC. A date with no time becomes midnight UTC — so, to cover the whole of the 21st, use start=2026-07-21&end=2026-07-22 (end on the next day) or
end=2026-07-21T23:59:59Z.cash-in lifecycle
pending↓paid↓Two fields, not one. status
is the PIX (it reaches paid and stops there); settlement_status is the crypto delivery (pending → sending → sent). status never becomes "settled" — success is status: "paid" with a settlement_status: "sent", and
cashin.settled is the name of the event, not a value of status. If your state machine expects status: "settled", it hangs waiting for something that never comes. If it ends up uncertain, the operation reconciles on-chain before any new send: the transaction is looked up on the network by the recorded hash, and we only resend when that lookup proves nothing went out.
| status | Meaning |
|---|---|
| pending | QR issued, awaiting payment. |
| under_review | PIX under review at the provider (rare, minutes). |
| paid | Money actually received — settlement triggers on its own. |
| expired | Nobody paid within 15 min. Create another one. |
| refunded | PIX refunded to the payer. |
| failed | Failed at the provider before payment. |
| settlement_status | Meaning |
|---|---|
| pending | Waiting for (or reprocessing) the USDT send. |
| sending | On-chain transaction in progress. |
| sent | USDT delivered — see settlement_tx_hash. |
| uncertain | Ambiguous send; our operation reconciles on-chain (we never resend blindly: the on-chain reconciliation decides whether a send happened before any retry). |
| failed | Send failed; manual handling already triggered. |
cashin.settled
(or settlement_status = "sent") — never on the paid alone, and never on the quoted amount: the field that counts is depix_received_cents.cash-out — crypto in, PIX out
The way back: you (or your customer) send crypto and a PIX key receives reais. Three-step flow: quote → accept → deposit. After the deposit, it is on us: conversion and PIX go out on their own, with an event at every transition.
We accept USDT and USDC on the main networks — Polygon with settlement in seconds, and Ethereum, BNB Chain, Arbitrum, Optimism, Base, Avalanche, TON, Aptos, NEAR and others at each network's own time. See the two paths and the timing table before choosing the network.
expires_at field of the response, and DEPOSIT_DETECTED only exists on the conversion rail — on Polygon the order goes straight from AWAITING_DEPOSIT to processing.Asset catalog
Current assets, networks and limits for cash-out. It is the source of truth — the catalog changes on its own as networks go in and out of maintenance, so read from it instead of hard-coding a list in your code.
{
"updatedAt": "2026-07-31T03:18:16.793Z",
"fast": [ { "asset": "USDT", "network": "polygon" } ], // settles in seconds
"convert": [ // major networks, timing set by the network
{ "asset": "USDT", "networks": ["eth", "bsc", "arbitrum", …],
"min": "10", "max": "200000", "precision": 8 }
],
"dex": { "chains": ["polygon"], "note": "…" }
}
network: "solana" is refused at the quote with HTTP 400 and does not appear in the catalog — on purpose: better to refuse at the door than to accept a deposit that never becomes a PIX. Networks that are in the catalog are the ones we pay.curl https://api.luniumpay.com/catalog -H "X-API-Key: $LUNIUM_KEY"
Two settlement paths — choose by timing
The same POST /cash-outs serves both; what decides is the pair
asset + network you send. The difference that matters to your customer is when the PIX lands.
| Path | Networks | PIX time | When to use |
|---|---|---|---|
fastour own settlement |
Polygon — USDT direct · USDC via on-chain swap | seconds after confirmation | Default. This is the instant experience — use it whenever your customer can send on Polygon.
The fast field of the /catalog lists only USDT; USDC-Polygon goes through a conversion first and, if liquidity is missing, becomes manual review. |
convertvia partner exchange |
USDT and USDC on the main networks (table below) | from ~1 min to a few hours, depending on the network | When the customer already holds the balance on another network and does not want to pay for a bridge. |
convert.
The time is not ours: each network requires a number of confirmations before the balance is released, and that is what dictates the timing. Show the estimate from the table below before the customer sends the crypto — it is the difference between a wait that is understood and a support ticket.Networks and estimated times
Estimates measured by the confirmations required per network. The min is per order (USDT 10 · USDC 20); the ceiling is 200,000 per order, subject to your tier.
| Network | network | Coins | PIX goes out in |
|---|---|---|---|
| Polygon | polygon | USDT · USDC | seconds (own rail) |
| TON | ton | USDT | ~1 min |
| Aptos | aptos | USDT · USDC | ~1 min |
| Avalanche | avalanche | USDT · USDC | ~2 min |
| NEAR | near | USDT · USDC | ~2 min |
| BNB Chain | bsc | USDT · USDC | ~3 min |
| Polkadot | polkadot | USDT · USDC | ~4 min |
| Base | base | USDC | ~5 min |
| Ethereum | eth | USDT · USDC | ~20 min |
| Arbitrum | arbitrum | USDT · USDC | ~21 min |
| Optimism | optimism | USDT · USDC | ~1 hour |
| Celo | celo | USDT | ~3 hours |
Also available in convert: Klaytn (klay), Plasma (plasma),
Conflux (cfxevm) for USDT; XDC (xdc), Sonic (sonic), Sei (seievm),
Sui (sui), Starknet (stark) and Algorand (algo) for USDC. Always check GET /catalog — the live list may have more. | |||
Long-tail tokens via DEX (Polygon). Symbols repeat — when using a token from the
/catalog/dex, also send the exact token_address in the quote. The token is converted on-chain before the PIX, so the outcome depends on the liquidity of the pair.
Create quote
| Field | Type | Description |
|---|---|---|
asset required | string | E.g.: USDT (see /catalog). |
network required | string | polygon (seconds) or one of the main networks — eth, bsc, arbitrum, optimism, base, avalanche, ton, aptos, near… Time per network in the table; live list at /catalog. |
amount one of the two | string | Amount in decimal string — "50", never a float. Send amount or brl_amount. |
brl_amount one of the two | string | Reverse quote: the PIX amount in reais ("250.00", up to 2 decimal places) and Lunium calculates the crypto — the amount in the response is what the customer deposits. It is the natural path for anyone who thinks in reais ("I want to receive R$ 500"). |
pix_key required | string | The PIX key that receives the reais. Accepted formats (the API normalizes to the exact format the settlement provider requires, and rejects before any deposit exists, 400 pix_key_invalida): CPF 11 digits · CNPJ 14 digits · international phone +55DDDNUMBER (+5548996005588) · email · random key in UUID (6602ede6-b1a9-4e63-9178-c6883fd0095e). |
pix_key_type optional | string | cpf · cnpj · phone · email · random. To sell, the PIX key alone is enough — the type is inferred from it for email, CNPJ, random key and phone with +55. It is only required when the key is 11 bare digits, because CPF and phone have the same length and guessing would pay the wrong person. If you know the type, send it — explicit always beats inference. |
token_address | string | Exact address/mint (required for tokens from the /catalog/dex). |
external_id | string | Your id — idempotency. |
refund_address always provide it | string | Return wallet of your customer, on the network of the sale. It is where the crypto goes back in any refund (provider rejected the key, reversal, failure before sending). Without it the refund goes to the on-chain origin of the deposit — the wallet of the exchange, if the customer withdrew from one. |
br_code charge | string | PIX copy-and-paste code (BR Code) from a charge with an amount — to pay a QR code instead of a key. Only with USDT or USDC on Polygon (otherwise 400 br_code_nao_suportado). The QR amount becomes the brl_amount and the recipient key comes from the QR itself (merchant_name in the response): do not send amount, brl_amount or pix_key along with it. If the settlement provider rejects the QR, the crypto goes back to refund_address (REFUNDED). |
curl -X POST https://api.luniumpay.com/cash-outs \
-H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
-d '{
"asset": "USDT", "network": "polygon", "amount": "50",
"pix_key": "12345678901", "pix_key_type": "cpf",
"external_id": "withdrawal-2207"
}'const ordem = await lunium('POST', '/cash-outs', {
asset: 'USDT', network: 'polygon', amount: '50',
pix_key: '12345678901', pix_key_type: 'cpf',
external_id: 'withdrawal-2207',
});ordem = lunium("POST", "/cash-outs", {
"asset": "USDT", "network": "polygon", "amount": "50",
"pix_key": "12345678901", "pix_key_type": "cpf",
"external_id": "withdrawal-2207",
}){
"cashout_id": "dede940b-6adb-4104-ac6b-979b1e84b1f5",
"state": "QUOTE_CREATED",
"asset": "USDT", "network": "polygon", "amount": "50",
"brl_amount": "268.00", // the PIX the key will receive
"pix_key": "123•••••901", // always masked
"expires_at": "2026-07-20T18:40:00.000Z"
}
expires_at — read it from there, never guess. Today: 15 min on Polygon; 90 min (volatile asset) or 300 min (stablecoin) on the other networks, because there the deposit still needs the network confirmations. Didn't accept in time? It expires on its own, at no cost — just quote again.Accept the quote
Locks the quote and returns the deposit address. Send exactly the quoted amount, on the right network; on networks with memo/tag, include the deposit_tag.
deposit_tag is in the contract, but is not filled in yet — and a deposit without a memo is lost at the exchange. So, while that is the case, the networks that require memo/tag (TON, XRP, XLM, ATOM, EOS, ALGO, HBAR and others) do not appear in GET /catalog and are rejected in the POST /cash-outs with a rede_indisponivel. You don't need to keep them in an avoid list: if it is in the catalog, you can deposit; if it is not, you can't. They come back on their own once the memo starts being filled in.{
"cashout_id": "dede940b-6adb-4104-ac6b-979b1e84b1f5",
"state": "AWAITING_DEPOSIT",
"deposit_address": "0xDdE987…", // send the crypto here
"brl_amount": "268.00", …
}
from on-chain is usually an exchange, bridge or contract wallet, which cannot safely receive a refund (it disappears or lands in the wrong account). Hence:
- Deposit from a wallet you control (self-custody), not straight from an exchange. That way there is a safe destination if a refund becomes necessary.
- The refund is coordinated with the operations team (today, manually) to an external address that you confirm — never an address of ours, never the origin blindly.
- Engine guarantee: the order only becomes
REFUNDEDafter a successful on-chain withdrawal. While there is no valid destination, it stays inMANUAL_REVIEWwith the crypto in our custody — nothing is sent to the wrong place and nothing disappears. Money sitting still can be fixed; money at the wrong address cannot.
Status & list
Current state. Our worker also tracks every order 24/7 and fires a webhook/Telegram on each transition — polling is optional, not required.
Orders for your key, most recent first. Response: { "cash_outs": [ … ] }.
cash-out lifecycle
AWAITING_DEPOSIT↓The internal states DEPOSIT_CONFIRMED · SELLING · SOLD · FORWARDING · PAYING_OUT collapse into a single event cashout.processing — you don't need to handle each one.
| state | Meaning |
|---|---|
| QUOTE_CREATED | Quote open; accept to lock it. |
| AWAITING_DEPOSIT | Awaiting your crypto at the address provided. |
| DEPOSIT_DETECTED | Deposit seen on-chain; confirming. |
| DEPOSIT_CONFIRMED · SELLING · SOLD · FORWARDING · PAYING_OUT | Processing (conversion and PIX). For your system, all of this is the single event cashout.processing. |
| MANUAL_REVIEW | Quick manual check by the operations team. |
| COMPLETED | PIX paid. 🎉 |
| REFUNDING_CRYPTO | Something blocked the PIX and the refund is on its way to refund_address (or to the deposit origin, if you did not provide one). |
| REFUNDED | Crypto refunded — refund_tx_hash is the on-chain proof. It is only marked after the transaction is confirmed, never on paper. See what happens in each failure. |
| EXPIRED | Quote/deposit outside the window. |
| FAILED | Terminal failure — our operations team has already been alerted. |
Refund — what happens in each failure
Send refund_address (your customer's wallet, on the network of the sale) in every quote. It is the destination of any refund; without it, the crypto goes back to the on-chain origin of the deposit — and the origin of an exchange withdrawal is the wallet of the exchange.
- Copy-and-paste code in the wrong field, or outside USDT/USDC Polygon → rejected at the quote (
pix_key_invalida/br_code_nao_suportado). Nothing moves: there is no deposit address yet. To pay a QR usebr_code. - QR rejected by the settlement provider at payment time (expired charge, already paid, QR not recognized) → the settlement provider returns the crypto to Lunium's custody within seconds and it goes on to
refund_address;MANUAL_REVIEW→REFUNDEDwith arefund_tx_hash. - Provider rejects the PIX key after the deposit (invalid/nonexistent) →
MANUAL_REVIEW(cashout.under_review); the crypto returns to Lunium's custody and automatically goes torefund_address; it ends inREFUNDED(cashout.refunded) withrefund_tx_hash. What is refunded is the net amount the provider returned. - Failure before sending (provider down, key rejected on submission) → retried every 5 min for up to 45 min; if it does not settle, the full amount (fee included) goes back to
refund_address;REFUNDED+ hash. - PIX reversed after being paid (rare — destination account blocked) → same path:
REFUNDEDwith arefund_tx_hash. - Deposit different from the quoted amount → the amount actually received is paid. A different token stays in
MANUAL_REVIEW. - Deposit after
expires_at→ the order is revived when the deposit shows up (up to 7 days); otherwiseMANUAL_REVIEW. - Automatic ceiling: 5,000 USDT per order; above that, someone on the operations team executes it and you receive
REFUNDED+ hash all the same. The refund always goes out on the network and token of the deposit, from Lunium's custody address; the gas is on us.
refund_address, refund_tx_hash and deposit_from come in GET /cash-outs/{id} and in every webhook.
Incorrect deposits and recovery
After the accept, send exactly the quoted amount, in the asset and on the
network that were quoted, in a single transaction, before the expires_at. The engine has a tolerance band — 90% to 105% of the quoted amount — and, within it, the PIX is calculated on what actually arrived, not on what was quoted. Outside it, the deposit is not processed automatically: it is held and becomes a manual review. Nothing is refunded to the on-chain origin automatically (same rule as refund).
| Situation | What happens |
|---|---|
| Exact amount | Processes normally; PIX for the quoted amount. |
| Short, within 90–100% | Processes; the PIX goes out proportional to what arrived (lower). You are paid for what you actually deposited. |
| Short, below 90% | No match → MANUAL_REVIEW. Recovery coordinated with our operations team, to an external address you confirm. |
| Over, within 100–105% | Processes for the quoted amount; the excess is not refunded automatically — recover it through support. Avoid overpaying. |
| Over, above 105% | No match → MANUAL_REVIEW. The ceiling is deliberate: it prevents settling a small order with a large deposit (every deposit on the conversion rail lands at the same address). |
| Two transactions / partial deposit | Each deposit is evaluated on its own; anything that does not match within the band becomes a manual review. Send a single transaction, for the exact amount. |
| Wrong token (right network) | Not the expected asset → no match → manual review. |
| Wrong network | Does not match the order → manual review. (Networks that require memo/tag are blocked precisely because the deposit would be lost — see Accept the quote.) |
After the expires_at | The order has already expired; the deposit arrives orphaned and becomes a manual review. Always deposit before field of the expires_at from the response. |
payouts — PIX directly in reais unlocks at R$ 1,000
The third rail on the same key: pay any PIX key in BRL with a single POST — no crypto at the end, no QR, no accept step. You send the amount and the recipient's PIX key; the PIX goes out and you receive the event payout.sent (webhook + Telegram). Ideal for platforms that need to pay users, suppliers or commissions in reais.
GET /keys/me returns the payout with a
enabled, settled_volume_cents, remaining_cents and
via ("automatico" or "manual") — read eligibility from there, do not hardcode the trigger. cash-in and cash-out remain 100% self-service from the first minute.Send a PIX
| Field | Type | Description |
|---|---|---|
amount_cents required | int | Amount net that arrives at the PIX key (cents). |
pix_key required | string | The recipient's PIX key. |
pix_key_type optional | string | cpf · cnpj · phone · email · random. To sell, the PIX key alone is enough — the type is inferred from it for email, CNPJ, random key and phone with +55. It is only required when the key is 11 bare digits, because CPF and phone have the same length and guessing would pay the wrong person. If you know the type, send it — explicit always beats inference. |
tax_number | string | The recipient's CPF/CNPJ (recommended — validated at the destination). |
beneficiary_name | string | Recipient's name. |
external_id | string | Your id — idempotency (a retry never pays 2×). |
curl -X POST https://api.luniumpay.com/payouts \
-H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
-d '{
"amount_cents": 15000,
"pix_key": "maria@exemplo.com", "pix_key_type": "email",
"tax_number": "12345678901",
"external_id": "commission-042"
}'const payout = await lunium('POST', '/payouts', {
amount_cents: 15000,
pix_key: 'maria@exemplo.com', pix_key_type: 'email',
tax_number: '12345678901',
external_id: 'commission-042',
});payout = lunium("POST", "/payouts", {
"amount_cents": 15000,
"pix_key": "maria@exemplo.com", "pix_key_type": "email",
"tax_number": "12345678901",
"external_id": "commission-042",
}){
"payout_id": "po_4c1d9e22ab37f0885a1c",
"status": "processing",
"amount_cents": 15000,
"pix_key": "ma•••@exemplo.com", "pix_key_type": "email",
"external_id": "commission-042",
"created_at": "2026-07-21T12:00:00.000Z"
}
limits.daily_limit_applies_to in the
GET /keys/me: read it from there, not from this page.Status & list
List response: { "payouts": [ … ] }. Notifications arrive on their own via webhook and Telegram — polling is optional.
| status | Meaning |
|---|---|
| processing | PIX being processed within the banking system. |
| sent | PIX in the recipient's account (event payout.sent). |
| failed | Could not be paid (nonexistent key, refusal). The error field explains it; nothing was debited from your ceiling. |
| refunded | Payment refunded by the recipient's bank. |
Signed webhooks
Set the webhook_url (when creating the key or via PATCH /keys/me) and receive a
POST on every event — cash-in and cash-out, same signature, same format:
POST {your webhook_url}
Content-Type: application/json
X-Webhook-Signature: sha256=3f5a9c… // v1 — HMAC of the raw body
X-Lunium-Signature: t=1786153475,v1=8b21e4… // v2 — HMAC of "t.body" (prefer this one)
X-Lunium-Event: cashin.settled
X-Lunium-Event-Id: evt_9c3f21a70b5e4d8812ff0a63
X-Lunium-Delivery-Id: dlv_4f0a72c1e8b5 // changes on every attempt
X-Lunium-Attempt: 1 // >1 = you may already have received it
X-Lunium-Timestamp: 1786153475
{
"event": "cashin.settled",
"event_id": "evt_9c3f21a70b5e4d8812ff0a63", // stable across retries
"created_at": "2026-07-20T18:34:41.000Z",
"data": { …same format as the status endpoint… }
}
Store the event_id. It is the identity of the event and does not change on a retry — it is what keeps a retry on our side from becoming a double credit on yours. The
X-Lunium-Delivery-Id is the opposite: it identifies that specific attempt, and it is the number you quote so we can find the exact delivery. And treat an unknown event as a no-op: the list grows without notice, and ignoring what you do not recognize is the correct behavior.
| Event | When it fires |
|---|---|
cashin.paid | PIX confirmed (money in hand). |
cashin.delayed | The provider held the release (happens on a payer's first transaction). Not a failure: it becomes paid on its own. Do not create a second charge. |
cashin.settled | USDT delivered — the event to credit your customer. |
cashin.settlement_failed | Settlement failure (handling triggered). |
cashin.expired · cashin.refunded · cashin.failed | Endings with no payment / reversal. |
cashout.awaiting_deposit | Quote accepted, address issued. |
cashout.deposit_detected | Crypto seen on-chain. |
cashout.processing | Converting and preparing the PIX. |
cashout.completed | PIX paid. |
cashout.under_review · cashout.refunding · cashout.refunded · cashout.expired · cashout.failed | Deviations and alternative endings. |
payout.sent | PIX paid to the recipient (direct payout). |
payout.failed · payout.refunded | payout not paid / refunded. |
Verify the signature (always!)
We send two signatures, from the same secret. The v1 (X-Webhook-Signature) signs only the body and remains valid — if your handler already verifies it, change nothing. The v2
(X-Lunium-Signature) signs "<timestamp>.<raw body>" and it exists for a practical reason: since v1 carries no timestamp inside, it is valid forever — anyone who captures a delivery (a proxy log, an endpoint that changed hands) can replay that
cashin.settled months later and you would credit it again. With the timestamp inside the HMAC you reject anything stale. For new integrations, use v2.
v1 status: deprecated, with a set date — shutdown on
November 9, 2026 (Monday). Until then it keeps being sent normally to existing integrations and nothing breaks. Keys created from 2026-08-08 onward already receive only v2. Migrate your handler to X-Lunium-Signature before that date — the reminder also goes out in the partner group and on the changelog.
import { createHmac, timingSafeEqual } from 'node:crypto';
// use the RAW BODY (string/buffer), not the re-serialized JSON
// v2 — rejects a stale delivery, so a replay cannot credit you twice
function assinaturaValida(corpoBruto, header, secret, toleranciaSeg = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(String(header || ''));
if (!m) return false;
if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranciaSeg) return false; // too old
const esperada = createHmac('sha256', secret).update(`${m[1]}.${corpoBruto}`).digest('hex');
const a = Buffer.from(esperada), b = Buffer.from(m[2]);
return a.length === b.length && timingSafeEqual(a, b);
}
app.post('/webhooks/lunium', express.raw({ type: '*/*' }), (req, res) => {
if (!assinaturaValida(req.body, req.headers['x-lunium-signature'], process.env.LUNIUM_WEBHOOK_SECRET))
return res.status(401).end();
const { event, event_id, data } = JSON.parse(req.body);
if (jaProcessei(event_id)) return res.status(200).end(); // our own retry cannot credit twice
if (event === 'cashin.settled') creditar(data.external_id, data.usdt_amount);
res.status(200).end(); // answer 2xx fast; process asynchronously
});import hmac, hashlib, re, time
# v2 — rejects a stale delivery, so a replay cannot credit you twice
def assinatura_valida(corpo_bruto: bytes, header: str, secret: str, tolerancia=300) -> bool:
m = re.match(r"^t=(\d+),v1=([0-9a-f]{64})$", header or "")
if not m or abs(time.time() - int(m.group(1))) > tolerancia:
return False
esperada = hmac.new(secret.encode(), f"{m.group(1)}.".encode() + corpo_bruto, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperada, m.group(2))
# Flask
@app.post("/webhooks/lunium")
def lunium_webhook():
if not assinatura_valida(request.get_data(), request.headers.get("X-Lunium-Signature"), SECRET):
return "", 401
evento = request.get_json()
if ja_processei(evento["event_id"]): # our own retry cannot credit twice
return "", 200
if evento["event"] == "cashin.settled":
creditar(evento["data"]["external_id"], evento["data"]["usdt_amount"])
return "", 200- Delivery: retry with exponential backoff: 12 attempts, waiting 2ⁿ minutes with a ceiling of 60 — ~7 hours in total, not a day. Any 2xx ends it. Each destination is attempted in parallel: one of your endpoints being down does not delay anyone else's events.
- Dedupe: each (operation, event) pair fires once. On your side, deduplicate by the
event_id. - Nothing is lost silently: once the attempts are exhausted, the delivery is left as
failedinGET /webhooks/deliveriesand you recover it with the retry below. Our operations team is alerted immediately as well — before, an event could die without anyone knowing. - webhook_url must be
httpsand point to a public internet address. - Security: reject an invalid signature with 401; never process without verifying.
Test your handler before the first real one
Don't find out the endpoint was wrong when the first real PIX fails to arrive:
curl -X POST https://api.luniumpay.com/webhooks/test \
-H "X-API-Key: $LUNIUM_KEY"
// response — what YOUR endpoint replied, measured by us
{ "ok": true, "status_code": 200, "ms": 180,
"event_id": "evt_…", "delivery_id": "dlv_…", "url": "https://your.app/webhooks/lunium" }
Fires an event webhook.test over the same transport as production — same signatures, same headers — so whatever passes here passes there. It moves no money, does not enter the queue and works with a sandbox key too. Maximum of 6 per minute.
What went out, what failed, and how to recover
GET /webhooks/deliveries?status=failed // also: ?event=&limit=
POST /webhooks/deliveries/{event_id}/retry // resends it now
The list returns status (pending · sent · failed),
attempts, last_error and a failed_count — you don't need to know how to filter to find out you missed an event. The retry attempts immediately and answers with the result; if it fails again, the delivery goes back to the queue with 12 fresh attempts. Anything already confirmed with 2xx is refused with 409 ja_entregue, so you don't duplicate by accident.
Design note: the retry is addressed by the event_id because today each event has exactly one destination (one webhook_url per key) — event and delivery are 1:1. The delivery_id identifies each attempt at sending. If one day there are multiple destinations per event, a per-delivery route will appear — this one still holds.
Telegram agent — operations and support
Every Lunium customer has a real agent on Telegram: an AI (Claude, via API, with access to the account's tools), not a menu of buttons. It creates credentials, operates through natural language, notifies in real time and answers integration questions — always with your key, isolated per customer (multi-tenant).
- Credentials in the chat. Say "my name is YourCompany" (or
/criarchave) and the API key is created right there, with cash-in and cash-out enabled. Self-service, at scale — nobody at Lunium generates credentials for you. - Operates through natural language, with confirmation. Ask "charge R$ 250 from CPF …" or "withdraw 25 USDT to my PIX key": the agent reads the real data from your account, assembles the operation and asks for a confirmation tap before any movement. Money never moves without your OK.
- Notifies in real time. Every PIX that comes in, every coin bought and sold (with a Polygonscan link), every PIX that goes out, reviews and failures — the second they happen.
- In your project's group (recommended for teams). Create a group (e.g.: Lunium YourProject), add the agent and have an admin link the key: the whole team starts receiving the alerts and running queries — each group scoped to your key, with credentials and secrets always reserved for direct messages.
- Live monitor.
/monitoropens a real-time web dashboard of your flow (cash-in, cash-out, payouts) — read-only and shareable with the team. Also in themonitor_urlof your key. Link leaked?PATCH /keys/mewith a{"rotate_monitor_token": true}rotates the token — the old URL stops showing data immediately and the new one comes in the response. - 24/7 support. Free text — "how do I validate the webhook signature?", "I'm getting 401" — and the agent answers immediately, with examples, trained on this documentation.
Your agent's link comes in the response of POST /keys (field telegram.link) and in the GET /keys/me — one tap and the chat is linked. The commands below also work through natural language.
| Command | What it does |
|---|---|
"my name is …" · /criarchave | Creates your API key in the chat (shown only once) and links the alerts. |
/cobrar <amount> <cpf/cnpj> [0x…] [usdc] | PIX charge → USDT/USDC in the chat, with the same guards as the API (ceiling, key fee). |
/cotar <amount> [usdc] · /calcular <qty> | Conversion preview (creates nothing) and reverse calculation of how much to charge. |
/qrdelay <amount> <cpf> <hours> | Charge with extended validity (up to 720 h) — an operational feature exclusive to the agent; through the REST API the window is the expires_at default (~15 min). |
/sacar <qty> <asset> <network> <pix_key> <type> | Crypto → PIX cash-out: quote + accept on the button + deposit address. |
/pagar <amount> <pix_key> [type] | Direct PIX payout (unlocks at R$ 1,000 settled); confirm on the button. |
/status <id> · /extrato · /limites · /chave | Queries: status of an operation, movements, tier/usage and key data. |
/monitor | Link to your live dashboard (read-only, scoped to the key) — shareable with the team. |
/webhook <https://url> | Configures the webhook from the chat (HMAC secret shown once); /webhook off turns it off. |
/vincular <code> · /ping · /desvincular | Links a chat/group to the key, tests connectivity and mutes the alerts. |
| any question | Integration support answers in seconds, with examples. |
Who can do what — agent permissions
The agent works in your direct message (DM) and in the group that is linked. The rules are not the same in both — and it is worth understanding that before adding people to the group:
| Action | In direct messages (DM) | In the linked group |
|---|---|---|
Query (/status, /extrato, /limites, /monitor) | you | any member |
| Start a charge, cash-out or payout | you | any member |
| Confirm an operation (the tap that moves money) | you | only whoever started that — nobody confirms someone else's |
| Link / unlink / change webhook (config) | you | group admins only (creator/administrator on Telegram) |
X-API-Key itself — only add people you would trust with the money. Pay special attention to /pagar (payout: PIX from your treasury to a key the initiator chooses — the only operation that sends your money out): any group member can start it. If that is sensitive in your case, keep
payouts turned off (that is the default) or talk to us to restrict it to admins.Named roles (Owner · Admin · Operator · Viewer) with per-role capabilities are on our roadmap for larger teams. Today the model is the one in the table above: queries and operations for the group, config for admins, confirmation always tied to whoever requested it.
A key without going through a human
If a tool responded erro: "chave_ausente", an agent does not need to stop and ask for credentials. POST /keys/sandbox returns a lun_test_ key in one call, with no authentication and no form — over MCP, the same thing is the tool lunium_create_sandbox_key, which requires no key at all.
It runs cash-out on the same base URL as production and nothing settles (cash-in and payouts are not simulated). Make it clear to the user that this is a test key: moving real money requires a production key, and that is a human decision.
Z — e.g.: 2026-08-01T22:35:49.123Z. Convert to your user's time zone when displaying; do not treat the time as local.What to expect, in numbers we measured
These are measurements, not promises — collected on 2026-08-02 against the live service.
| What | Measured |
|---|---|
| Settlement, from quote to PIX paid | 49–65 s on Polygon |
| Order in sandbox, end to end | p50 34 ms · p95 76 ms with 20 concurrent |
| Sustained throughput (sandbox) | ~200 orders/s with production latency unchanged |
| Limit per key | 60 req/min — a 429 it is protection, not a failure |
| Per operation | R$ 6.00 to R$ 250,000.00 (always check in GET /keys/me → limits.per_operation) |
GET /catalog, and size your retry by the expires_at in the response — never by a fixed window.payout_nao_liberado and
acao: "esperar", saying how much is missing. In GET /keys/me, the
payout block carries enabled, settled_volume_cents and
remaining_cents — read eligibility from there instead of hardcoding the trigger.Errors
The error contract is: erro (a stable code that does not change when the text changes),
acao (what to do) and detail (human-readable text — never branch on it). Limit errors also carry limits, already converted into your order's currency. Connectivity without auth: GET /ping → {"ok":true,"msg":"pong"}.
detail. So branch in this order: 1) the HTTP status (always correct), 2) the field
erro when present, 3) never the text of detail. We are filling in erro and acao on the responses that lack it — it is an addition, so your code will not break when it arrives.{ "erro": "limite_diario",
"acao": "esperar",
"detail": "Essa cobrança (R$ 900.00) estoura seu limite diário. Disponível hoje: R$ 450.00.",
"limits": { "daily_limit_cents": 500000, "used_today_cents": 455000 } }
The four actions
acao | Meaning |
|---|---|
| corrigir | The request is wrong. Repeating it identically never will work. |
| repetir | A transient failure on our side. Try again with backoff. |
| esperar | A quota resets. Come back later — here, trying tomorrow does work. |
| parar | Do not retry. Alert a human. |
This distinction exists because two opposite situations returned the same answer: an order larger than an entire day's quota was told “try tomorrow” — and tomorrow failed the same way, forever.
All codes
The same codes come out of sandbox and production. Code that branches on erro in sandbox keeps working when you swap the key.
| Code | Action | What happened |
|---|---|---|
chave_ausente | parar | Missing the header X-API-Key. POST /keys/sandbox creates a test key, no signup. |
formato_invalido | parar | The value is not a Lunium key — they start with lun_ or lun_test_. |
chave_incorreta | parar | The prefix exists but the secret does not match — the key was truncated when copied. The detail says how many characters arrived and how many are expected. |
chave_desconhecida | parar | No key starts with that prefix. Probably the wrong environment. |
campos_obrigatorios | corrigir | asset and network are required (see GET /catalog). |
amount_invalido | corrigir | amount must be a positive decimal as a string: "25.5". |
pix_key_obrigatoria | corrigir | Missing the pix_key — who receives the reais. |
tipo_ambiguo | corrigir | The key is 11 plain digits: CPF or phone. We refuse instead of guessing — paying the wrong person is worse than an error. Send pix_key_type. |
pix_key_invalida | corrigir | The key does not match the declared type. |
valor_abaixo_do_minimo | corrigir | Read limits.min_amount — it already comes converted into your order's currency. |
valor_acima_do_maximo | corrigir | Read limits.max_amount, same idea. |
limite_diario | esperar | Your tier's daily quota. Resets at midnight and grows with settled volume — it is the only error where trying tomorrow actually works. |
rede_indisponivel | corrigir | This network is not settling right now. Use polygon (seconds) or another one from GET /catalog. |
nao_encontrado | corrigir | The id is wrong or belongs to another key. Do not repeat the same id. |
asset_invalido | corrigir | The requested asset is not in the catalog. Read GET /catalog — it changes. |
cashin_indisponivel | esperar | Buy side temporarily down (PIX provider switch). cash-out is running normally. |
cashout_indisponivel | esperar | Sell side temporarily down. Nothing was charged. |
cotacao_expirada | corrigir | The quote expired before the accept. Re-quote, do not insist on the same one: the price changed. |
cotacao_indisponivel | repetir | We cannot price right now (unstable price feed). Try again with backoff. |
documento_invalido | corrigir | CPF/CNPJ with a wrong check digit. |
erro_interno | repetir | Unexpected failure on our side. Retry with backoff; if it persists, contact support. |
external_id_divergente | corrigir | You reused an external_id with a different parameters. We return the original order and tell you, instead of letting you believe you updated something. |
limite_de_chaves_teste | esperar | Daily sandbox key quota per IP. It resets on its own. |
limite_do_pagador | corrigir | The paying CPF/CNPJ blew through its own ladder (R$ 60 on the first order, R$ 200 in the first 24h, R$ 6,000/day). It belongs to the payer, separate from the key's ceiling. |
payer_tax_invalido | corrigir | The payer's document failed validation. |
payer_tax_number_invalido | corrigir | Payer document number in an invalid format. |
payout_address_invalido | corrigir | The address is not valid for the chosen network. |
payout_address_obrigatorio | corrigir | Missing the crypto destination address. |
payout_nao_liberado | esperar | payouts not enabled for this key yet — it unlocks based on volume already settled with us. |
provedor_indisponivel | repetir | The settlement provider refused or did not respond. Transient — do not recreate the order, it is retried on its own. |
sandbox_sem_cashin | parar | A test key does not create a real charge. Use a production key or the sandbox flow. |
{ "detail": "Essa cobrança (R$ 900.00) estoura seu limite diário. Disponível hoje: R$ 450.00. …",
"limits": { "daily_limit_cents": 500000, "used_today_cents": 455000, "available_today_cents": 45000 } }
By HTTP code
| Code | When | What to do |
|---|---|---|
| 400 | Missing/invalid field. | Fix the payload — the detail says which field. |
| 401 | No key or invalid key. | Check the header X-API-Key. |
| 403 | Product disabled on the key · daily limit exceeded. | See limits; the tier rises on its own with volume. |
| 404 | Resource does not exist or belongs to another key. | Ids are scoped per key — check the id. |
| 409 | external_id reused with different parameters. | Generate a new id per request. |
| 429 | Rate limit on the key (or key creation per IP). | Exponential backoff; default 60 req/min. |
| 500 | Unexpected internal error. | Retry; if it persists, support. |
| 502 · 503 | Upstream instability (quote, PIX provider, orchestrator). | Retry with backoff; nothing was charged. |
429, 500, 502 and 503 can be repeated with exponential backoff (250 ms → 1 s → 4 s…). On writes (charge, cash-out, payout) always send an external_id: then any retry is idempotent and never charges/pays 2×. Only exception: one 500 in POST /payouts may mean the PIX went out at the provider but failed to register — do not retry blindly, check with support.Authentication & availability (any route)
| HTTP | detail you receive | Cause & fix |
|---|---|---|
401 | X-API-Key invalida ou ausente. | Wrong/missing key. Send X-API-Key: lun_… (create one at POST /keys). |
429 | erro: "rate_limit", acao: "esperar" | You exceeded rate_limit_per_minute (default 60). Read Retry-After and X-RateLimit-* in the response; space out your calls with backoff. |
503 | Serviço inicializando — banco indisponível. | Momentary service restart. Retry in seconds. |
404 | Rota nao encontrada. | Wrong method or path. Check the quick reference. |
Keys (/keys)
| HTTP | detail | Cause & fix |
|---|---|---|
429 | Limite de 5 chaves por dia por IP… | Max. 5 keys/day per IP. Reuse the key or talk to us. |
400 | name obrigatorio (2 a 80 caracteres)… | Send the business name (2–80 chars). |
400 | email invalido. | Malformed email (the field is optional — you can omit it). |
400 | settlement_address invalido (endereco Polygon 0x...). | Invalid EVM address. Use a 0x… of 42 chars. |
400 | webhook_url invalida. · webhook_url precisa ser https. | Malformed URL or http. Use https://. |
401 | X-API-Key obrigatória. | /keys/me requires the key in the header. |
400 | Nada para atualizar. Campos: name, settlement_address, webhook_url, rotate_webhook_secret. | PATCH with no valid field in the body. |
Cash-in (/cashin)
| HTTP | detail | Cause & fix |
|---|---|---|
403 | Cash-in nao habilitado para esta chave. | Rail disabled on the key. Talk to support. |
400 | Valor minimo: R$ 1.00. · Acima do limite (R$ 5000.00). | amount_cents outside the 100–500000 range. |
400 | asset deve ser "usdt" ou "usdc" (Polygon). | Only usdt or usdc. |
400 | payer_tax_number deve ser CPF (11) ou CNPJ (14) digitos. | Digits only, 11 or 14. |
400 | Rede nao suportada. Use chain="polygon". | cash-in settles on Polygon. |
400 | payout_address obrigatorio (endereco USDT Polygon 0x...). | Provide the destination address (or set settlement_address on the key). |
400 | payout_address invalido (endereco Polygon 0x...). | Invalid EVM address. |
409 | external_id ja utilizado com outros parametros. | Same id, different payload. Generate a new id. |
403 | Essa cobrança (…) estoura seu limite diário… (+ limits) | Tier ceiling. See limits; it rises with settled volume. |
502 | Erro ao gerar PIX: … · O provedor PIX não retornou o QR… | Unstable PIX provider. Retry — nothing was charged. |
404 | Cobranca nao encontrada. | Id does not exist or belongs to another key. |
403 | Listagem exige X-API-Key. | /cashin/charges does not exist in public mode. |
Cash-out (/cash-outs, /catalog)
| HTTP | detail | Cause & fix |
|---|---|---|
401 | Cash-out exige X-API-Key. Crie a sua em POST /keys… | cash-out has no public mode. Use the key. |
403 | Cash-out não habilitado para esta chave. | Rail disabled on the key. |
400 | asset e network são obrigatórios (veja GET /catalog). | Get valid values from /catalog. |
400 | amount deve ser decimal positivo em STRING (ex.: "25.5"). | Send amount as a string, never a float. |
400 | pix_key obrigatória… · pix_key_type deve ser: cpf, cnpj, phone, email ou random. | Provide the PIX key and the right type. |
409 | external_id já utilizado com outros parâmetros. | Generate a new id. |
403 | Limite diário do tier … esgotado… (+ limits) | Ceiling reached. It resets at midnight; it grows with volume. |
503 | Cotação temporariamente indisponível (pico de demanda)… | Upstream queue full. Retry with backoff. |
502 | Cash-out: <erro do orquestrador> | Upstream instability. Retry; nothing was moved. |
404 | Cash-out não encontrado. | Id does not exist or belongs to another key. |
Payouts (/payouts)
| HTTP | detail | Cause & fix |
|---|---|---|
403 | erro: "payout_nao_liberado", acao: "esperar" | The key has not settled R$ 1,000.00 yet — the release is automatic, with no human approval, and the body says exactly how much is missing (limits.falta_cents). Track it in GET /keys/me, block payout. |
400 | amount_cents mínimo: 100… · … acima do teto por operação (R$ 5000.00). | Range 100–500000 cents. |
400 | pix_key obrigatória… · pix_key_type deve ser: … | Provide the recipient's PIX key and the type. |
400 | tax_number deve ser CPF (11) ou CNPJ (14) dígitos. | Optional, but if you send it, it must have 11 or 14 digits. |
409 | external_id já utilizado com outros parâmetros. | Generate a new id. |
403 | Esse payout (…) estoura seu limite diário… (+ limits) | Tier ceiling (cash-in + payouts count toward the quota; cash-out does not). The body limits tells you what is available, and the scope in effect comes in daily_limit_applies_to in the GET /keys/me. |
503 | Pico de demanda — tente novamente em alguns segundos. | Provider rate limit. Retry with backoff. |
502 | Payout recusado: <erro do provedor PIX> | The provider rejected it (invalid key, etc.). See the detail. |
| 500 | Erro ao registrar o payout — NÃO repita sem conferir com o suporte. | Caution: the PIX may have gone out at the provider but failed to be recorded here. Do not resend blindly — check with support first. |
Rate limits
- 60 requests/min per key (token bucket, smooth). Exceeded →
429. - POST /keys: 5 keys/day per IP.
- Cash-out quotes have a global protection ceiling; under peak load,
503— retry in seconds. - Need more? Talk to us — the per-key limit is configurable.
You don't have to guess your budget. Every authenticated response carries the current state in the headers — read them instead of hitting the ceiling to find out:
X-RateLimit-Limit: 60 // your per-minute ceiling (= rate_limit_per_minute)
X-RateLimit-Remaining: 42 // how many still fit right now
X-RateLimit-Reset: 18 // seconds until the bucket refills
On a 429, you also get Retry-After: <seconds> — wait that long before retrying, with backoff. The body carries erro: "rate_limit", acao: "esperar".
Rules that prevent expensive mistakes
Each one of these has already cost money — someone's, here. They are in the main doc so they don't cost you yours.
A fixed-amount charge does not accept “close enough”
A charge QR reconciles by the exact amount and by the txid that lives inside the BR Code. Paying R$ 0.59 too much is as invisible as R$ 0.59 too little: the money arrives and the sale stays “unpaid”. If you pass a copy-and-paste code on to your customer, send the entire BR Code — not just the PIX key extracted from it. The key alone becomes a standalone transfer, with no txid, and no automatic reconciliation will ever find it.
expires_at sets the window, not your code
Always read the expires_at from the response. Today it is ~15 minutes on Polygon and up to 300 on other networks, but that changes with the network and with the provider. A window hardcoded in the client is the classic source of “I paid and it expired”.
Dates: ISO 8601 in UTC.
Every date from the API comes out in ISO 8601 in UTC, ending in Z:
2026-08-01T22:35:49.123Z. Never treat it as local time — convert on your side. The only exception is the key's daily quota, which is computed in the Brasília time zone (America/Sao_Paulo) and therefore rolls over at midnight there, not at midnight UTC. A timestamp read wrong turns into “payment past the deadline” in an argument with the merchant — and the receipt is a document of record.
delayed is not a failure
The PIX was paid and the provider is holding the release — common on a new payer's first operation. The response carries delay_until. Do not cancel it and do not create a second charge: it turns into paid on its own, and the second charge becomes duplicate money to reconcile by hand.
Absence is not proof of absence
nao_encontrado when verifying an E2E means that Lunium did not settle that payment — does not that the PIX did not happen. Another institution may have settled it. Report that difference to your user instead of alleging fraud.
external_id on every write
It is the reconciliation key and what makes the call idempotent: repeating it returns the
same order instead of creating another one. Reusing the same id with different parameters returns the order original and responds external_id_divergente — on purpose, so you don't think you updated something that did not change.
Idempotency
Send the your order id in external_id (cash-in and cash-out). Repeated the call with the same external_id and the same parameters? You get back the same operation (200) — with no duplicate charge and no duplicate order. Same id with different parameters → 409. It is your protection against timeout + retry.
Security
| Layer | How it works |
|---|---|
| Transport | HTTPS/TLS across the whole surface; HTTP is not served. |
| Credential | Key stored only as a hash (unrecoverable on our side); the plaintext prefix exists only for identification. Leaked? Create another one right away and contact us to deactivate the old one. |
| Webhooks | Every event goes out with two HMAC-SHA256 signatures: X-Lunium-Signature (v2, with timestamp — verify this one and reject stale deliveries) and X-Webhook-Signature (v1, body only — deprecated; shutdown on 2026-11-09, and keys created from 2026-08-08 onward no longer receive it). Reject an invalid signature with 401; rotate the secret whenever you want (rotate_webhook_secret). |
| Scope | Every resource is scoped by key: another customer's id responds 404, never 403 (we don't leak existence). |
| Sensitive data | CPF/CNPJ and PIX keys are always masked in responses and webhooks; providers' internal identifiers are never exposed. |
| Abuse | Rate limit per key (token bucket), key creation capped per IP, idempotency against retry double-spend — and, above all, limits that rise with history (below). |
| New key, little power | The credential is self-service, but it starts out constrained: payouts turned off (released only with R$ 1,000 settled), initial tier with a low daily ceiling that rises with volume, and a new payer (CPF/CNPJ) held to the ladder R$ 60 → R$ 200/24h → R$ 6,000/day. This is KYC-by-activity: whoever wants to move more needs history, not a form. No key without history starts out with the power of a mature account. |
| On your side | Key only in a server-side environment variable; never in front-end, app, repository or log. Validate webhooks before processing; use external_id on everything. |
Daily reconciliation
How to match your day against ours, without a manual spreadsheet:
- Tie everything together with
external_id(the id from YOUR system) on charges, cash-outs and payouts — it is the primary key of reconciliation. - Pull the day's window:
GET /cashin/charges?start=2026-07-21&end=2026-07-22,GET /cash-outs?limit=200andGET /payouts?limit=200. - Check against the source-of-truth fields: in cash-in, the amount that counts is
depix_received_cents(what actually came in) andsettlement_tx_hash(on-chain proof); in cash-out,state = COMPLETED; in payout,status = sent. - Webhooks are the trigger, the API is the statement: process events idempotently and reconcile from the listing — never rely on the webhook alone to close the books.
Best practices (the production checklist)
- ✅ Key server-side only; the front-end never sees it
X-API-Key. - ✅ Credit from the webhook signed (
cashin.settled/cashout.completed), with HMAC verification. - ✅ Use
external_idon every operation (idempotency for free). - ✅ Amounts: cents (int) in cash-in, decimal string in cash-out — never float.
- ✅ Handle
429/5xxwith retry + exponential backoff (1s, 2s, 4s…). - ✅ Link Telegram — it is your real-time monitor, for free.
- ✅ Trust the
depix_received_cents, not the quoted amount.
Complete example — PIX checkout → USDT
A minimal helper and the entire flow (create charge → track → credit via webhook):
// lunium.js — a 15-line helper, zero dependencies (Node 18+)
const BASE = 'https://api.luniumpay.com';
export async function lunium(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: {
'X-API-Key': process.env.LUNIUM_KEY,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`);
return data;
}
// 1) the customer clicked "pay with PIX"
const charge = await lunium('POST', '/cashin/charge', {
amount_cents: pedido.totalCents,
payer_tax_number: cliente.cpf,
payer_name: cliente.nome,
external_id: pedido.id, // idempotency
});
render(charge.qr_copypaste); // 2) show the copy-and-paste code
// 3) the REAL credit arrives on the signed webhook (see Webhooks):
// event === 'cashin.settled' → release the order data.external_idimport os, requests
BASE = "https://api.luniumpay.com"
def lunium(method, path, body=None):
r = requests.request(method, BASE + path, json=body,
headers={"X-API-Key": os.environ["LUNIUM_KEY"]})
data = r.json()
if not r.ok:
raise RuntimeError(data.get("detail", f"HTTP {r.status_code}"))
return data
# 1) create the charge
charge = lunium("POST", "/cashin/charge", {
"amount_cents": pedido.total_cents,
"payer_tax_number": cliente.cpf,
"payer_name": cliente.nome,
"external_id": pedido.id,
})
mostrar(charge["qr_copypaste"]) # 2) show the PIX code
# 3) credit on the 'cashin.settled' webhook (Webhooks section)Idempotency & recovery by external_id
One number that solves the two worst moments of a payment integration: the retry that must not charge/pay twice, and the 500/timeout in which you don't know whether the operation existed.
Send external_id in every write operation — cash-in, cash-out and payout. It is your reconciliation key, and the contract is the same in all three:
| Rule | How it works |
|---|---|
| Scope | Unique per key and per product. The same
external_id can exist once in cash-in, once in cash-out and once in payout — never twice in the same product under the same key. |
| Size / format | Text of up to 64 characters, case-sensitive (Pedido-1 ≠ pedido-1). Above 64 it is truncated. |
| Repeat identical | Same external_id + same parameters returns the
original operation with a 200 — it does not create a second one. Safe retry. |
| Repeat different | Same external_id + diverging parameters returns 409 external_id_divergente. The operation returned would be the ORIGINAL one, not yours: generate a new id. |
| Retention | Permanent. An external_id that has been used never becomes free again — that is what makes idempotency still hold months later. |
Recovery: find out the truth after an error
Got a 500, a timeout, or lost the response? Do not retry blindly — ask. The same idempotency key is the read key:
GET /cashin/charges?external_id=order-8812
GET /cash-outs?external_id=withdrawal-2207
GET /payouts?external_id=commission-042
Each one responds 200 with the product's list: one element if the operation exists (with its current state), empty list if it does not exist — and then it is safe to create it again with the same external_id. The unique index guarantees 0 or 1 result. It is the antidote for the payout case where "the PIX may have gone out but the response was lost": you query and you know.
external_id before calling; if the call fails ambiguously (timeout, 5xx), query by it before any retry. If you find it, use what came back; if you did not, repeat the creation with the same
external_id — idempotency covers the rest.Before the first real charge
Seven checks. Each one exists because it already cost money or someone's all-nighter — this is not a formality.
- Test the webhook with
POST /webhooks/testand confirm a 2xx. It is the difference between finding out your handler is wrong now or when the first PIX fails to arrive. - Verify the signature (
X-Lunium-Signature) and reject anything older than ~5 minutes. Never process an event without verifying it. - Deduplicate by
event_id. A retry on our side is normal; a double credit is not. - Send
external_idon every order. It is the reconciliation key and what makes the call idempotent — repeating it returns the same order instead of creating another one. - Branch on
erroandacao, never by the text.detailis for humans and changes without notice. - Read the limits from
GET /keys/me(per_operationandpayer_limits) instead of hardcoding values in your code — they rise on their own with your volume. - Join the partner group. It is where contract changes are announced before they go live, and where you talk to us when it cannot wait.
The sandbox charges nothing and moves no money: POST /keys/sandbox returns a lun_test_… instantly, and all 7 items above can be exercised end to end there.
Versioning & changelog
Compatibility policy: changes that are additive (new fields in responses, new events, new codes for erro, new endpoints) happen without notice — write your parser to ignore unknown fields and handle an unknown event or code by whatever the
acao says. Changes that break the contract (removing or renaming a field, changing semantics) are announced in advance.
Where the notice goes out, in order of reach: in the
partner group on Telegram, in your key's bot and in this table. If you registered an e-mail in POST /keys, by e-mail as well — but most keys have no e-mail, so do not rely on it alone: join the group.
The OpenAPI is the contract. info.version in
openapi.json goes up by a MINOR on every compatible addition and by a MAJOR if something breaks. An automated test checks, every 6 hours, whether production still does what that file promises — when it diverges, we are notified before you are.
| Date | Change |
|---|---|
2026-08-26 | PIX key holder before paying: GET /pix/keys/lookup?key= queries DICT and returns the name, CPF/CNPJ masked and institution — this is what lets you show "you are about to pay John Doe · Nubank" before the accept. Type inferred from the format; 24h cache; 5 lookups/min across the whole account (429 limite_consultas with a Retry-After); a nonexistent key → 404 chave_nao_encontrada. The sandbox returns a test holder in the same shape. |
2026-08-26 | Reverse quote on cash-out: POST /cash-outs accepts brl_amount (the PIX amount in reais) in place of amount — exactly one of the two. The response carries the crypto amount computed in amount. Additive: anyone sending amount sees no change. The sandbox mirrors it (in the reverse direction, the deterministic trigger reads the cents of brl_amount). New error code brl_amount_invalido. |
2026-08-08 | Sunset announced ACTION REQUIRED BY NOV 9, 2026 — on November 9, 2026 the following cease to exist: (1) the webhook signature v1 (X-Webhook-Signature) — verify the v2 (X-Lunium-Signature, HMAC of "timestamp.body", reject > 5 min); and (2) the field code in error bodies — branch on erro. Existing integrations stay intact until that date; keys created from Aug 8, 2026 onward already receive v2 only. |
2026-08-08 | Hardening audit — docs × production, endpoint by endpoint. Behavior changes: the cash-in minimum amount is now per key BEHAVIOR CHANGE (below it the fixed fee would consume everything and delivery would be zero — rejected with valor_abaixo_do_minimo; read limits.per_operation.cashin.min_brl_cents in GET /keys/me); networks that require a memo/tag were removed from /catalog and are rejected in the POST /cash-outs BEHAVIOR CHANGE (without the memo the deposit was lost); fee example fixed — do not recompute the quote locally, the API fields are the source of truth. |
2026-08-08 | Recovery by external_id: GET /cashin/charges, GET /cash-outs and GET /payouts accept ?external_id= and return the exact operation (0 or 1) — the antidote to the ambiguous 500/timeout. The divergence 409 is external_id_divergente across the three products, and an identical replay returns 200 in the sandbox too. New section: Idempotency & recovery. |
2026-08-08 | Operability: every authenticated response carries X-RateLimit-Limit/-Remaining/-Reset (and the 429, Retry-After); GET /keys/me gained the block payout (machine-readable eligibility); PATCH /keys/me accepts rotate_monitor_token; webhook signature v1 marked as deprecated — verify v2 (X-Lunium-Signature). |
2026-08-07 | Webhooks: identity and anti-replay. The body now carries
event_id (evt_…, stable across retries — deduplicate by it) and new headers are sent: X-Lunium-Event, -Event-Id, -Delivery-Id,
-Attempt, -Timestamp. New signature X-Lunium-Signature
(t=…,v1=…, HMAC of "t.body") that lets you reject an old delivery; the
X-Webhook-Signature old one remains unchanged and valid. New:
POST /webhooks/test, GET /webhooks/deliveries and
POST /webhooks/deliveries/{event_id}/retry — an event that exhausts its attempts is no longer lost. webhook_url is now validated at key creation as well (https, public host). |
2026-08-07 | Every response is traceable: header X-Request-ID
on everything and field request_id on every error. If you send X-Request-ID
([A-Za-z0-9._:-]{8,64}), it is preserved end to end. Every error now carries
erro and acao — previously 59 of the 74 endpoints answered only detail. |
2026-08-07 | sandbox identical to production: a nonexistent pair asset/network
is now rejected in the sandbox with the same 400 as production — before, it would quote a made-up network and the code only broke on the first real day. Cash-out errors now carry
erro/acao and an actionable message instead of the raw passthrough from the internal service. |
2026-08-07 | Limits stop being guesswork: GET /keys/me
now publishes per_operation (minimum and maximum for cash-in, payout and cash-out) and
payer_limits (the ladder per CPF/CNPJ). The daily quota now enforces exactly the scope that
daily_limit_applies_to declares. Read from here instead of hardcoding numbers in the client. |
2026-08-07 | POST /payouts with a external_id became idempotent for real: the order is reserved before the PIX goes out, so two simultaneous calls with the same external_id do not pay twice. With no response from the provider it returns
provedor_sem_resposta with a acao: "parar" — query, do not repeat. |
2026-07-31 | Multi-network cash-out documented: table of networks and times, the two settlement paths and the per-currency minimums (USDT 10 · USDC 20). Solana temporarily unavailable for cash-out — rejected with 400 and out of the /catalog. |
2026-07-30 | Limit per payer (CPF/CNPJ) BEHAVIOR CHANGE: a ladder of R$ 60 on the first use → R$ 200 in the first 24 h → R$ 6,000/day per document, separate from the key ceiling. Exceeding it returns 403. |
2026-07-26 | Conversion rail: cash-out of USDT and USDC on the main networks (Ethereum, BNB Chain, Arbitrum, Optimism, Base, Avalanche, TON, Aptos, NEAR and others) through the same POST /cash-outs — time set by each network's confirmations. |
2026-07-21 | Live per-client monitor: monitor_url in the POST /keys and GET /keys/me (real-time web panel, read-only, scoped to the key) + GET /cashin/events (JSON) + command /monitor in the agent. A team group is now the standard onboarding recommendation. |
2026-07-21 | USDC on cash-in: field asset ("usdt" default | "usdc" Circle-native, Polygon) in /cashin/preview and /cashin/charge; cash-in responses now include asset. Bot: usdc optional in /cobrar /qrdelay /cotar /calcular. |
2026-07-21 | Bot becomes a full agent: groups per project (linked with code lk…), AI with real account data, /sacar /pagar /cotar /calcular /qrdelay /ping and DePix bot aliases; money only with a confirmation button. |
2026-07-21 | Payouts (direct BRL PIX, subject to approval) + events payout.*; GET /ping; filters start/end in /cashin/charges; commands /cobrar, /qr and /webhook in the bot; technical assistant in the bot. |
2026-07-20 | Launch of the unified API: self-service key (POST /keys), cash-in and cash-out on the same key, tiers by volume, end-to-end signed webhooks, Telegram bot with real-time alerts. |
Support & contact
Talk directly to someone on the team, during business hours. This is the way when the subject is a contract, a custom limit or a production problem that cannot wait.
Talk to the team
The same bot that alerts you on every transaction also answers technical questions at any hour. Link in your POST /keys /
GET /keys/me.
status measures cash-in, cash-out, webhook delivery and whether production still complies with the OpenAPI — every 5 minutes, with real calls. Check it before opening a ticket.
contato@luniumpay.com — Enterprise, partnerships and anything that needs to be on the record in writing.
⬡ Lunium — the PIX ↔ stablecoin hub. This documentation describes the current behavior of the API in production; contract-breaking changes are announced in advance in the partner group, in your key's bot and in the changelog.
ENVIRONMENT: PRODUCTION · 24/7 MONITORING · CONTATO@LUNIUMPAY.COM