A screenshot is not proof. A transaction ID is not proof by itself. The only thing that matters is whether a transaction pays your address, for the amount you asked, for this order, and whether you are willing to accept its confirmation status.
Payment verification is the part of a Bitcoin store that looks trivial until it is not. “The txid is on the block explorer” is not enough. A malicious buyer can paste someone else’s transaction, pay the wrong address, underpay by a few thousand sats, or send on the wrong network.
The five checks
| Check | What to verify | Failure mode |
|---|---|---|
| 1. Existence | The transaction is known to the network or your node. | A fake or malformed txid. |
| 2. Destination | At least one output pays the exact receiving address for this order. | Payment to an old, wrong, or attacker-controlled address. |
| 3. Amount | The summed outputs to your address cover the quoted satoshi amount. | Underpayment or a stale exchange rate. |
| 4. Uniqueness | The txid has not already unlocked another order, and the address or amount binds it to this order. | Replay of another buyer’s transaction. |
| 5. Finality | The confirmation count meets your published threshold. | Zero-conf replacement, dropped transaction, or reorg. |
A minimal code example
This is deliberately small. It shows the shape of a server-side check, not a complete production implementation. Use your own node or a paid API with a clear SLA for a real store.
const EXPECTED_SATS = 25000;
const STORE_ADDRESS = "bc1q...your address...";
const MIN_CONFIRMATIONS = 1;
async function verifyPayment(txid) {
const res = await fetch("https://mempool.space/api/tx/" + txid);
if (!res.ok) throw new Error("Transaction not found");
const tx = await res.json();
const paid = tx.vout
.filter((out) => out.scriptpubkey_address === STORE_ADDRESS)
.reduce((sum, out) => sum + out.value, 0);
if (paid < EXPECTED_SATS) throw new Error("Underpaid");
if (MIN_CONFIRMATIONS > 0 && !tx.status.confirmed) {
throw new Error("Waiting for confirmation");
}
return { txid, paid, confirmed: tx.status.confirmed };
}
The example checks the address and amount. It does not prove uniqueness. A transaction that paid your address for someone else’s order would also pass this function. Add a per-order address, or a unique amount and expiry, then mark the txid as consumed in your database.
Use sats everywhere. vout.value is already in satoshis. Convert the fiat quote to sats once, store the integer, and compare integers. Do not compare floating-point BTC strings.
The traps
The reused transaction
One buyer pays. Another buyer copies the txid. If both orders use the same address and you only check “did this address receive enough?”, both files unlock. Use a fresh address per order, or a unique amount and a short window, and record which txid unlocked which product.
The underpayment
A payment can be one satoshi short and still look successful in a wallet UI. Decide whether you require exact payment or a small tolerance. Whatever you choose, make it explicit and enforce it server-side.
The wrong address
Old addresses, testnet addresses, and addresses from a different wallet all exist. Match the exact string, not a substring or a label. If you rotate addresses, keep a record that maps the address to the order.
The unconfirmed payment
A transaction in the mempool is not final. It can be replaced with a higher fee, dropped, or reorged. For a $4 download, accepting zero-conf may be a rational risk. For a $200 product, it is not. Publish the threshold.
The explorer timeout
When an API times out, do not treat the payment as valid or invalid by accident. Fail closed, show a “we are checking” state, and let the buyer retry. Log the txid for support.
The delivery token
Once the payment passes, issue a token that is:
- Signed server-side with a secret the browser never sees.
- Scoped to one product and one order.
- Short-lived — fifteen minutes is plenty for a download.
- Checked in constant time before the file is streamed.
The file itself should live outside the public web root. If the plaintext is on a public CDN or in a public repository, a signed URL is security theatre. The token should unlock a server-side read, not reveal a permanent path.
Test plan
- Pay the exact amount and confirm the file unlocks.
- Underpay by one sat and confirm it does not.
- Submit a random txid and confirm it does not.
- Reuse a txid on a second order and confirm it does not.
- Try an expired token and confirm it does not.
- Try a token for product A on product B and confirm it does not.
- Simulate an explorer timeout and confirm the store fails closed.
- Test on mobile, where most buyers will open the link.
FAQ
Is a transaction ID enough to prove payment?
Should I trust a block explorer?
How many confirmations are enough?
Can a buyer reuse another buyer’s transaction ID?
What about replace-by-fee and reorgs?
Where should the verification code run?
Skip the verification bugs
The Bitcoin Storefront Starter Kit includes the checkout, the on-chain check, and the short-lived download token. Read the code, deploy it, and change the wallet.