Cash-out · stablecoin → PIX

USDT to PIX API

You send crypto to an address created per order. A PIX key receives reais, with a receipt the counterparty verifies on their own. Quote, accept, deposit — three calls.

Create a sandbox key Talk to integration Full documentation

Who this is for

The problem

Selling crypto and getting the PIX into the right account is usually a manual process: someone sends to an exchange, waits for the sale, withdraws to a bank and transfers. Each step has a cut-off, a limit and a human. When it fails, the money sits somewhere nobody can point at.

Here the order has state. You know where it is, the amount is locked by the quote you accepted, and a failure has a defined destination: the crypto goes back to the refund address you supplied.

The flow, end to end

  1. Quote with POST /cash-outs/preview, in either direction: by amount in reais or by crypto quantity. Nothing is created.
  2. Create the order with POST /cash-outs, naming the PIX key that receives — or the copy-and-paste code of a charge.
  3. Accept with POST /cash-outs/{id}/accept. The response carries the deposit address, the exact amount and expires_at.
  4. Send exactly the quoted amount, on the quoted network. On shared-address networks, include the deposit_tag.
  5. The PIX goes out on its own. cashout.completed brings the pix_e2e and the receipt URL.

Endpoints used

EndpointWhat for
POST /cash-outs/previewQuote both ways, without creating an order or consuming a limit. Built for screens that quote on every keystroke.
POST /cash-outsCreates the quote with the destination PIX key, or with br_code to pay a charge.
POST /cash-outs/{id}/acceptLocks the quote and returns the deposit address, plus the memo when the network requires one.
GET /cash-outs/{id}State, server-stamped timeline and receipt.
GET /pix/keys/lookup?key=Key owner before the accept: show "you are paying John · Bank" and avoid a refusal.
GET /catalogAssets, networks and per-route minimums. Source of truth, changes on its own.

A example that runs

# 1. cotação (não cria ordem)
curl -X POST https://api.luniumpay.com/cash-outs/preview \
  -H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
  -d '{"asset":"USDT","network":"polygon","brl_amount":"500.00"}'

# 2. cria a ordem e aceita
ID=$(curl -sX POST https://api.luniumpay.com/cash-outs \
  -H "X-API-Key: $LUNIUM_KEY" -H "Content-Type: application/json" \
  -d '{"asset":"USDT","network":"polygon","brl_amount":"500.00",
       "pix_key":"loja@exemplo.com","pix_key_type":"email",
       "external_id":"saque-4412"}' | jq -r .cashout_id)

curl -X POST https://api.luniumpay.com/cash-outs/$ID/accept -H "X-API-Key: $LUNIUM_KEY"
const cabecalho = { "X-API-Key": process.env.LUNIUM_KEY, "Content-Type": "application/json" };

const previa = await (await fetch(`${API}/cash-outs/preview`, {
  method: "POST", headers: cabecalho,
  body: JSON.stringify({ asset: "USDT", network: "polygon", brl_amount: "500.00" }),
})).json();

const ordem = await (await fetch(`${API}/cash-outs`, {
  method: "POST", headers: cabecalho,
  body: JSON.stringify({
    asset: "USDT", network: "polygon", brl_amount: "500.00",
    pix_key: "loja@exemplo.com", pix_key_type: "email", external_id: "saque-4412",
  }),
})).json();

const aceita = await (await fetch(`${API}/cash-outs/${ordem.cashout_id}/accept`, {
  method: "POST", headers: cabecalho,
})).json();

console.log(aceita.deposit_address, aceita.deposit_tag, aceita.expires_at);
import os, requests

API = "https://api.luniumpay.com"
h = {"X-API-Key": os.environ["LUNIUM_KEY"]}

previa = requests.post(f"{API}/cash-outs/preview", headers=h, timeout=30, json={
    "asset": "USDT", "network": "polygon", "brl_amount": "500.00"}).json()

ordem = requests.post(f"{API}/cash-outs", headers=h, timeout=30, json={
    "asset": "USDT", "network": "polygon", "brl_amount": "500.00",
    "pix_key": "loja@exemplo.com", "pix_key_type": "email",
    "external_id": "saque-4412"}).json()

aceita = requests.post(f"{API}/cash-outs/{ordem['cashout_id']}/accept",
                       headers=h, timeout=60).json()
print(aceita["deposit_address"], aceita.get("deposit_tag"), aceita["expires_at"])

Sandbox: test before spending

One call, no prior approval, no card; only your email is required (that is where we send the manual, the flowchart and the integration steps). 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 '{"email":"you@company.com","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

Use GET /catalog for incoming crypto and GET /cashin/catalog for delivery after Pix. Asset availability, networks and minimums differ between the two directions and can change.

Send the returned deposit_tag with a deposit when required. Use payout_tag for an outgoing route that requires a memo. See the API manual for the current contract.

Limits and holds

Read account limits and fees from GET /keys/me, payer availability from GET /cashin/limits?payer_tax=... and route minimums from previews. Cash-in, cash-out and payouts have different constraints.

A received payment can be held before settlement. Track status, timeline and signed events until the final outcome; read expiry from expires_at.

Measurements, not promises

Settlement time depends on the payment flow, route availability, network confirmations and any hold. Track the final state and check service status. Sandbox timing does not measure real settlement.

Idempotency

Send external_id on every money-moving call. Repeating the same call returns the same operation, never a second one; the same parameters with a different id create two, and the same id with different parameters answers 409 external_id_divergente instead of guessing.

A timeout is not a refusal. If the call did not answer, look the operation up by external_id before repeating — it may already exist.

Webhooks and reconciliation

Every transition fires an event signed with HMAC SHA-256 plus a timestamp (header X-Lunium-Signature, format t=…,v1=…) — the timestamp is what stops a captured delivery from being replayed. Verify the signature before trusting the body.

The webhook is the primary signal; polling is optional. When you do poll, the status carries a timeline stamped by the server — draw the stage from it, because your polling stopwatch measures your network, not the operation.

Did not answer 2xx? Delivery is retried with backoff. You can resend by hand at POST /webhooks/deliveries/{event_id}/retry and test your URL at POST /webhooks/test.

When it goes wrong

Deposit with an amount different from the quote: the order does not match and goes to review instead of paying an amount nobody recognises. Send the exact amount from the response.

Wrong network, or after expires_at: the deposit arrives orphaned and becomes a manual review. Always deposit before expiry, on the quoted network.

The bank refuses the key: the crypto goes back to the refund_address you supplied, with state REFUNDED and the refund hash — no balance left hanging with us.

A memo network without the memo: the funds land at the exchange with no owner. That is why the accept returns deposit_tag on those networks and the docs insist: if it came filled, it is mandatory.

Security

Open verification is the one that matters in a negotiation: your counterparty checks the PIX themselves, with no key and without taking our word for it.

Time to first integration

Three calls and a deposit. Anyone who only wants the price stops at the first, which creates nothing and consumes no limit.

Polygon is the predictable route: 83 s median from accept to PIX paid (66 orders, 90 days, measured 2026-09-17). On other networks the clock is the confirmations — it is in eta in the catalog.

Questions integrators ask

Can the amount change between quote and deposit?

No. The accept locks the quote and expires_at says how long it holds. Depositing after that is what breaks the match — not a price move.

Can I pay a QR code instead of a key?

Yes. Send br_code (the whole copy-and-paste string) instead of pix_key, with USDT or USDC on any catalog network. The order's brl_amount becomes exactly the QR amount, and you can see the merchant before confirming by sending the same br_code to the preview.

How does the other side verify the PIX was paid?

By the EndToEndId, in the open verifier: GET /v1/verificar/{e2e} answers with no key at all. The counterparty does not have to take your word, or ours.

Can I pay third parties?

Yes, the key does not have to be yours. The daily receiving limit is per receiving tax id and is in GET /keys/me.

Which networks settle fastest?

Polygon: 83 s median from accept to PIX paid. Off it, the wait is the confirmations the exchange requires, listed per route in GET /catalog.

Create a sandbox key Talk to integration

Current OpenAPI contract · facts on this page in product-truth.json · status

Take USDT, pay PIX. Start with a test key.

The test key takes one call, no prior approval and no cost — and your case goes straight to the integration team.

Get a sandbox key and talk to integration 3.3% + R$ 1.00 on cash-in · 1% + provider fee on PIX → PIX · no monthly fee

Other paths

PIX to USDT APITake reais, deliver USDT to your customer’s wallet.PIX payout APIPay suppliers and users in reais, over the API.BRL ↔ USDT over APIBoth directions, with a quote and a receipt.Brazil on-ramp and off-rampCrypto in and out of Brazil, in one integration.PIX at checkout, stablecoin in treasuryCharge in PIX, hold in digital dollars.PIX and stablecoin for walletsGive your users a way in and out.Infrastructure under your brandThe whole rail running behind your product.

Talk to the people who built it

A sandbox key takes one call, no prior approval — if that's what you're after, start with the docs. This form is for the rest: volume pricing, a case the docs don't cover, or a question before you integrate.

Rather email us? contato@luniumpay.com