Setting Up Bitcoin Payment Acceptance
The most common mistake when integrating Bitcoin payments is using a single address for all users and tracking amounts. This doesn't work: two users can send the same amount in one transaction, you can receive multiple incomplete payments (UTXOs) that together constitute the needed amount, or a user might send from an exchange using batching. The correct approach is to generate a unique address for each payment.
Architecture: HD Wallets and Address Derivation
The BIP-32/BIP-44 standard allows generating an infinite tree of addresses deterministically from a single master seed. For payment acceptance, use the xpub (extended public key) — the public part that the server stores openly and uses to generate addresses. The private key is stored separately (cold storage, hardware wallet) and is needed only for withdrawals.
Master Seed → xpub (m/44'/0'/0')
↓
index=0: 1A1zP1... (payment #1)
index=1: 1B2zP2... (payment #2)
index=N: ... (payment #N)
Derivation path per BIP-44 for Bitcoin mainnet: m/44'/0'/account'/change/index. For receiving — change=0, increment index.
Address Types
| Type | Format | SegWit | Recommended |
|---|---|---|---|
| P2PKH (Legacy) | 1... |
No | No (high fees) |
| P2SH-P2WPKH (Wrapped SegWit) | 3... |
Yes | For compatibility |
| P2WPKH (Native SegWit) | bc1q... |
Yes | Yes |
| P2TR (Taproot) | bc1p... |
Yes | Yes (new projects) |
I recommend Native SegWit (bc1q): fees are 30–40% lower than Legacy, supported by all modern wallets and exchanges. Taproot — if you need Schnorr signatures and scripts in the future.
Implementation: Node.js + bitcoinjs-lib
npm install bitcoinjs-lib bip32 bip39 tiny-secp256k1
import * as bitcoin from 'bitcoinjs-lib'
import { BIP32Factory } from 'bip32'
import * as ecc from 'tiny-secp256k1'
bitcoin.initEccLib(ecc)
const bip32 = BIP32Factory(ecc)
const NETWORK = bitcoin.networks.bitcoin // or networks.testnet
// Once: generate xpub from seed (execute in cold storage)
// const seed = bip39.mnemonicToSeedSync(mnemonic)
// const root = bip32.fromSeed(seed, NETWORK)
// const account = root.derivePath("m/84'/0'/0'") // BIP-84 for Native SegWit
// const xpub = account.neutered().toBase58()
// console.log(xpub) // store in .env as BITCOIN_XPUB
// On the server: generate address by index
function getPaymentAddress(xpub: string, index: number): string {
const node = bip32.fromBase58(xpub, NETWORK)
const child = node.derive(0).derive(index) // external chain, index N
const { address } = bitcoin.payments.p2wpkh({
pubkey: Buffer.from(child.publicKey),
network: NETWORK,
})
if (!address) throw new Error('Failed to derive address')
return address
}
Database: Payment Schema
CREATE TABLE bitcoin_payments (
id BIGSERIAL PRIMARY KEY,
order_id UUID NOT NULL REFERENCES orders(id),
address VARCHAR(62) NOT NULL UNIQUE,
hd_index INTEGER NOT NULL UNIQUE,
amount_sat BIGINT NOT NULL, -- amount in satoshis
status VARCHAR(20) DEFAULT 'pending', -- pending/underpaid/confirmed/expired
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
confirmed_at TIMESTAMPTZ,
tx_hash VARCHAR(64)
);
CREATE INDEX ON bitcoin_payments(address);
CREATE INDEX ON bitcoin_payments(status) WHERE status = 'pending';
Transaction Monitoring
Option 1: Electrum/Electrs via WebSocket
The cheapest way — run electrs (Rust implementation of Electrum Server) on top of your Bitcoin node and listen to events:
import ElectrumClient from 'electrum-client'
const client = new ElectrumClient(50002, 'your-electrs-host', 'tls')
await client.connect('payment-monitor', '1.4')
// Subscribe to address
async function watchAddress(address: string, onPayment: (tx: any) => void) {
const scriptHash = addressToScriptHash(address) // sha256 reversedLE
await client.subscribe.on('blockchain.scripthash.subscribe', async (updates) => {
const [scripthash, status] = updates
if (scripthash === scriptHash && status !== null) {
const history = await client.blockchainScripthash_getHistory(scriptHash)
onPayment(history)
}
})
await client.blockchainScripthash_subscribe(scriptHash)
}
Option 2: Polling via Public API
For MVP or low load — Blockstream Esplora API (public, no key):
async function checkPayment(address: string, expectedSat: bigint): Promise<'pending' | 'underpaid' | 'confirmed'> {
const res = await fetch(`https://blockstream.info/api/address/${address}/utxo`)
const utxos: Array<{ txid: string; value: number; status: { confirmed: boolean } }> = await res.json()
const confirmedSat = utxos
.filter(u => u.status.confirmed)
.reduce((sum, u) => sum + BigInt(u.value), 0n)
if (confirmedSat === 0n) return 'pending'
if (confirmedSat < expectedSat) return 'underpaid'
return 'confirmed'
}
For production use your own node + electrs. Dependency on public APIs in a payment service — not okay.
Number of Confirmations
| Amount | Recommended Confirmations |
|---|---|
| < $100 | 1 (first block inclusion) |
| $100 – $1 000 | 3 |
| $1 000 – $10 000 | 6 |
| > $10 000 | 6+ or wait for Finalized by business logic |
0-conf (unconfirmed) is acceptable only for physical point-of-sale with small amounts and RBF=false. In e-commerce — only with confirmations.
Edge Case Handling
Overpayment — user sent more. Policy: credit as full payment, keep the difference on balance, offer refund or credit toward next order. Auto-refund — only if you have a refund address from the user.
Underpayment — less than needed arrived. Two options: freeze the payment and ask for the difference to the same address (within time window); or cancel and request new payment. Don't credit partial payment as full.
Expiry — address expired (usually in 15–60 minutes) but transaction still arrived. Strategy: keep "expired" addresses active for 24 more hours but don't show to user for new payments.
Transaction Replacement (RBF) — transaction can be replaced with higher fee. Don't trust unconfirmed transactions with BIP125-opt-in-RBF=true flag.
Withdrawals
To withdraw accumulated UTXOs you need coin selection logic. Use bitcoinjs-lib PSBT:
// Access to private keys needed — execute only in isolated service
const psbt = new bitcoin.Psbt({ network: NETWORK })
// ... add inputs/outputs with correct fee calculation
// Transaction size in vbytes depends on number of inputs/outputs
// fee = feeRate (sat/vByte) * vsize
I recommend sweeping periodically (once a day) via script, not automatically on each payment — this reduces transaction count and fees.







