Most guides to taking crypto in a web shop start with "sign up for a processor". You paste an API key, the processor hosts the checkout, and the money lands in an account they control until they pay you out. That works, but it means an application, a review, a fee on every sale and a third party that can freeze the balance.
I wanted the opposite: a checkout that runs on a box I own, where the coins go straight into my own wallet and nothing in the middle can hold them. That's what we built with Coldtill, and this post walks through how that kind of setup works and what to watch out for when you wire it into a shop.
What "self-hosted" and "non-custodial" actually mean
They get used interchangeably, but they're different promises.
- Self-hosted means the software runs on your infrastructure. Your server, your database, your logs. Customer data never leaves it.
- Non-custodial means nobody but you controls the funds. Not the software vendor, and ideally not even the server the software runs on.
The second one is the hard part. A gateway can be self-hosted and still keep a hot wallet full of customer payments on the same machine that faces the internet. If someone breaks into that machine, the money goes with them.
The architecture
Coldtill ships as Docker containers and runs on an ordinary Linux VPS. Two vCPU and 4 GB of RAM is comfortable, on amd64 or arm64. Under the hood it's split into separate processes that share a PostgreSQL database and never call each other directly:
- api: the merchant REST API, the dashboard and the hosted checkout page. This is the only part exposed to the internet, and it holds no private keys.
- watcher: follows the chains, counts confirmations, handles reorgs and sends your webhooks.
- sweeper: moves funds from deposit addresses to your cold wallet.
- payouts: signs approved refunds and payouts from a small hot wallet.
The processes that hold keys accept no network connections. So the piece an attacker can reach first has nothing worth stealing.
The other half of the design is where deposits go. Every customer gets their own deposit address. On EVM chains, Tron and Solana, that address is a contract that can only send to your cold wallet. Bitcoin addresses are derived from your own wallet's public key. The server can't change the destination, which means a compromised server still can't redirect a payment.
The payment flow
From your shop's point of view it's four steps:
- Your backend calls
POST /api/v1/paymentwith an amount and your order ID and gets back a checkout URL. - You redirect the customer there. They pick a coin and network, scan a QR code and pay.
- The watcher waits for confirmations and sends your shop a signed webhook.
- Later, the sweeper moves the funds to your cold wallet.
Out of the box it handles native coins and USDC/USDT on the main chains. If you turn on auto-convert, customers can pay with 200+ coins across 35+ chains and you still receive a stablecoin, so you never end up holding a token you didn't choose.
Handling the webhook (the part people get wrong)
Webhooks are where shops usually get burned: they trust the JSON body, parse it before verifying, or mark an order paid twice because a delivery was retried. The rules are boring but important:
- Read the raw body before any JSON parsing.
- Reject timestamps that are too old, so a captured request can't be replayed.
- Recompute the HMAC over the timestamp and body and compare it in constant time.
- Treat events as idempotent. Retries happen, and ordering isn't guaranteed.
- Decide what to do from the signed body, not from an unsigned header.
Here's an illustrative Express handler in TypeScript. It follows the signing scheme described in Coldtill's webhook docs, but check the docs for the current header names and payload before you copy it into production, and prefer the official SDK's verifier if you're on Node.
// Illustrative only. See the webhook docs for the current scheme.
import express from 'express'
import crypto from 'node:crypto'
const app = express()
const SECRET = process.env.COLDTILL_WEBHOOK_SECRET!
app.post('/coldtill/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const ts = String(req.headers['x-coldtill-timestamp'] ?? '')
const sig = String(req.headers['x-coldtill-signature-v2'] ?? '').toLowerCase()
// 1. Reject stale or malformed timestamps (five-minute window).
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.sendStatus(401)
}
// 2. HMAC over "<timestamp>.<raw body>", compared in constant time.
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(`${ts}.`)
.update(req.body as Buffer)
.digest('hex')
const ok = expected.length === sig.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
if (!ok) return res.sendStatus(401)
// 3. Only now parse, and act on the signed body.
const event = JSON.parse((req.body as Buffer).toString('utf8'))
if (event.status === 'FILLED' || event.status === 'OVER_FILLED') {
await markOrderPaid(event.invoice_id) // must be idempotent
}
res.sendStatus(200)
})
Make markOrderPaid safe to call twice. A unique constraint on the order ID or the delivery ID is usually enough.
Things to sort out before you take real money
A self-hosted crypto payment gateway puts operations on you, so a short checklist:
- Run it on its own server, not next to your shop. A hole in your storefront shouldn't reach the hot wallet.
-
Keep the dashboard off the public internet. Put the checkout on a public subdomain like
pay.yourshop.comand the admin behind a VPN such as Tailscale, or at least a firewall rule. - Back up the database and take provider snapshots.
- Test on testnet first and send yourself a test webhook before you flip to mainnet.
Pricing, for the curious
The Free edition takes a 2% fee, Standard is $499 one-time with a 1% fee, and Pro is $999 one-time with no fee. Bitcoin and auto-converted payments carry no fee on any edition. There's no licence server; it keeps running even if our site goes down. The WooCommerce plugin is MIT-licensed on GitHub; the core app is proprietary under an EULA.
If you want to poke around before installing anything, there's a live demo dashboard with made-up data linked from coldtill.com. Questions about the architecture are very welcome in the comments.


Top comments (0)