Developing a Cryptocurrency Payment Acceptance Gateway
Typical problem statement: "we want to accept crypto like regular Stripe, just without intermediaries." In practice this means solving several non-trivial problems simultaneously — detecting incoming payments without polling every few seconds, volatility during conversion, partial payments, confirmation thresholds for different networks, and idempotence during network failures.
Architecture: What a Gateway Consists of
A minimally viable payment gateway consists of four components:
[Client] → [API Gateway] → [Payment Service]
↓
[Blockchain Listener]
↓
[Event Queue (Redis/Kafka)]
↓
[Settlement Service] → [ERP/CRM]
Payment Service — creates an order, generates a unique address (or payment ID), returns payment data to the client. Stateful — stores mapping address → order.
Blockchain Listener — monitors incoming transactions. This is the most critical component from a reliability perspective. Two approaches:
-
WebSocket subscription (
eth_subscribe("logs", filter)oreth_subscribe("newHeads")) — low latency, but connections break, need reconnect with backoff and replay of missed blocks. -
Polling + cursor — less elegant, but predictable. Store the last processed block, query
eth_getLogswith address filter. More resilient to network failures.
For production: hybrid approach — WebSocket for low latency, polling as fallback with cursor-based recovery.
Event Queue — buffer between listener and settlement. Kafka for high loads, Redis Streams for medium. Key point: listener publishes TransactionDetected event, settlement subscribes. This decouples components and guarantees processing if settlement service temporarily fails.
Settlement Service — verifies confirmations, converts amount, updates order status, notifies upstream system (webhook).
Payment Detection: Network-Specific Nuances
EVM Networks (ETH, BNB, Polygon, Arbitrum...)
Native ETH transfers: monitor via eth_subscribe("newHeads") + eth_getBlockByNumber and filter transactions by to address.
ERC-20 tokens (USDT, USDC, DAI): monitor Transfer(address indexed from, address indexed to, uint256 value) event via eth_getLogs with filter:
const filter = {
fromBlock: 'latest',
topics: [
ethers.id('Transfer(address,address,uint256)'),
null, // from: any
ethers.zeroPadValue(paymentAddress, 32), // to: our address
],
};
Important for USDT (Tether): it has non-standard ERC-20 — transfer function doesn't return bool. Calling through standard interface fails. Use safeTransfer or low-level call with returndata verification.
Bitcoin and UTXO Model
For BTC there's no concept of "address → transaction" at the node level. Use either:
- Electrum Server (Electrs) — indexes UTXOs by address, allows subscribing to an address
- BlockCypher / Blockcypher WebHook API — hosted solution, but third-party dependency
-
Bitcoin Core with
importaddress— add address to wallet node, get notifications via ZMQ
Minimum confirmations for BTC: 1 for small amounts (<$100), 3 for medium, 6 for large. For Ethereum 12–20 blocks suffice (EIP-3607 finality not guaranteed without Casper finalization).
TON
TON transactions are asynchronous: incoming transfer is a bounce-able message, you must verify it's actually a transfer, not a bounce. Use TonAPI or TON Center API with webhook on address.
Confirmation Threshold and Double-Spend Protection
Can't consider payment complete after first detecting transaction in mempool — that's pending state, not confirmed. Minimum thresholds:
| Network | Threshold | Rationale |
|---|---|---|
| Ethereum | 12 blocks (~2.5 min) | After merge finality via checkpoint, but 12 blocks is practical standard |
| BNB Chain | 15 blocks (~45 sec) | Centralized, but reorgs happen |
| Polygon PoS | 128 blocks (~4 min) | Checkpoint on Ethereum every ~30 min, reorgs possible until then |
| Bitcoin | 3–6 blocks (30–60 min) | Classic, for large sums |
| Arbitrum/Optimism | 1 block (L2 finality) | Reorgs on L2 practically impossible |
Partial Payments and Overpayments
Real users sometimes pay inexact amounts — exchanges deduct fees, people make mistakes. Need a policy:
-
Underpayment: if 99–100% received — consider paid (1% tolerance). If less —
partially_paid, wait X minutes for top-up, thenexpired. - Overpayment: auto-accept, refund difference (need refund flow) or credit account.
Volatility: Exchange Rate at Order Creation
Standard scheme: fix crypto-to-USD rate at invoice creation, give 15–30 minutes for payment. Rate source — Chainlink Price Feed (on-chain) or CoinGecko/CoinMarketCap API (off-chain). Chainlink preferred — less vendor lock-in, but needs web3 RPC.
On expiration: expired status, generate new invoice with current rate on client request.
Webhook and Idempotence
Notifying upstream via webhook must be idempotent — retries are possible. Include payment_id (unique) + tx_hash + status in payload. Upstream should verify it hasn't processed this payment_id already.
Retry policy: exponential backoff, 5–10 attempts, then dead letter queue for manual review.
Stack
- Node.js + TypeScript or Go for listener and API — good web3 library support
- ethers.js v6 or viem for EVM interaction
- PostgreSQL for payment storage (ACID, transactionality on status updates)
- Redis for rate limiting and exchange rate caching
- Kafka or Redis Streams for event queue
- Grafana + Prometheus — monitoring: listener lag vs chain head, processing speed, errors
Custom gateway makes sense at >500 payments/day volume or specific privacy/control requirements. For smaller volumes — NOWPayments, CoinGate or similar solve it cheaper.







