Payments
Take crypto payments on your own site, or at a URL you send to someone. Both run on the same primitive: ChainOS derives a fresh address from your extended public key the moment it is needed, watches it, and tells you what arrived.
Nothing about this changes the custody model. The address belongs to your mnemonic, which only your Edge has ever seen. Ziklag cannot spend from it and neither can anybody who compromises us.
The two surfaces are different products
Pick by where the payer is, not by which sounds more flexible.
| Payment button | Payment link | |
|---|---|---|
| Where | Embedded on your checkout, in a cross-origin iframe | A page we host, at a URL you send out |
| Address | One per checkout session | One per link |
| Payers | One | One (fixed) or many (donation) |
| Amount | Set by your cart, per purchase | Preconfigured on the link |
| Closes when | Paid, or the countdown expires | Paid once, a threshold is reached, or a date passes |
| You write | A few lines of JavaScript | Nothing |
A link is not a factory that mints a session per visitor. It owns an address and a running total, which is why it has no countdown: a link closes on a threshold or a date.
→ Payment button · Payment links
Four things to know before you start
1. A payment is irreversible. There is no chargeback, no issuer and no recall. That is why the checkout is served in a cross-origin iframe rather than drawn in your page: the deposit address is the payload, and any XSS, any compromised tag-manager script and any browser extension with host permissions on your domain could otherwise rewrite it silently and take every payment. It also means the honest version of the claim — a frame makes address substitution a visible impersonation that has to rebuild the UI pixel for pixel, rather than making it impossible.
2. Your server decides what is paid, always. Read Verifying payments before you write any fulfilment code. It is the shortest page here and the only one that can cost you money.
3. Amounts are decimal strings, never numbers. "100.00", not 100.0. JSON has one
numeric type and it is a double, and these figures decide whether an order is paid. More
decimal places than the asset carries is refused, not rounded — rounding would quietly
ask a payer for less than you asked for and then report the order paid in full.
4. A payment that lands late is still yours. Expiry and archival are two separate
deadlines. expiresAt stops the checkout accepting the payment; monitorUntil — weeks
later — is when we stop watching the address. A payer who broadcasts at 14:59:50 against a
15:00:00 deadline has sent real money, and we credit it. See
Late payments.
Where a payment can be

status on the session is one of these nine, lowercase, and it is the only thing your
fulfilment code should branch on.
The two worth reading twice are underpaid and expired_paid. Neither is a failure.
underpaid means the payer committed real money and it fell short — usually a fee the
sending exchange took — so the page asks for the remainder at the same address and extends
the window once. expired_paid means money arrived after the clock ran out, which is
money the payer cannot get back; calling that "expired" and moving on is how a customer
loses funds because our timer expired.
completed is terminal, including after a reorg. The invoice is issued and goods may
already have shipped, and a state machine that un-completes is one that claims it can
un-ship. A post-confirmation reorg is recorded on the session and raises a webhook, and a
person decides.
Chains and assets
Checkout supports seven of the ten chains ChainOS monitors: Bitcoin, Ethereum, BNB Smart Chain, Polygon, Avalanche, Base and TRON. Stablecoins are the intended case and TRC-20 USDT on TRON is plausibly the highest-volume asset you will see.
Solana, Stellar and XRP are refused at session creation with an error that names the reason, rather than failing later and obscurely:
- Solana and Stellar issue addresses from a pool your Edge pre-derives. Checkout on the other seven keeps working through an Edge outage — Cloud holds the extended public key and derivation is pure computation — but on these two it would stop.
- Stellar additionally cannot receive an issued asset until the address has signed its own trustline, and the sender cannot create it for them.
- XRP cannot receive anything below the base reserve until the account is funded, so a freshly derived per-session address is unusable for small payments.
Ask the server rather than hardcoding the list:
curl $CHAINOS/v1/payments/readiness -H "X-API-Key: $CHAINOS_PAYMENT_KEY"
Every chain comes back with available, a reason when it is not, the assets you may
price in, and the confirmation threshold. Read it before offering a chain rather than
after somebody has picked one — a gateway that answers 403 at the moment a customer
presses Pay is a lost sale with nothing on any screen to explain it.
Dust: the failure that costs you the payment
A 5 USDT payment on Ethereum costs more in gas to collect than it is worth, and batching does not help — on an account-model chain every consolidation is its own transaction.
So a session below the chain's economic minimum is refused at creation, with a message that names a cheaper chain:
5 USDT is below the 40 USDT minimum for Ethereum at current gas. Use Base, BSC or Polygon.
The same figure is on /v1/payments/readiness as nativeMinimum and estimatedSweepFee,
so you can steer the chain choice before the payer commits. That is the only moment this is
free to fix.
nativeMinimum is null for tokens, and that is not an omission: the fee comes back in ETH
or BNB while the amount is in USDT, and comparing them needs a rate we deliberately keep
off the payment path.
Keys
Two kinds, minted as a pair.
pmk_live_… / pmk_test_… | ppk_live_… / ppk_test_… | |
|---|---|---|
| Called | Secret key | Publishable key |
| Lives | Your server | Your page source |
| May do | Everything on the payments API | Open a checkout session, and nothing else |
| Bounded by | Nothing | An exact origin list and a per-session cap |
They are minted together and not by two separate calls, because issuing them separately is how the secret one ends up in a page.
Server-created sessions are the documented default. In publishable-key mode the browser asserts the amount, which means a customer can pay 0.01 for a 100.00 order and the webhook will faithfully report a confirmed payment. Origin locking and the cap bound the upside only. See Verifying payments.
Invoices
An invoice is issued and emailed automatically on the first confirmation, numbered
INV-2026-000123 — per account, per year, so the number does not tell your customer how
many invoices the whole platform has issued.
It is a hosted HTML page with a print stylesheet, not a PDF. Resending mints a new link and stops the old one working, which is what you want when the first email went astray.
One case issues no invoice at all: a donation link using one shared address. A shared address cannot tell two concurrent donors apart well enough to put a name on a receipt, and that is the stated cost of choosing it.