Building a crypto payment gateway
You are a fintech. Your customers are shops. They will never run a container, never hold a mnemonic and never have a ChainOS account — your product is their dashboard, and as far as they are concerned you are the payment processor.
This page is that business, end to end, on the checkout page ChainOS hosts and co-brands. If the page has to be entirely yours, build this first and then read White-label gateway, which is the same machinery with a different front.
Read this paragraph before you build anything
Ziklag still cannot move funds. Payment addresses derive from your extended public key
and sweep to the address your Edge re-derives at M/1/1 — the only destination its
signing guard admits.
But a sub-merchant's money lands in your treasury. That makes you the custodian of your merchants' funds, and what a merchant is owed becomes a book entry rather than a balance sitting somewhere addressable.
ChainOS keeps that book for you and can prove it adds up. It cannot pay anybody. There is no payout rail in this product and acquiring one would mean a new signing guard on your Edge — the one control that stops a compromised Cloud redirecting funds.
That is a licensing position, not an implementation detail. If it is not the model you want, the alternative is for each shop to hold its own ChainOS account and run its own Edge — in which case they are a customer of ours, not of yours, and the page they want is Merchant acceptance.
The shape of it

Two books, and the whole job is keeping them in step. The pool is real money in one place. The merchant ledger is a per-merchant record of obligations against it.
1. Provision a merchant
curl -X POST $CHAINOS/v1/payments/merchants \
-H "X-API-Key: $CHAINOS_KEY" -H "Content-Type: application/json" \
-d '{
"slug": "corner-shop",
"externalRef": "MERCHANT-4417",
"displayName": "Corner Shop",
"supportEmail": "help@corner-shop.example",
"feeBps": 250
}'
A merchant is a name a payer sees, a logo, a commission and a balance. It is not a tenant:
no users, no login, no credentials of its own, no row in accounts.
Two fields are identity and cannot be changed afterwards.
slug appears in payment-page URLs you may already have sent to customers.
externalRef is your own identifier, and it is worth setting on every merchant. Without
it, reconciling your book against ours is a name match. It is unique per account, so
provisioning the same merchant twice is refused rather than producing two merchants that each
accumulate half a balance — which nothing would ever report, because both would be real.
feeBps is your commission. It is applied at capture as a second, negative entry beside
the credit, so a merchant's statement shows what they took and what it cost them as two lines
rather than one net figure they cannot check. Changing it is not retroactive.
Give each merchant a scoped key
curl -X POST $CHAINOS/v1/payments/keys \
-H "X-API-Key: $CHAINOS_KEY" -H "Content-Type: application/json" \
-d '{ "label": "Corner Shop storefront", "merchantId": "…",
"origins": ["https://corner-shop.example"], "maxAmount": "500000000" }'
Such a key may act for that merchant and for nothing else. It cannot list your merchants, create one, read another's balance, or record a payout. The scope cannot be changed later — re-pointing a key at another merchant would start crediting one merchant's payments to another, using a credential somebody else already holds. Rotation mints a successor with the same scope.
2. Branding: whose page is it?
# Theirs
curl -X POST $CHAINOS/v1/payments/merchants/$MERCHANT/brand \
-H "X-API-Key: $CHAINOS_KEY" -F file=@corner-shop.png
# Yours, once, for the whole account
curl -X POST $CHAINOS/v1/payments/brand \
-H "X-API-Key: $CHAINOS_KEY" -F file=@your-logo.png
PNG, JPEG or WebP, 256 KB or smaller. The merchant's mark leads and yours sits beside it as "Payments by …" — a payer knows the shop and has usually never heard of you, and a page that leads with your brand reads to them as the wrong site.
The type is read from the bytes, not from the Content-Type you send, so relabelling a file
does not get it in. An SVG is a document that can carry script, and this file is served from
the same origin as the page displaying a payment address.
A URL you supplied would need img-src widened to arbitrary hosts on that page, and would
leave whoever controls that host able to change what a payer sees at the moment they decide
whether the page is real.
The checkout carries the merchant's name and logo, yours, and support contacts — and no
identifiers at all. Not merchantId, not accountId, nothing internal. That is enforced by
a test that walks the shape of the payer-facing records and fails the build on a UUID, rather
than by anybody remembering.
3. Take a payment for a merchant
One field. Everything downstream follows from it.
POST /v1/payments/sessions
{ "chain": "bsc", "asset": "USDT", "amount": "100.00", "merchantId": "…",
"reference": "ORDER-88213" }
The session, its receipts, its invoice and every payment_* webhook carry merchantId, and
the branding on the page is resolved from it. A merchant-scoped key may omit the field and have
its own filled in; naming a different merchant is refused.
Everything in Merchant acceptance steps 4 to 7 applies unchanged — the overlay, the webhook verification, fulfil-exactly-once. The difference is that you are now doing it on behalf of somebody, and the merchant's own system may also need telling.
4. The collection pool
Every merchant's payments sweep into your treasury wallets, per chain, per asset. One pool.
There is no per-merchant on-chain segregation, and that is not a limitation you can configure around: per-merchant treasury wallets would mean per-merchant sweep policies, per-merchant gas tanks and a fee floor per merchant that makes small shops uneconomic to serve at all.
So the segregation is in the book, and the book has to be right.
curl $CHAINOS/v1/payments/merchants/$MERCHANT/balances -H "X-API-Key: $CHAINOS_KEY"
{ "success": true, "data": [
{ "chain": "bsc", "asset": "USDT", "owed": "1240.50", "decimals": 18 },
{ "chain": "bsc", "asset": "BNB", "owed": "0.42", "decimals": 18 }
]}
USDT on BSC and BNB are not addable, and the rate that would add them is deliberately not on any path in this product. If you settle with merchants in fiat, that conversion is yours and happens after this book. Putting a rate inside it would make every historical balance depend on when you asked.
What credits a merchant, and what does not
| A confirmed payment | Credits. A detected one does not — it can still be reorganised away |
| The amount that arrived | Credits, including an overpayment. It is the payer's money and belongs to the merchant |
| The wrong asset | Credits nothing. It is swept like any other balance and left unattributed until you decide |
The wrong-asset case is the one that generates arguments. Settling an order against an asset
the merchant never agreed to price would be worse than leaving it unattributed, so ChainOS
does neither automatically. Resolve it with an adjustment once a human has decided what it
was.
5. Settlement policy — the part ChainOS cannot decide for you
POST /v1/payments/merchants/{id}/payouts records a payout. It does not pay anybody.
Calling it asserts that you transferred the money on your own rails, and the only thing it
changes here is the balance.
So you must write the policy yourself. Six decisions, and every gateway makes all six whether or not it writes them down.
5.1 The cycle
When does an obligation become payable? A worked policy:
Payments confirmed before 23:59:59 UTC settle on the next business day at 09:00 UTC.
Crypto has no chargeback and no issuer, so there is no scheme-mandated delay to wait out. The reasons to hold anything at all are reorg risk (minutes, and already covered by the confirmation threshold), refund exposure, and your own operational capacity. If you cannot justify a hold, do not impose one — "T+1 because that is what cards do" is cargo cult.
5.2 The minimum
Below some amount, paying out destroys value.
No payout is made below 50 USDT on Ethereum, 5 USDT on Polygon, Base, BSC or TRON. Balances below the floor roll forward.
Set it from the network fee on the chain you pay out on, not from a round number. Read the
current figure off estimatedSweepFee on /v1/payments/readiness and revisit it when gas
moves.
5.3 The commission, and when it is taken
feeBps is applied at capture, which means a merchant's balance is already net. A separate
invoicing model — gross credit, bill monthly — is possible by setting feeBps: 0 and posting
your own adjustment entries, and it is materially more work to reconcile. Take the
commission at capture unless you have a reason.
5.4 Reserve, if you hold one
5% of each capture is withheld and released 30 days later.
Crypto's refund exposure is real but is not the card industry's. A merchant who ships nothing and disappears leaves you having paid out against payments that were never disputed — nobody can claw them back, so a reserve protects you against a merchant, not against a scheme. Decide whether that is a risk you actually carry before you charge merchants for it.
Implement a reserve as an adjustment entry against the merchant at capture time, and the
release as an equal and opposite one. Do not implement it by paying out less than you recorded
— then the book says you owe nothing and you do.
5.5 The rail
ChainOS has none. Yours is one of:
- On-chain, same asset. A withdrawal from your treasury on the chain you collected on. The simplest and the one your merchants already understand.
- On-chain, different chain. The merchant wants USDC on Polygon and you collected on Base. See Cross-network withdrawals — you are now running a payout float and rebalancing it.
- Fiat. Your existing rails, after your own conversion. The book above stays denominated in the asset; the conversion is a separate record on your side.
5.6 Recording it
curl -X POST $CHAINOS/v1/payments/merchants/$MERCHANT/payouts \
-H "X-API-Key: $CHAINOS_KEY" -H "Content-Type: application/json" \
-d '{ "chain": "bsc", "asset": "USDT", "amount": "1000.00",
"externalReference": "TRF-2026-0142" }'
externalReference is required and is the idempotency key. Sending it twice records one
payout, so a retry after a timeout is safe. Without it, a merchant disputing a payout has
nothing to quote and you have nothing to prove.
Record the payout after the transfer succeeds, not before. A recorded payout that never went out leaves a merchant short with a book that says they were paid — the hardest class of discrepancy to find, because every number is internally consistent.
6. A worked month
Corner Shop, feeBps: 250, settling daily above a 5 USDT floor, no reserve.
| Day | Event | Entry | Balance |
|---|---|---|---|
| 1 | Order confirms, 100.00 USDT | capture +100.00 | 100.00 |
| 1 | Your commission | fee −2.50 | 97.50 |
| 1 | Order confirms, 40.00 USDT | capture +40.00 | 137.50 |
| 1 | Commission | fee −1.00 | 136.50 |
| 2 | You transfer 136.50 on chain, then record it | payout −136.50 | 0.00 |
| 3 | Order confirms, 3.20 USDT | capture +3.20, fee −0.08 | 3.12 |
| 4 | Below the floor — nothing paid | — | 3.12 |
| 5 | A day-1 order is refunded by the merchant | reversal −97.50 | −94.38 |
A balance can be negative, and reporting it as zero would hide the discrepancy in the one place somebody would look. Day 5 is the case every gateway eventually meets: money was paid out on day 2 against a capture that was undone on day 5. Recover it from future captures, or invoice the merchant; either way the book says what is true in the meantime.
7. Reconciliation
Two numbers are maintained on our side: the running balance, and the entries it came from. They are written in the same transaction and compared hourly.
That redundancy is the whole point. A balance computed on demand is always self-consistent — it is whatever the sum says, including when a writer got it wrong. Two numbers arrived at independently disagree when something is wrong, and nothing else in the system would ever reveal a payment credited to the wrong merchant.
A mismatch is logged at ERROR and never repaired automatically. Overwriting the stored figure with the sum would destroy the only evidence of which writer was wrong. Post an adjustment once you know the cause.
On your side, two checks, daily:
Per merchant, per asset: does your own record of what you owe them equal owed here? Join
on externalRef, never on name. A drift means one of the two books has a write path the other
does not.
Across the pool: does the sum of every merchant's owed, plus your accumulated commission,
plus anything unattributed, equal what is actually in your treasury wallets — allowing for
unswept addresses and in-flight transfers? This is the solvency check, and it is the one a
regulator will ask about.
Σ merchant owed + your fee income + unattributed
== treasury balances + unswept payment addresses − payouts in flight
8. Suspending and closing
PATCH /v1/payments/merchants/{id}/status
{ "status": "suspended" }
Suspension stops new checkouts and nothing else. Every session already open stays payable to the end of its monitoring window — a payer who broadcast ninety seconds before you suspended their merchant has sent real money and something still has to credit it. Refusing it would strand those funds at an address nothing reconciles.
There is no delete. A merchant that has taken a payment cannot be removed: the receipts
that say what it was owed outlive it, because a merchant disputing a final balance needs those
rows more than you need a tidy table. Use closed.
9. What to build on your side
| Merchant onboarding, KYC/KYB, and whatever your regulator requires | Yours |
| The merchant's dashboard | Yours — ChainOS has no merchant-facing surface |
| The settlement engine that decides when to pay | Yours, from §5 |
| The transfer itself | Yours |
| Address issuance, monitoring, confirmation, invoicing, the obligations book | ChainOS |
The honest summary: ChainOS gives you the acceptance side and the book. The money movement to your merchants, and the licence that lets you hold their funds while you owe them, are yours.
Next
- Sub-merchants — the reference page for provisioning and scoping.
- Settlement and payouts — every entry type and what writes it.
- White-label gateway — the same business on a page your merchants believe is yours.