Payments Guide

Bolt a pay page onto any domain. Card, Lightning, Bitcoin, stables, Ark, Cashu. Sweep in the background to whatever you actually hold.

Most internet sales are small. At that size a hot wallet on a VPS is a cost of doing business. You sweep on settle. You do not store life savings next to the shop.

The shop does one job: take the money the customer already holds, unlock the file, and convert in the background. The customer never sees the sweep. Price, format, how to pay.

Copy any block. Change the names. This is the shape running on this site.

The tiles

Show the wallets people hold. Hide the plumbing.

Card · Lightning · Ark · Cashu · Bitcoin · Stables

TileCustomer paysYou receiveThen
CardStripe CheckoutUSDleave it
LightningQR onlyLightningalready home
BitcoinSilent Payments QR, bc1p fallbackUTXO in a bridge walletsubmarine swap to Lightning
Stablesnetwork pickerUSDT / USDC / the next brandswap to BTC or LN
Arkunique tark1 / ark1 QRVTXO in Barksend Lightning
CashuNUT-18 payment requestmelt to Lightningalready Lightning

Stables are one tile with a dropdown. The dropdown is the rails table in env. Add USDC on Base by adding a row. An altcoin is the same row with a different ticker.

Machines

  • App VPS — site, Stripe webhook, hot wallets, swap queue. Short-lived coins. Sweep on settle.
  • Lightning destination — a Lightning Invoice from your own node is the gold standard, use a lightning address if your swap provider can pay to it and you want to avoid having another machine live on the internet.
  • Your local machine — generates keys, papers them, deploys.
  • Not on the VPS — hardware wallets, node hsm_secret, mail passwords, bank.

Rails table

Every crypto method is one row. Receive-only on the shop host. Spend keys stay on the VPS.

# rails.env

CARD=stripe
[email protected]

# Bitcoin
BTC_SP=sp1q…
BTC_FALLBACK=bc1p…

# Stables: id | kind | address | decimals | asset_or_contract | chain_id
# kind = evm | tron | liquid | other
STABLE_USDT_ETH=evm,0xYourEvm,6,0xdAC17F958D2ee523a2206206994597C13D831ec7,1
STABLE_USDT_POLYGON=evm,0xYourEvm,6,0xc2132D05D31c914a87C6611C10748AEb04B58e8F,137
STABLE_USDT_ARB=evm,0xYourEvm,6,0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9,42161
STABLE_USDT_TRON=tron,TYourTron,6,TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t,0
STABLE_USDT_LIQUID=liquid,lq1qqYourLiquid,8,ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2,0

# Add a stable: copy a line, change id, contract, chain.
# STABLE_USDC_BASE=evm,0xYourEvm,6,0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913,8453

# An alt is the same row.
# ALT_LTC=other,ltc1q…,8,LTC,0

The pay page reads the table and draws the dropdown. The sweeper reads the same ids and asks the queue for a pair. When a government or a bank ships a coin on a chain you already watch, you add a line. You do not rewrite the shop.

Domain and HTTPS

pay.example.com          Stripe Checkout only
example.com              the shop
example.com {
    encode gzip
    handle /pay/* {
        reverse_proxy 127.0.0.1:8080
    }
    file_server
}

pay.example.com {
    reverse_proxy 127.0.0.1:8081
}

The pay host creates Stripe sessions. The shop host never sees the Stripe secret. They share an HMAC so a paid return can mint a cookie.

Card

STRIPE_SECRET_KEY=sk_live_…
STRIPE_WEBHOOK_SECRET=whsec_…
PAY_HMAC=<32-byte hex>

Checkout Session: the product title only, product price, success URL https://example.com/pay/return?g={grant}. Webhook checkout.session.completed marks paid. The return URL is a second path so a closed webhook still unlocks.

Lightning

If you are using a Lightning Address instead of your own node, show a QR. Do not print the Lightning Address in the page JSON.

LNURL-pay at https://{domain}/.well-known/lnurlp/{user} must 200 JSON or the sweep skips.

Bitcoin

One seed, two receive paths.

  • Silent Payments (BIP-352): sp1q… per checkout.
  • BIP-86 Taproot fallback: unique bc1p. BIP-321 URI with sp= only. Do not put bc1 in the same URI.
python3 -c "import os; print(os.urandom(32).hex())"

Paper it. VPS env holds the seed. Derive m/352h/0h/0h/0h/0 spend, m/352h/0h/0h/1h/0 scan, m/86h/0h/0h for bc1p. Fulfill at 0-conf. Sweep after 1 conf.

Pin the BTC-USD rate for the session (thirty minutes is enough). Do not add a unique-sats pad on a fresh address — the customer should see the same quote when they switch tiles.

Hot wallets

One key per family, not per customer.

EVM. One secp256k1 key. Same 0x on Ethereum, Polygon, Arbitrum, Base, and the next EVM a state coin launches on.

python3 - <<'PY'
import os
from coincurve import PrivateKey
from Crypto.Hash import keccak
raw = os.urandom(32)
pub = PrivateKey(raw).public_key.format(compressed=False)[1:]
k = keccak.new(digest_bits=256); k.update(pub)
print("addr", "0x" + k.digest()[-20:].hex())
open("evm.hex","w").write(raw.hex()+"\n")
PY

TRC-20. Same curve, Tron address (T…).

Liquid. BIP32 coin type 1776 plus a blinding key. Confidential lq1qq…. Keep the wallet when Liquid is ugly.

Paper every hex. Mode 700 on the directory, 600 on the files. Never git. The shop env is receive-only. The VPS env has the spend hex.

Matching inbound: EVM Transfer logs, TronGrid TRC-20, Liquid Esplora by asset id. Confidential Liquid amounts need the blinding key to auto-match.

Autoswap queue

On paid: settle → hot wallet already has it → round-robin swap queue → dest. Cron every ten minutes is catch-up.

# queue.py — failed names stay in the list. Cursor on disk.

BOLTZ_V2 = {
    "satsrouting": "https://api.satsrouting.exchange",
    "zeus": "https://swaps.zeuslsp.com/api",
    "boltz": "https://api.boltz.exchange",
}

def parse_queue(raw: str) -> list[str]:
    return [p.strip().lower() for p in (raw or "").split(",") if p.strip()]

def walk(names, start):
    n = len(names)
    start = start % n
    return [names[(start + i) % n] for i in range(n)]

def try_round_robin(names, state_path, fn):
    state = json.loads(state_path.read_text()) if state_path.is_file() else {}
    start = (int(state.get("cursor", -1)) + 1) % len(names)
    errors = []
    for i, name in enumerate(walk(names, start)):
        try:
            result = fn(name)
        except Exception as exc:
            errors.append(f"{name}: {exc}")
            continue
        state["cursor"] = (start + i) % len(names)
        state_path.write_text(json.dumps(state))
        return result, name
    raise RuntimeError("all providers failed: " + "; ".join(errors))
SWAP_PROVIDERS=satsrouting,zeus,lightning-swap,fixedfloat,sideshift,boltz
TREASURY=lightning:[email protected]

A $1 sale is below many floors (SATS Routing 50k sats, SideShift ~$7.56 USDT, Tron fees). Batch until the floor, then one swap.

Boltz v2 submarine (chain → Lightning), used by SATS Routing and ZEUS:

POST {base}/v2/swap/submarine
{"invoice": "<bolt11>", "to": "BTC", "from": "BTC", "refundPublicKey": "<hex>"}

Resolve your Lightning Address to bolt11 first. Start the claim waiter before you pay a hold invoice.

Instant exchangers (FixedFloat, Lightning Swap, SideShift): they give a deposit address. Your hot wallet pays it. They pay your dest. Custodial for minutes. Fine for a dollar batch.

ERC-20 send from the VPS when a deposit address exists:

  • data = a9059cbb + padded to + padded amount
  • to = the token contract
  • value = 0
  • EIP-155 v = recovery_id + chainId * 2 + 35

Never log the privkey. Kick on settle with a daemon thread. Cron is the net.

Ark and Cashu

Assume customers arrive with Bark and with a Cashu wallet.

Ark. Bark datadir. One tark1 / ark1 per checkout (bark address). Poll bark address lookup --address. Fulfill when received sats cover the invoice. Then bark send a Lightning invoice on the sink. Do not iframe a browser desk.

Cashu. NUT-18 creqA + CBOR. Amount in sats, HTTP POST callback. The customer's wallet melts to Lightning. You never run a mint on the shop box. v1 does not hold Cashu tokens. Melt is the autoswap.

The file

A paid return mints a host-only cookie so the shop can unlock what was bought.

Set-Cookie: sale={token}; Path=/pay; HttpOnly; Secure; SameSite=Lax

systemd and cron

[Service]
User=www-data
WorkingDirectory=/opt/pay
EnvironmentFile=/etc/pay.env
ExecStart=/usr/bin/python3 /opt/pay/server.py
Restart=on-failure
*/10 * * * * /opt/pay/sweep.sh >> /opt/pay/sweep.log 2>&1

Copy

On the pay screen: price, format, tiles. On the receipt: same. Which swap host, which mint, which exclusive window: those stay off the button.

What not to do

  • Do not run a mint or a Lightning node on the shop box if you can help it.
  • Do not iframe a browser swap desk.
  • Do not rotate receive addresses in git.
  • Do not describe the sweep on the button.
  • Do not autoswap Card.
  • Do not leave coins on the hot wallet once a provider is up.
  • Do not wait for one swap host to come back before you take the next sale.

Bring-up order

  1. Domain and HTTPS.
  2. Stripe pay host + webhook.
  3. Lightning Address QR.
  4. Bitcoin Silent Payments + fallback + queue.
  5. Stables table (USDT on EVM / TRON / Liquid first; USDC next).
  6. Kick on settle. Cron as catch-up.
  7. Ark and Cashu NUT-18 on the same pay page.
  8. The file and the unlock cookie.

Provider radar (September 2026)

Probe from the VPS: DNS, TLS, min amount, refund. A name in env that fails is skipped for that settle only.

In rotation when the JSON answers

IdWhatNotes
satsroutingBoltz-shapedGET /v2/swap/submarine 200. Min 50k / max 5M sats. BTC, LN, Liquid.
zeusZEUS Swaps, Boltz v2POST /v2/swap/submarine.
lightning-swaplightning-swap.com /api/v2USDT TRC / SOL / ERC-20 in, bolt11 out.
fixedfloatff.io /api/v2/createCan pay BTCLN. Custodial for minutes.
sideshiftUSDT → BTC bitcoinQuotes work. createShift may be false. No LN settle as of 2026-09.
boltzOriginalPaused 2026-08-03.

Watch

IdWhoStatus
blockstreamBlockstream SwapsInvite beta. No public REST. BTC / LN / Liquid.
atomiqatomiq.exchangeREST live for smart-chain → BTC/LN.
swaptopiaSwaptopiaYou run Bark. Not an iframe.
kaleidoswap@kaleidoswapRGB Lightning USDT, not Liquid.
utexo@utexocomRGB BTC↔USDT with Lightning execution.

Do not add Diamond Hands (shut), PeerSwap (peer channels), THORChain / Maya / Chainflip (not Lightning invoices), or Strike / Cash App / Wallet of Satoshi (accounts).

When someone posts that they are building a swap host, write the handle in the table. Do not call it until probed.

Env template (VPS, signs)

BTC_BRIDGE_SEED=<hex>
BTC_SWEEP_ENABLED=true
[email protected]
SWAP_PROVIDERS=satsrouting,zeus,lightning-swap,fixedfloat,sideshift,boltz
USDT_EVM_ADDRESS=0x…
USDT_EVM_PRIVKEY=<hex>
USDT_TRON_ADDRESS=T…
USDT_LIQUID_ADDRESS=lq1qq…
USDT_SWEEP_ENABLED=true
USDT_SWEEP_BTC_ADDRESS=bc1p…

Shop host copies the addresses only.

Adding the next stable, or an alt

  1. Put the receive address in the rails table (same EVM key if it is another EVM chain).
  2. Confirm a swap pair exists: STABLE → your dest on at least one provider in the queue.
  3. If no pair yet, still take the payment. The batch sits. When a provider lists the pair, the next cron picks it up.
  4. An altcoin is step 1 with kind=other and a scanner you already trust. The queue does not care what the ticker was, only whether a host will take it.