
cTrip — Building a Multi-Chain Crypto Payment Gateway with FastAPI
I built cTrip, a production-style cryptocurrency payment gateway. It accepts native and ERC-20 payments across EVM chains, detects them within seconds, confirms them against block depth, and sweeps funds to your wallet — all automatically.
The full documentation lives at ctrip-docs.readthedocs.io.
The Problem
Accepting crypto payments directly is painful. A merchant needs a way to:
- Give each customer a unique deposit address per payment
- Detect incoming transfers reliably (no polling a wallet and hoping)
- Wait for confirmations so a payment can't be double-spent
- Sweep funds into a single admin wallet
- Get webhook notifications on every state change so their backend stays in sync
Doing this by hand means writing a block scanner, a state machine, a retry system, and a key-management scheme — all at once. cTrip packages that entire job into a single deployable system.
What It Does
- Accepts native and ERC-20 payments on any EVM chain (Sepolia, BSC, Ethereum, Anvil, ...)
- Generates a deterministically-derived deposit address per payment — no BIP-44 mnemonics
- Detects transfers by scanning new blocks every 10 seconds
- Moves payments through
pending → detected → confirmed → settled(orexpired) - Delivers HMAC-SHA256-signed webhooks with exponential-backoff retries
- Exposes a JSON admin API, analytics, and a server-rendered admin console
- Runs fully in Docker Compose — API, PostgreSQL, Redis, and worker
Architecture
Client App / Frontend <--> FastAPI Server (Port 8000)
│
├── API Routers ──> PostgreSQL / SQLite
├── Redis Broker ────────┐
└── WalletKeyManager │
▼
┌─────────────────────┐
│ ARQ Worker │
│ Block Scanner (10s) │
│ Lifecycle Cron (15s)│
│ Sweeper │
│ Webhook Dispatcher │
└──────────┬──────────┘
▼
BlockchainService / EVMClient
▼
EVM Networks (RPC)
The system splits into clean layers:
- API layer — FastAPI routers handling auth, payments, admin operations, and analytics
- Wallet layer —
WalletKeyManagerderiving deterministic addresses via HKDF - Blockchain layer — async
EVMClientwith automatic RPC failover across multiple endpoints - Scanner — block polling, transaction/log matching, and detection
- Workers — a single ARQ process running cron-driven lifecycle, sweeping, and webhooks
- Storage — PostgreSQL (production) or SQLite (development) plus Redis for cursors and queues
Payment Lifecycle
Every payment is a small state machine, driven by the scanner and the worker crons:
pending ──▶ detected ──▶ confirmed ──▶ settled
│ │
└────expired─┴─────▶ (terminal)
- pending → detected — the scanner finds an on-chain transfer matching a pending payment (recipient address +
value >= amount_raw) and records the event. - detected → confirmed — the lifecycle cron checks block depth every 15 seconds and promotes payments past the confirmation threshold.
- confirmed → settled — the sweeper derives the payment's private key, moves funds to the admin wallet, and marks the payment settled.
- pending/detected → expired — payments past their expiry window are closed out in a single bulk update.
Every transition is appended to a payment_state_changes audit table, which feeds the analytics endpoints.
The Block Scanner
The scanner is the heart of detection. Instead of polling each payment individually or subscribing to WebSockets, it maintains one Redis cursor per chain — the last block it has enqueued — and only scans chains that have pending, unexpired payments. Idle networks cost nothing.
Every 10 seconds, the scan_orchestrator cron:
- Finds active scan targets (chains with pending payments)
- Reads the per-chain cursor from Redis
- Enqueues up to 500 new blocks per tick, then advances the cursor
- Dispatches per-block jobs for native transactions and per-range jobs for ERC-20
Transferlogs
The cursor advances when jobs are enqueued, not completed, so ARQ retries keep the bounded ranges gap-tolerant — a restart resumes scanning exactly where it left off.
Matching is straightforward: for native transfers, the to address must match a pending payment and the value must be >= amount_raw. For ERC-20, the decoded Transfer event recipient must match a pending payment for that token contract. Detections are recorded in payment_events with a unique (tx_hash, log_index) constraint, making re-scans idempotent.
Guarded State Transitions
One design detail I care about: payments move state via guarded bulk updates — UPDATE ... WHERE status='pending' ... RETURNING. Because the update atomically claims the row only if it is still in the expected status, concurrent workers can never revert a payment's state or double-promote it. No row-level locks, no read-modify-write races.
Deterministic Deposit Addresses
Every payment gets its own deposit address, derived with HKDF (RFC 5869) from the payment ID plus two server secrets — no mnemonic, no BIP-44 path.
WALLET_SECRET_A + WALLET_SECRET_B
│
▼ HKDF extract-and-expand (info = payment_id:key_version:recovery_key)
Deterministic 32-byte seed
│
▼ SHA-256
Ethereum private key ──▶ checksummed deposit address
This gives several useful properties:
- Deterministic — the same payment ID always yields the same address
- Re-derivable — the sweeper can recover the private key at any time
- No address reuse — each payment gets a fresh on-chain address
- Rotatable — increment the
key_versionto rotate a wallet
Only the address is ever exposed to clients; the private key stays derivable only from server secrets.
Background Workers
All background processing runs in a single ARQ worker backed by Redis, which keeps deployment simple and scales horizontally by running more worker processes.
The cron schedule:
| Cron | Cadence | Purpose |
|---|---|---|
scan_orchestrator | every 10s | Scan new blocks on chains with pending payments |
listen_for_payments | every 15s | Confirm detected payments; expire stale ones |
prune_stale_cursors | hourly | Drop Redis cursors for inactive chains |
retry_failed_webhooks | every 5 min | Retry failed deliveries with backoff |
The same worker hosts the task functions for scanning, lifecycle, sweeping, and webhook delivery, so the API just enqueues jobs through a thin WorkerClient — the admin console's "scan now" and "sweep now" buttons are exactly that.
Webhooks with HMAC Signing
Every state change can notify your backend asynchronously:
| Event | Fired when |
|---|---|
payment.confirmed | A payment passes the confirmation depth |
payment.expired | A payment passes expires_at |
payment.swept | Funds are swept to the admin wallet |
Payloads are signed with HMAC-SHA256 using WEBHOOK_SECRET, delivered in the X-Webhook-Signature header. Deliveries are recorded in a webhook_attempts table, and failures retry with exponential backoff — 1 min, 5 min, 15 min, 1 hour, 3 hours — before being marked failed and available for manual re-send via the admin API.
API Design
Two auth schemes keep merchant and admin concerns separate:
- Merchant API keys (
X-Api-Key: ck_...) for the payments API — stored as SHA-256 hashes, raw key shown once - Admin JWT (
Authorization: Bearer <token>) for admin and analytics — access tokens last 30 minutes, refresh tokens 7 days
Creating a payment is a single request:
curl -X POST http://localhost:8000/api/v1/payments/ \
-H "Content-Type: application/json" \
-H "X-Api-Key: $API_KEY" \
-d '{"chain_id": 11155111, "amount": 1000000000000000000}'
The response returns the deposit address, the payment id, and status: "pending". From there the scanner, workers, and webhooks do the rest.
Deployment
A single docker-compose up --build brings up the full stack: PostgreSQL 15 with a persistent volume, Redis 7, the API (running migrations then uvicorn on port 8000), and the worker. On first boot the app seeds the default admin user, a default merchant, and the chains table from chains.yaml.
A handy detail: RPC hosts named localhost in chains.yaml are automatically rewritten to host.docker.internal inside the container, so a local Anvil node just works.
Reflections
The most interesting engineering decisions were the ones that keep a production system honest under load:
- Scanning by cursor, not by payment — detection cost scales with chains, not with payments
- Guarded bulk transitions — concurrency safety without pessimistic locking
- HKDF-derived addresses — no pre-provisioning, no address reuse, recoverable keys
- Single worker, cron-driven — the whole background pipeline is one process you can scale out
If you're building something that needs to accept crypto payments — or you just want to study how a real gateway is structured — the full documentation is worth a read.
Documentation
Read the complete docs at ctrip-docs.readthedocs.io — covering the API reference, payment lifecycle, block scanner, background workers, database schema, and configuration guides.