A crypto payment gateway accepts cryptocurrency for an order: it issues an invoice, watches the chain, matches the amount, then tells the shop with a callback. Peiko built a watch-only one — eight services, five chains, eleven watcher processes, three confirmations by default — with no spending keys in the watching plane.
Read in order the first time. Early sections set the product story. Invoice and architecture are the heart of the build. Incidents and the pending runbook are for when something is already on fire.
| Phase | Section | What you are doing |
|---|---|---|
| 1. Picture | Gateway, watch-only, accept | Hear what this product is |
| 2. Choose | Network, build vs buy | Decide scope before coding |
| 3. Build | Invoice, lifecycle, eight services | See the machine |
| 4. Operate | Cost, fees, incidents, API | Keep it alive |
| 5. On-call | Pending runbook, go-live, FAQ | Debug and ship |
- Explain watch-only vs custodial — a leaked API key should not drain funds.
- Merchant path: invoice → exact
payment_amount→ signed callback → ship only on status 2. - Bitcoin uniqueness is a new HD address; USDT on a shared address uses an amount tail — do not round the QR.
- Sender pays gas; gateway commission is a separate tariff; only the orchestrator marks an invoice paid.
- Pending runbook starts with ingest and
from_pythondepth, not “is Next.js up.”
Written for engineering leads who are building a gateway or reviewing a build. Same topic as our notes on Web3 ecommerce payments and Node.js backends in fintech, but this one is the machine we actually run.
Who this article is for
| You are… | Read this | Skip to a SaaS plugin |
|---|---|---|
| Building or reviewing a crypto payment gateway | Yes | — |
| Integrating an invoice API (key, QR, callback) | Start at “How to accept crypto payments on a website” | — |
| Picking USDT TRC-20 vs ERC-20 vs Bitcoin | “Which network to turn on first” | — |
| Need a hosted checkout this week | — | Yes. This is eight services, not a button. |
What is a crypto payment gateway
In plain language, a crypto payment gateway is a closed loop: the store hits your API, you return an address and an exact payment_amount, the buyer pays from their wallet, you watch the chain, and you fire a callback when the transfer matches. Unlike cards, a TRC-20 transfer does not reverse with a bank button.
A crypto payment gateway — crypto processing — is the loop: store hits the API, we return an address and an exact payment_amount, the buyer pays from their wallet, we watch the chain, we fire a callback. Unlike cards, a TRC-20 transfer does not reverse with a bank button. Three Bitcoin confirmations are tens of minutes; the same integer on Solana is a different bet.
Watch-only vs custodial
We chose watch-only so a leaked store API key is an address-map and callback problem, not a drained hot wallet. The merchant keeps spending keys. We see deposits; we never sign withdrawals inside the watching plane. Custodial gateways hold those keys and can auto-payout — faster UX, larger blast radius.
We went watch-only so a leaked API key is an address-map and callback problem, not stolen funds.
| Watch-only (this build) | Custodial | |
|---|---|---|
| Spending key | Merchant keeps it. We never see it. | Gateway holds it and signs withdrawals. |
| Bitcoin intake | xpub / ypub / zpub, HD receive chain | Hot wallet on the server |
| Other chains | Watched list or deposit pool | Same keys as spend |
| Withdrawal | Ticket: moderation → operator sends outside | Auto-payout on-chain |
| If the API key leaks | Address map + swapped callback | Funds can leave |
| UX | Merchant must show exact amount | Often simpler “pay” button |
Bitcoin: merchant gives xpub / ypub / zpub (BIP44/49/84), we derive receive, skip index 0, ignore change. Other chains: a watched list or a deposit pool.
We also run a wallet dashboard on the same MySQL and the same watcher. Merchant flow is invoice → webhook. Dashboard flow is history, tickets, withdrawals we do not sign. Mix them once — we did, on a “top up wallet” shortcut — and external_id vanishes.

How to accept crypto payments on a website
This is the path a shop follows without the eight-service diagram first. API key on the server, never in React. Create an invoice, show the exact amount, let the buyer pay gas, verify a signed callback, ship only on status 2.
- Issue a store API key (2FA on before production).
POST /api/v2/addresswithexternal_id, fiat or crypto amount,payment_currency,chainif the coin exists on more than one network.- Persist
address,payment_amount,external_id, chain, expiry. Show that amount in the QR. No rounding. - Buyer pays from their wallet. They pay network gas. You do not.
- Receive callback
status0 → 1 → 2. HMAC or a header secret, then match addr / value /external_idto your copy. - Ship the goods only on status 2. Ignore a repeat
txid.
Which network to turn on first
USDT TRC-20, USDT ERC-20, and Bitcoin are not three rows in one dropdown. They are three scanners, three gas models, and three ways to issue an address. Network gas is paid by the sender; gateway commission is a separate tariff.
USDT TRC-20, USDT ERC-20 and Bitcoin are not three rows in one dropdown. They are three scanners, three gas models, three ways to issue an address.
| USDT TRC-20 | USDT ERC-20 | Bitcoin | |
|---|---|---|---|
| What shops usually want | Cheap stablecoin | Same ticker, Ethereum gas | Hard money, slower |
| Address | Often shared store / pool | Shared | New HD child per invoice |
| Match key | Amount tail (100.00 vs 100.01) | Amount tail | The address itself |
| Confirmations | Minutes; 3 is conservative | Minutes | Tens of minutes at 3 |
| Gotcha | Wrong chain to a look-alike address | Gas spikes | Rate drift vs an exchange screenshot |
If you take one chain live first, take USDT TRC-20 for checkout volume, Bitcoin if the audience actually holds BTC. ERC-20 USDT is a different product, not an alias.
Network gas ≠ gateway commission. The sender pays gas to the chain. Our fee is a tariff on the service balance — that is the 401 when coins are still on the address.
Build vs buy
Buy when you need hosted checkout this quarter. Build when the merchant must keep the keys, you need amount tails or an xpub watcher, or the callback must match your external_id. Cost is eight services and on-call on ingest — not a plugin license.
Buy
Coinbase Commerce, BitPay, and the rest — when you need a hosted checkout this quarter and you can live with their coins, KYC and callback contract.
Build
This article: the merchant must keep the keys, you need invoice tails / xpub / a five-chain watcher, or the callback has to match your external_id and accounting. Cost is eight services, on-call on ingest, and the list in “After go-live”.
We have done both shapes. This write-up is the second.
How a crypto payment gateway invoice works
This is the heart of the money-in path. Issue an invoice with a unique external_id, freeze a rate, publish new_address so the watcher starts caring, then match on-chain by address and — on shared addresses — by an amount tail. Only the orchestrator marks the invoice paid.
Merchant binds an xpub. A tiny Node service (batch of 50 children, skip 0) answers whether the sample address belongs to that tree. We almost put that check in PHP. One BIP path off and you attach someone else’s tree. The generator stays the only HD source, and it stays off the public internet — an xpub is a map of future addresses.
Invoice comes in with external_id unique per store. Next HD child. Fiat amounts use a rate we refresh every 10 minutes; freeze it for the invoice or the buyer will screenshot an exchange and open a ticket. If the address already has a pending bill, we add a unique amount tail. Then new_address into RabbitMQ crypto. Until the watcher inserts the row, the chain transfer does not exist for us.
Watcher: ZMQ rawtx on Bitcoin, JSON-RPC transfer(address,uint256) on ETH/BSC, socket subscriptions on Solana, block batches on Tron. Event goes to from_python. Orchestrator matches address, time window, amount. Percent tolerance only when there is a single pending invoice. Several pending → exact amount plus tail.
EVM, Solana and Tron skip HD. Round-robin store addresses or a free pool slot. BNB sometimes needs a memo. The watcher logs every ERC-20 by selector, not a USDT allowlist. After the dashboard filled with junk tokens, coin filtering moved into the orchestrator.
A worked pass for order 4821, $100 USD as USDT TRC-20. Two pending bills already sat on the store address, so the second one got a tail:
POST /api/v2/address
Authorization: Bearer <store_api_key>
{
"external_id": "4821",
"origin_amount": 100,
"origin_currency": "usd",
"payment_currency": "usdt",
"chain": "trx"
}
{
"address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"payment_amount": "100.01",
"payment_currency": "usdt"
}
That 100.01 is the extra payment amount loop in DetailsPayment: while a pending invoice already owns that address + amount + coin, add extra_payment_amount (for stables, one cent). Then we publish to RabbitMQ queue crypto:
content_type: new_address
{"address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", "chain": "TRX"}
Until the watcher consumes that, an on-chain transfer is invisible to us. After confirmations the callback looks like this (status: 0 unconfirmed, 1 partial, 2 confirmed):
{
"addr": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"status": 2,
"txid": "0c3e…",
"value": "100.01",
"external_id": "4821",
"memo": null
}
GET or POST depends on the store flag is_post_callback. We still have shops that round the QR to 100 and then wait forever.
payment_amount from the API response.Bitcoin is the other model. New invoice, new HD child. No tail, because uniqueness is the address.
Merchant binds a zpub (BIP84, bc1…, path m/84'/0'/0'/0/n). We skip index 0 so it does not collide with the sample they pasted. Change (path 1) is unused. Node generator, batch 50, is the only place that derivation happens. Then:
POST /api/v2/address
{
"external_id": "4821",
"origin_amount": 100,
"origin_currency": "usd",
"payment_currency": "btc"
}
{
"address": "bc1q…n",
"payment_amount": "0.00115200",
"payment_currency": "btc"
}
Watcher side is ZMQ rawtx, not JSON-RPC logs. chain on the broker message is BITCOIN. Freeze the fiat rate for the invoice lifetime or the buyer screenshots an exchange ten minutes later and opens a ticket — rates refresh on a 10-minute cron.
Why a crypto invoice stays pending
Two clocks run at once: invoice pending → completed or expired, and on-chain confirmations as callback status 0 / 1 / 2. A transfer into an expired invoice is a ticket, not auto-complete.
The invoice is pending → completed or expired (0 / 1 / 2 in our table). The on-chain tx is unconfirmed → partially_confirmed → confirmed, which is what the callback sends as status 0 / 1 / 2. Those clocks are not the same.
Expiry is a job every 6 minutes. Buyer can still push a transfer after we flipped the bill to expired. Coins are on-chain. Invoice will not auto-complete. That is a ticket, not a second completed.
We mark completed on product policy, usually when the tx hits confirmed (default 3). Repeating the same txid must update confirmations. It must not fire PaymentTransactionCreated again.
Crypto payment gateway architecture we run
Eight services: two Next.js apps (merchant and admin, separate cookies), Laravel orchestrator (~139 MySQL migrations), a second Laravel for the public API with no migrations of its own, Django watcher (11 containers, five scanners), Node generator, WordPress, RabbitMQ, a host Telegram bot.
You can put 12 workers on the API. Ingest of from_python is still one process. from_python (the RabbitMQ routing key the Django watcher publishes detected txs onto — JSON like new_transaction / update_transaction — which a single Laravel ingest process consumes). We learned that the loud way.
Laravel’s scheduler in this repo is often empty. Cron lives in the image: rates, invoice expiry every 6 minutes, pool TTL every minute, unused BTC prune every 30. Default confirmations: 3, for every chain. Amounts: decimal(36, 18) the whole way through PHP, Python and JSON. Floats clip USDT.
How much it costs to build a crypto payment gateway
The cost is those eight services, not a checkout plugin. What moves the number is how many chains you watch, whether ingest and prune are real, and whether merchant and admin stay separate products.
The cost to develop a crypto payment gateway is the eight services, not a checkout plugin. We shipped two Next.js dashboards (merchant and admin, separate cookies), a Laravel orchestrator with about 139 MySQL migrations, a public API with no schema of its own, a Django watcher — 11 processes across 5 chains — a Node HD generator, WordPress, RabbitMQ, a host bot. That list is the bill.
What moves it. Chains: Bitcoin is an HD tree and a ZMQ scanner; USDT TRC-20 is a shared address plus an amount tail; the same ticker on ERC-20 is a third scanner. Five chains is five of those jobs, not five rows in a dropdown. Watcher processes: eleven containers, and ingest of from_python is still one process — that one going down is an outage even when the dashboard is green. Two dashboards: merchant flow is invoice → webhook; admin is tickets and withdrawals we do not sign. Mixing them once cost us external_id.
Adding a chain looks cheaper than keeping five alive. It is not, if you skip the eight touchpoints in How to add a network to a crypto payment gateway. A coin in the admin list with no scanner is invoices that never close — we have reviewed that miss. The quiet cost is ingest alerts, prune that actually publishes del_address, pool TTL, rates every 10 minutes, callbacks on their own queue. A one-chain MVP and this five-chain build are different products.
How fees, rates and withdrawals work
Four pieces get mixed into “the balance”: deposit pools, rates, commission tariff, and withdrawal tickets. Withdrawals are moderated — we do not sign; see the withdrawals article for create without debit and approve that moves fiat.
Four operational pieces people mix into “the balance”.
Deposit pool. For non-Bitcoin, addresses sit free → busy with expired_at. A monitor runs every minute. There is a 15-minute buffer before we treat a slot as reusable; Solana’s default window in settings is about 30 minutes. An expired TTL means the address is no longer unique for the current bill. If the pool is empty, the API throws “free address not found” — that is an ops problem, not a chain outage.
Rates. Fiat → crypto every 10 minutes from a pivot table. Stables get round() to a whole unit unless a tail is applied, which is why a USDT QR can show 100 or 100.01. Pass payment_amount yourself if you already converted. Otherwise freeze our rate for the invoice window.
Commission. Listener on PaymentTransactionCreated. Percent or flat, in USD tariff units, consumeBalance on the user, a second row with type = commission. Gas is paid by the sender. Repeat the same incoming txid and you should not insert a second commission. That listener is why a negative service balance returns 401 while 100 USDT still sits on the address.
Withdrawal. Watch-only: we do not sign. Ticket statuses moderation → in-progress → success or declined. An operator moves funds outside this plane. If you need auto-payout, that is a different product with a hot wallet we deliberately did not put next to the watcher.
What broke in our crypto payment gateway
Incidents we still talk about: ingest missing while the dashboard looked fine, callbacks inside the ingest loop, two Laravels drifting, stub prune, sandbox on the live queue, one balance widget hiding a negative fee, rounding, same confirmations everywhere, amount-tail races.
These are the ones we still talk about internally.
Dashboard was green. Payments were not. Ingest was missing from supervisor. Support saw history in the UI from an old sync and thought the chain was fine. First question on “I don’t see the money” is no longer “is the site up”. It is “is ingest running, and how deep is from_python”. We alert on both.
Callback lived inside the ingest loop. Synchronous HTTP to the merchant. One shop timed out, the whole confirmation feed stalled, coins sat on-chain, invoices stayed open. Callbacks belong on their own queue with retries. We still find the old pattern in similar reviews.
Two Laravels drifted. Only the orchestrator has migrations. POST callbacks, amount tolerance and stablecoins landed there first. API kept writing the old invoice shape. Deploy the API before the migration and create-invoice dies. We now ship the schema change and the twin models in one release. The grown-up fix is a shared domain package. We have not extracted it yet. Until we do, every CVE patch is twice the work.
Prune was a stub. The job returned immediately. Solana subscriptions piled up and hit connection limits while the product still looked healthy. Prune has to publish del_address and actually run.
Sandbox closed live invoices. A “test payment” published new_address onto the production crypto queue. Staging must never touch that queue.
Support: API 401, 100 USDT TRC-20 sitting on the address. Three numbers. On-chain. Store ledger (can lag a sync). Service fee balance in tariff units — that one going negative is what returns 401. The coins did not vanish. We split the widgets. One “total” hid the cause for months.
Rounding. Merchant UI rounded USDT to whole units. Invoice pending forever, money already on the address. The other way: a few extra cents, tolerance on, one pending bill — we closed the wrong invoice. Checkout copy is one line: send the amount from the response, no rounding.
Same 3 confirmations on BTC, EVM and Solana. Convenient. Wrong in a merchant contract, especially on large cheques. Policy has to be per chain.
Race on the tail. Two invoices, one EVM address, no row lock, same extra amount. Match is exact tails; without a unique index on an open amount you collide.
A few more we treat as debt, not theory: API keys are 40-character plaintext; the generator we inherited has no auth; migrate on Docker build can shift the database when you publish an image. Generator stays on the internal network. We do not call the public stubs that always answer “valid”.
And we do not mark an invoice paid inside the scanner. Scanner says: address appeared, tx is new, confirmations grew. Orchestrator matches. Otherwise tails and fees invent a second ledger.
Adding a chain is eight touchpoints — catalog, handler, address issue, chain code, scanner, event JSON, dashboard, vectors. Skip one and you get a coin in the dropdown that never closes. That list is the next section.
How to add a network to a crypto payment gateway
Adding a chain is eight touchpoints — catalog, handler, address issue, chain code, scanner, event JSON, dashboard, vectors. USDT on ERC-20, BEP-20, and TRC-20 are three of those lists, not a dropdown alias.
If you want to know how to build a crypto payment gateway that can grow, adding a chain is the test of whether we keep service boundaries. The miss we have reviewed: currency in the admin list, no scanner, invoices pending forever.
- Currency catalog in the orchestrator:
iso_name, decimals, parent for a token, contract, stablecoin flag, fiat rate row. - Handler factory: parse the tx, memo fields, amount as
decimal— notfloat. - Address issue in the public API: HD, store address, or pool. Pick it on purpose.
new_addresswith achainstring the watcher understands one-to-one (BITCOIN,TRX, …).- Scanner process in compose, block cursor, idempotent writes.
- Same JSON on
from_pythonthe ingest command already expects (transaction,to_addr,value,input_msgfor tokens). - Dashboard icon, store form, admin toggle.
- Vector table: fixed txids, amounts, a reorg. Without it the chain “kind of works” on staging.
USDT ERC-20, BEP-20 and TRC-20 are three of these lists, not a dropdown alias.
How to connect a crypto payment gateway API
The contract is POST /api/v2/address behind the store API key. Persist the response, show the exact amount, verify a signed callback, close only on status 2. Sandbox must never publish new_address to the live crypto queue.
The contract we actually ship is POST /api/v2/address behind the store API key (api_key + api_closed middleware). v1 “just give me an address” should 410. Key stays on your server — not in React.
Persist what you got back: address, payment_amount, external_id, chain, expiry. Show that amount in the QR. A handler that does not invent a second source of truth looks like this:
// callback URL — HMAC or a header secret first
$saved = Order::where('external_id', $payload['external_id'])->firstOrFail();
if ($payload['txid'] && $saved->txid === $payload['txid']) {
return; // already processed
}
$ok = hash_equals($saved->address, $payload['addr'])
&& $saved->payment_amount === $payload['value']
&& (int) $payload['status'] === 2;
if ($ok) {
$saved->markPaid($payload['txid']);
}
Same idea in a Node shop: compare strings, do not parse USDT as Number. decimal(36, 18) on our side dies the moment your JSON stack turns 100.01 into 100.009999.
Rotate the key. Only the store owner changes the callback URL — a stolen key plus a swapped URL is someone else’s “paid” events. Sandbox must not publish new_address to the live crypto queue; we burned that once.
Why a crypto payment is pending
On-call order we actually use. Dashboard HTTP 200 is check zero, not the finish line. Start with ingest and from_python depth, then scanners, watchlist, amount/chain, expiry, three balances, callbacks, and sandbox contamination.
- Ingest command in supervisor? How deep is
from_python? - Which of the five chain containers died?
- Was
new_addresspublished? Is there a row in the watcherAddresstable? - On-chain amount vs
payment_amount. Wrong chain, rounded QR, missing BNB memo. - Did the 6-minute expiry job already close the invoice?
- Which of the three balances are they looking at? Negative service fee → 401 by design.
- Callback
log_lineon the payment tx? GET vs POST, merchant timeout, no HMAC. - Did a sandbox payment hit the live
cryptoqueue?
If 1–3 are healthy and 4 mismatches: do not auto-complete. Coins moved. That is a ticket.
After go-live
What still has to be true on a quiet Tuesday: ingest in supervisor, prune that publishes del_address, sandbox isolation, frozen rates, queued callbacks with HMAC, key rotation, three balance widgets, withdrawals as tickets.
The runbook above is for incidents. This is what still has to be true on a quiet Tuesday.
- Ingest is in supervisor. Alert on
from_pythondepth, not only dashboard HTTP. - Address prune actually publishes
del_address. - Sandbox cannot write to the live
cryptoqueue. - Invoice rate is frozen or the UI shows tolerance. QR shows
payment_amountwith no rounding. - Callbacks are queued with retries, HMAC, idempotent on
txid. - API key rotation exists. Only the owner changes the callback URL.
- Three balances are three widgets. Commission is not gas.
- Withdrawal is a ticket. Nobody expects the watcher to sign.
No comments yet. Be the first to comment!

