The first real crypto payment on a new shop is a bad time to find out your webhook handler only works when the customer pays the exact amount. Card payments mostly fail in tidy ways. Crypto payments fail in untidy ones: people send a bit too little, pay twice, pay after the link expired, or open a second checkout in another tab.
None of that is exotic, and all of it can be rehearsed with test coins before you switch anything on for real. This post is the test plan I run, with a tiny webhook receiver you can copy. I'm using Coldtill, the self-hosted gateway we build, so the field names and statuses below are its own, but the list of cases applies to any crypto checkout.
Set up a server you're allowed to break
Coldtill installs in testnet mode unless you tell it otherwise, and a testnet install is a separate server from your mainnet one. That's deliberate: you can be careless with it.
sudo apt install docker.io docker-compose-v2 curl
curl -fsSL https://coldtill.com/install.sh | sudo bash -s -- --edition free
The script fetches the Free release from the self-hosted crypto payment gateway repo on GitHub, checks its SHA-256 and starts everything in /opt/coldtill. Finish Get started in the dashboard: two-factor on your account, a cold wallet, and at least one deposit wallet.
In testnet mode each network code maps to its test network:
| Code | Testnet |
|---|---|
ETH |
Sepolia |
BASE |
Base Sepolia |
POLYGON |
Amoy |
BSC |
BSC testnet |
TRON |
Nile |
SOL |
Devnet |
BTC |
Signet |
I'd pick one network and stick with it for the whole run. Base Sepolia USDC is the easiest, because the amounts are in dollars and you don't have to think about price movements while you're reading logs. Sepolia ETH from a faucet works too. Get the test coins into an ordinary browser wallet that plays the customer.
Then create an API key and a webhook endpoint under API keys and Webhooks. On a testnet install, webhook endpoints can be plain http://localhost URLs, which saves you a tunnel. (Mainnet only accepts public https endpoints, so don't get attached to this.)
A receiver that logs everything
You don't need your real shop for the first pass. A thirty-line receiver that verifies the signature and prints what arrived tells you more, because nothing is hidden behind your order logic.
# receiver.py (python3 receiver.py)
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["COLDTILL_WEBHOOK_SECRET"].encode()
seen = set()
def verify(headers, raw):
ts = headers.get("X-Coldtill-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
want = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, headers.get("X-Coldtill-Signature-V2", "").lower())
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify(self.headers, raw):
print("REJECTED bad signature", flush=True)
self.send_response(401); self.end_headers(); return
delivery = self.headers.get("X-Coldtill-Delivery")
body = json.loads(raw)
dup = " (repeat delivery)" if delivery in seen else ""
seen.add(delivery)
print(f'{body.get("timestamp_ms")} {body.get("invoice_id")} {body.get("status")} '
f'filled={body.get("filled_amount_in_usd")} '
f'conf={body.get("confirmation_current")}/{body.get("confirmation_required")}{dup}', flush=True)
self.send_response(200); self.end_headers()
HTTPServer(("127.0.0.1", 8099), Hook).serve_forever()
Two details in there matter more than they look. The signature is computed over the timestamp plus the raw body, so read the bytes before you parse any JSON. And decide what happened from the signed body's status, not from the X-Coldtill-Event header, which isn't signed.
Run it somewhere the Coldtill server can reach, point the webhook at it (for example http://localhost:8099/), and press Webhooks → Send test. You should see one line printed. Now change one character of the secret in your environment, restart, and send the test again. You want a REJECTED line. If a receiver happily accepts a forged request in testing, it'll do the same in production.
The cases
I create each payment with curl so I control the amount and the customer:
pay() { # pay <customerID> <invoiceID> <usd> [expire]
curl -s -X POST "$COLDTILL_URL/api/v1/payment" \
-H "API-Key: $COLDTILL_API_KEY" -H 'Content-Type: application/json' \
-d "{\"customerID\":\"$1\",\"invoiceID\":\"$2\",\"amountInUSD\":$3${4:+,\"expire\":\"$4\"}}"
echo
}
Set COLDTILL_URL to your testnet server's site URL first. Then work down this list, one payment at a time.
1. The happy path. pay c1 T-001 5, open the returned url, pay exactly 5 USDC. Watch progress lines with climbing confirmation counts, then one final FILLED. This is the only case most people test.

The customer's first step: pick a network and coin. Note the expiry countdown under the button; case 4 below is about what happens when it reaches zero.
2. Too little. pay c2 T-002 5, send 3. You should get PARTIALLY_FILLED, and the checkout should ask for the remaining amount. Check that your shop does not ship. Then send the remaining 2 and confirm it moves to FILLED. Coldtill counts anything within 1% of the amount owed as filled, so a few cents of rounding from a wallet won't strand an order.
3. Too much. pay c3 T-003 5, send 6. Expect OVER_FILLED. Treat it as paid. Whether you refund the extra is a business decision, but your code needs to know this status exists, or the order sits there looking unpaid.
4. Nobody pays. Create one that expires soon. The expire field takes an RFC 3339 time at least a minute ahead:
pay c4 T-004 5 "$(date -u -d '+3 minutes' +%Y-%m-%dT%H:%M:%SZ)"
Wait, and you should see CANCELLED. Your shop should cancel the order or offer a new link. (The default expiry is 24 hours, which is why nobody ever tests this.)
5. Pays after the deadline. Now send funds to the expired payment from case 4. The payment stays closed. You'll get a progress event showing a new filled_amount, and no FILLED. This one needs a human decision, so make sure it lands somewhere a human will see it rather than in a log nobody reads.
6. Changes their mind. pay c5 T-005 5, then immediately pay c5 T-006 7 for the same customer. A customer has at most one open payment, so the first becomes REPLACED. Ignore it in your shop; the newer one carries on.
7. Changes their mind mid-payment. Same as above, but send coins to the first one and create the second while the deposit is still confirming. The API answers 409 rather than replacing a payment with money on its way. Your checkout should show something like "try again in a few minutes" instead of a stack trace.
8. The same event twice. Under Webhooks → Deliveries you can resend any delivery by hand. Resend the FILLED from case 1. The receiver will flag it as a repeat; your real handler has to make it a no-op. Final events are retried for up to two days if your endpoint doesn't answer, so idempotency isn't optional.
9. Your endpoint is down. Stop the receiver, pay a fresh invoice, and start it again a few minutes later. The final event isn't lost: after two quick retries the next attempt comes 30 minutes later, then at growing intervals for two days. (If you're impatient, resend it from Deliveries.) Progress events only retry twice, so don't build anything that depends on seeing every one of them.
10. Events out of order. Delivery order isn't guaranteed. Sort by timestamp_ms (two events can share a second) and never let an older progress event overwrite a final status.

The Payments list after a test run. Partial, over-filled and cancelled payments are all there to compare against what your shop recorded.
Then plug in the real shop
Once the receiver behaves, swap it for your shop's real endpoint and run the same ten cases again, this time checking order statuses instead of printed lines. Add one more check per case: the reference_id in the webhook must match the one you stored for that invoice_id. Without that, a late event from an old payment can mark a newer order paid.
What testnet can't tell you
A few things only show up on mainnet, so plan for them rather than assume they're covered:
- Auto-convert is hidden in testnet mode, because the swap providers only run on mainnet. If you'll take coins through it, test that flow with a small real payment.
- Public RPC defaults are fine for testing and rate limited for anything else. Add your own or paid nodes before go-live, ideally more than one per network.
-
Your real network. DNS, TLS, a firewall that only lets the office in, or a webhook URL with a stray
wwwwill all pass on localhost.
So the last step is always the same: install the mainnet server separately with --mode mainnet --site-url https://pay.yourshop.com, point the shop at it and pay yourself $1. If that dollar shows up as FILLED in your shop and in your cold wallet, you've done more testing than most crypto checkouts ever get.
The webhook and API details used above are in the Coldtill docs, if you want to check a field before relying on it.
Top comments (0)