Everything you need to take stablecoin payments in about ten minutes. No accounts to register with us, no funds ever held by us.
Quickstart — three calls to your first payment
Get an API key + tell us your wallet
During open beta, email your merchant name and a watch-only xpub (or ask us to generate setup instructions for your wallet) to the contact given in your onboarding note. You receive LL_API_KEY. We store only the public key — funds arrive directly in a wallet you already control.
What is an xpub? Your wallet (e.g. Ledger, Trust, Rabby, any BIP-32 HD wallet) can export an "extended public key". LedgerLite uses it to derive fresh deposit addresses per invoice. Search "export xpub" in your wallet's docs. Ethereum: m/44'/60'/0', Tron: m/44'/195'/0'.
Create an invoice
curl -s https://api.xstn.com/v1/invoices \
-H "Authorization: Bearer $LL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chain": "tron",
"token": "USDT",
"amountMinor": 10000000,
"customerRef": "order-1042",
"ttlMinutes": 60
}'
Response (the invoice's depositAddress is unique to this order — give it to your customer):
{ "invoice": {
"id": "inv_kAU7yk4REOCV",
"status": "created",
"chain": "tron", "token": "USDT",
"contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"decimals": 6,
"amountMinor": 10000000, // = 10.000000 USDT
"depositAddress": "TLdUbUprk7BDxdVBgqgmm4EUPw1mAadVSd",
"customerRef": "order-1042",
"createdAt": 1789500270000,
"expiresAt": 1789503870000
} }
Receive the payment event
Either poll GET /v1/invoices/inv_kAU7yk4REOCV every few seconds, or — recommended — point webhooks at your server (see Webhooks). Your job ends here: when the webhook says invoice.confirmed, the money is in your wallet, confirmed on-chain, with the exact amount.
Authentication
Every request carries your key as a bearer token:
Authorization: Bearer <LL_API_KEY>
Wrong or missing key → HTTP 401 {"error":"unauthorized"}. Keep the key server-side only; never embed it in browser/mobile code.
API reference
POST /v1/invoices — create
| Field | Type | Required | Notes |
|---|---|---|---|
chain | string | yes | "tron" · "eth" · "btc" |
token | string | yes | USDT / USDC (tron, eth) · native ETH (eth) · native BTC (btc). Decimals: tokens 6, ETH 18, BTC 8. |
amountMinor | integer or numeric string | yes | Smallest unit as a non-negative integer: USDT/USDC = 6 decimals ($10 → 10000000), ETH = 18 (1 ETH → 1000000000000000000), BTC = 8 (1 BTC → 100000000). For amounts above 2⁵³ send a digit-string — precision-safe at any size. The response echoes amountMinor as a string. |
customerRef | string | no | your order id; echoed back in reads & webhooks |
ttlMinutes | number | no | default 1440 (24 h). After expiry the invoice goes to expired; a late payment still reopens it (see lifecycle). |
webhookUrl | string | no | per-invoice override of your default webhook |
GET /v1/invoices/:id — read
Full invoice object incl. status, flags (underpaid, overpaid, late) and, once paid, paymentTx: { txHash, paidMinor, confirmations, chain }.
GET /v1/invoices — list
?ref=order-1042&limit=50&status=confirmed for your reconciliation page.
POST /v1/invoices/:id/claim — optional hint
{ "txHash": "0x8c3f…" }
Speeds detection up to the next tick (≈ a few seconds). Purely a hint: the hash is re-verified on-chain; a fake or unrelated hash can never mark anything paid (§20 of our design). Safe to expose to untrusted customers.
Invoice lifecycle
created ──(first scan sees the address funded)──▶ funding_awaiting
funding_awaiting ──(transfer detected)──▶ funding_detected
funding_detected ──(N confirmations + exact amount)──▶ confirmed ✅
any pre-paid state ──(ttl over)──▶ expired
expired ──(payment arrives late)──▶ funding_detected → confirmed (flags.late = true)
| Status | Meaning | You should |
|---|---|---|
created | address assigned, nothing seen yet | show address to customer |
funding_awaiting | we are actively watching this address | show "waiting for payment" UI |
funding_detected | transaction found on-chain, confirming | show "confirming…" UI |
confirmed | enough confirmations, exact contract & amount ✅ money in your wallet | ship the goods; this is your payment signal |
expired | TTL passed unpaid | re-issue a new invoice or contact buyer |
Flags
| Flag | Meaning | Handling |
|---|---|---|
underpaid | less than asked arrived (chain-fee mistakes, wrong amount) | invoice stays unconfirmed; send buyer a second invoice for the difference, or refund-less partial policy is up to you |
overpaid | more than asked arrived | does not block confirmation; keep the surplus or refund the excess off-band |
late | payment after expiry — invoice reopened automatically | treat as paid; honour the order |
Webhooks
We POST JSON to your webhookUrl:
POST https://your-shop.example/ledgerlite-hook
Content-Type: application/json
x-ledgerlite-event: invoice.confirmed
x-ledgerlite-timestamp: 1789500333000
x-ledgerlite-signature: sha256=<hex>
{
"event": "invoice.confirmed",
"ts": 1789500333000,
"invoice": { "id": "inv_…", "status": "confirmed",
"customerRef": "order-1042",
"paymentTx": { "txHash": "…", "paidMinor": 10000000,
"confirmations": 20 } }
}
Events
| Event | When |
|---|---|
invoice.awaiting | address now being watched |
invoice.payment_detected | on-chain transfer seen, confirming |
invoice.confirmed | payment final — deliver the order |
invoice.underpaid | detected but short |
invoice.expired | TTL reached unpaid |
invoice.reopened_late_payment | expired invoice got paid later |
Verify the signature (Node/Express example)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/ledgerlite-hook',
express.raw({ type: 'application/json' }), // ⚠ verify against RAW body
(req, res) => {
const ts = req.headers['x-ledgerlite-timestamp'];
const sig = (req.headers['x-ledgerlite-signature'] || '').replace('sha256=', '');
const expected = crypto
.createHmac('sha256', process.env.LL_WEBHOOK_SECRET)
.update(ts + '.' + req.body.toString('utf8'))
.digest('hex');
if (!sig || !crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex')))
return res.status(401).end();
const payload = JSON.parse(req.body.toString('utf8'));
if (payload.event === 'invoice.confirmed') {
markOrderPaid(payload.invoice.customerRef);
}
res.status(200).end(); // non-2xx = we retry
});
Retries: exponential backoff (≈1 m, 5 m, 30 m, 2 h, 6 h …), events persisted in an outbox and replayed if your endpoint is down. Idempotent consumers: process each invoice.id once (guard on paymentTx.txHash).
Errors & how to fix them
| HTTP | Body | Cause | Fix |
|---|---|---|---|
| 400 | bad json | malformed body | send valid JSON, Content-Type: application/json |
| 400 | unknown chain "…" | typo in chain | only tron / eth (others on roadmap) |
| 400 | unknown token … | token not supported for chain | USDT / USDC only; check spelling & case |
| 400 | amountMinor must be a positive integer (number or digit-string) | float, negative, or non-digit string | multiply dollar amount by 10⁶ and round to an integer (or pass the digit-string) |
| 401 | unauthorized | missing/wrong bearer key | header exactly Authorization: Bearer <key> — no "Basic", no quoting issues with env vars |
| 404 | not found | invoice id typo or belongs to another account | store the returned id, use it verbatim |
| 404 | no such route | endpoint typo (e.g. /v1/invoice) | plural /v1/invoices; see reference above |
| 500 | internal | chain-data provider hiccup (TronGrid/Etherscan rate limit) | retry with backoff; state is persisted, nothing is lost — detection is poll-resumed |
Behavioural "errors" (no HTTP code)
| Symptom | Likely cause | Fix |
|---|---|---|
Customer paid, invoice still funding_awaiting | paid to the old address, wrong network (e.g. USDT-TRC20 sent to an ETH address or vice-versa), or sent native coin (ETH/TRX) instead of the token | check the tx on explorer with the depositAddress; wrong-network transfers need manual recovery by you; always display chain + token + address together, unambiguous |
Status stuck funding_detected for minutes | confirmations accumulating (that's normal: ~9 blocks for Tron detection→safety, several minutes Ethereum) | wait; or have customer send txhash via /claim for faster attention |
Marked underpaid | buyer deducted chain fee from amount, or copied amount with fewer decimals | create second invoice for exact difference; in UI show amount as plain "10 USDT (TRC20)" copy-field |
| Invoice expired although paid at minute 59.9 | race between expiry & detection | no action: late payment automatically reopens it (flags.late, event invoice.reopened_late_payment) |
| Webhook never arrives but invoice is confirmed | your endpoint returned non-2xx / timeout, or public URL blocked bots/firewalled | read GET /v1/invoices/:id as fallback; ensure 200 within 10 s; disable WAF challenge pages on the hook path |
| Duplicate fulfilment | retried webhook processed twice | idempotency: store processed invoice.id / txHash before acting |
Testing before go-live
- 1 USDT smoke test on mainnet is the only honest end-to-end test (no public testnets for our monitored contracts yet — Tron Nile testnet tokens are not detected by us and would prove nothing).
- Anti-fraud check (safe in production): call
/claimwith a fake txHash — invoice must stay unpaid. If it ever flips to confirmed from a bogus claim, that is our bug: report immediately, payment is not released. - Webhook signature: send yourself an invalid signature → must 401 from your endpoint. Then a valid one → 200.
- Underpay flow: pay 90% of the invoice once, observe
invoice.underpaid+ non-confirmation; then top up the difference → detected as combined payment and confirmed.
Security model in one paragraph
LedgerLite is watch-only. We derive addresses from your xpub, watch the public chain, and sign nothing. Money moves customer → your wallet directly; we cannot freeze, redirect or hold funds — and you cannot lose your money by losing access to us. Your API key only creates/reads invoices in your own namespace; webhook payloads are HMAC-signed with a shared secret so nobody can forge a "paid" event into your shop.
Pricing
| Pay as you go | Starter (monthly) | Growth (monthly) | |
|---|---|---|---|
| Fee per confirmed invoice | 1‰ | 0% | 0% |
| Included volume | unlimited, pay-only | 300 / mo | 1,500 / mo |
| Price | — | €1.5 / mo · €12 / year (2 months free) | €4.5 / mo · €36 / year |
| Webhooks, xpub watch-only, multi-currency | ✅ | ✅ | ✅ + priority confirmation |
Open beta: every tier is currently free — including the 1‰. For scale: crypto payment processors typically charge 1–2% and card rails ~2.9% — our rate is a tenth of the industry standard, because non-custodial infrastructure costs us almost nothing per invoice. Rates lock in only when we announce GA, and existing merchants keep beta pricing for 6 months. Billing itself happens in stablecoins through LedgerLite, naturally.
FAQ
Do customers need an account?
No. They see one address and one amount. Any wallet or exchange withdrawal works.
Which chains/tokens now?
USDT & USDC on Ethereum (ERC-20), USDT on Tron (TRC-20) — plus native ETH and native BTC (BIP-84 bc1q… addresses, derived from the same master key). BTC confirms at 2 blocks (~20 min); native ETH needs no API key. Ethereum token scanning requires an Etherscan key on our side (beta: Tron-first, ETH tokens queued). BSC/Polygon/USDC-Tron on the roadmap.
Any per-invoice amount limits?
No. Pass amountMinor as a digit string (e.g. "200000000000000000" = 0.2 ETH) and amounts beyond JavaScript's safe-integer range work exactly — internally we compare in BigInt. Whale-friendly.
What if I want the money in a cold wallet?
Point the xpub at a receiving wallet you control, sweep whenever. Or derive the xpub from your hardware wallet directly.
Is my order data visible to you?
Only what you send: amounts, your customerRef, addresses. No names, no cards, nothing personal.
Refunds?
Not in v1 — the chain is final; refunds are you sending from your own wallet back to the customer's. A refund API is planned.
Docs version 2026-09-16 · API is versioned at /v1; backwards-incompatible changes only at /v2 with 6-month parallel run.