Skip to main content

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

One confirmed payment splits two ways: the coins sweep into your single treasury pool, and a per-merchant book records the capture, the fee and later the payout.

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.

SVG is refused, and logos are uploaded rather than linked

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 }
]}
Balances are per asset, never one number

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 paymentCredits. A detected one does not — it can still be reorganised away
The amount that arrivedCredits, including an overpayment. It is the payer's money and belongs to the merchant
The wrong assetCredits 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.

DayEventEntryBalance
1Order confirms, 100.00 USDTcapture +100.00100.00
1Your commissionfee −2.5097.50
1Order confirms, 40.00 USDTcapture +40.00137.50
1Commissionfee −1.00136.50
2You transfer 136.50 on chain, then record itpayout −136.500.00
3Order confirms, 3.20 USDTcapture +3.20, fee −0.083.12
4Below the floor — nothing paid3.12
5A day-1 order is refunded by the merchantreversal −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 requiresYours
The merchant's dashboardYours — ChainOS has no merchant-facing surface
The settlement engine that decides when to payYours, from §5
The transfer itselfYours
Address issuance, monitoring, confirmation, invoicing, the obligations bookChainOS

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