Traditional card chargebacks cost merchants between 0.5% and 2% of gross volume once disputes, processing fees and lost merchandise are factored in. Crypto payments don't have chargebacks. But they do have block reorganizations, and a merchant who releases goods before on-chain finality has created the exact same exposure with extra steps.
The solution isn't complicated: define an explicit finality SLA per chain and amount tier, encode it in the payment intent, wait for the right event, then fulfill. This post walks through what finality actually means on each chain BchainPay supports, how to model it as a serviceable SLA, and the exact API calls and webhook states involved.
What "final" means on each chain#
These are not interchangeable concepts. Each network has its own finality model, and confusing them is the primary source of merchant re-org losses.
Bitcoin: probabilistic finality via proof of work#
Bitcoin has no hard finality checkpoint. Each additional block makes a rewrite exponentially more expensive because every new block requires redoing all of its proof of work plus all subsequent blocks. The industry has converged on these thresholds:
- 0 confirmations (0-conf): Only acceptable for digital goods under $10 where the cost of a double-spend attack exceeds the gain, and only when the transaction has opted out of Replace-By-Fee. Never use for physical goods or anything you cannot revoke.
- 1 confirmation (~10 minutes): Appropriate for low-value digital goods under $100. A single confirmation eliminates the cheapest attacks.
- 6 confirmations (~60 minutes): The long-standing industry bar for high-value transfers. Mounting an attack requiring >50% hash power for 6 consecutive blocks is economically infeasible at current network scale. Use this floor for anything over $100.
Ethereum: Casper FFG checkpoints#
Post-Merge Ethereum uses Casper FFG (Friendly Finality Gadget). An epoch spans 32 slots (~6.4 minutes). Two consecutive justified epochs promote the earlier one to finalized. Reversing a finalized block requires slashing at least one-third of all staked ETH, currently well over $30B of economic security.
In practice:
- 1 block (~12 seconds): Acceptable for sub-$50 digital goods. Short re-orgs of 1-2 blocks do occur on mainnet.
- 12 blocks (~2.4 minutes): The BchainPay default for the $50-$5,000 range. Covers the vast majority of realistic re-org scenarios.
finalizedcommitment: Waits for Casper checkpoint confirmation (~6.4 minutes after block inclusion). Safe for any amount.
Polygon PoS re-orgs measurably more often than Ethereum mainnet. Use 64 blocks for amounts under $5,000 and 128 blocks above that.
Solana: named commitment levels#
Solana exposes three commitment levels that have different security properties:
processed: The transaction is in a block, but that block has not yet received any stake votes. Do not use this for payment confirmation.confirmed: The block has received a supermajority vote from 2/3 of active stake. Short forks remain possible but are rare.finalized: 32 slots have passed since the block and maximum vote lockout has been reached (~13 seconds). A finalized slot has never been reverted on Solana mainnet.
Use confirmed for amounts under $500 and finalized for everything
else. The latency difference for user experience is negligible.
Tron: DPOS solidification#
Tron uses delegated proof of stake with 27 Super Representatives. A block becomes "solidified" after 19 consecutive blocks are signed by at least two-thirds of active SRs (~57 seconds total). After solidification, reversing a block would require simultaneously controlling 19 SR slots, which represents a coordinated attack across the majority of the network.
Use 1 confirmation for Tron sub-$50 digital goods; solidified (19 blocks) for everything else. Our post on detecting double-spend re-orgs on TRON USDT covers the attack surface in detail if you want the underlying mechanics.
Mapping finality to amount tiers#
The right threshold is a function of two variables: how quickly you need to confirm, and how much you stand to lose if the transaction is reversed.
| Chain | Threshold | Approx. time | Amount tier |
|---|---|---|---|
| BTC | 1 block | ~10 min | <$100 |
| BTC | 6 blocks | ~60 min | $100+ |
| ETH | 12 blocks | ~2.4 min | <$5k |
| ETH | finalized |
~6.4 min | $5k+ |
| SOL | confirmed |
~0.4 s | <$500 |
| SOL | finalized |
~13 s | $500+ |
| TRX | 1 block | ~3 s | <$50 |
| TRX | solidified (19) | ~57 s | $50+ |
| POL | 64 blocks | ~128 s | <$5k |
| POL | 128 blocks | ~256 s | $5k+ |
These are starting points, not law. A merchant selling 20-minute concert e-tickets can absorb a 6-minute finality wait; a merchant selling live API credits cannot. Know your own SLA before copying the table.
Encoding finality SLAs in BchainPay payment intents#
The confirmation_policy field in POST /v1/payment-intents is where
you declare your SLA. Set it per chain, or use a named preset:
// Per-chain explicit policy
const res = await fetch('https://api.bchainpay.com/v1/payment-intents', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BCHAINPAY_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: '250.00',
currency: 'USDC',
confirmation_policy: {
ethereum: { blocks: 12 },
solana: 'finalized',
tron: { blocks: 19 },
bitcoin: { blocks: 1 },
polygon: { blocks: 64 },
},
confirmation_timeout: 3600, // fire payment_intent.timed_out after 1 h
expires_in: 900, // payment window: 15 min
metadata: { order_id: 'ord_abc123' },
}),
});
const intent = await res.json();Three named presets shortcut the per-chain config:
| Preset | Use case |
|---|---|
"fast" |
Digital goods under $50, minimal wait |
"balanced" |
Default: chain-appropriate medium thresholds |
"conservative" |
Physical goods, high-value services |
// Preset shorthand
body: JSON.stringify({
amount: '49.00',
currency: 'USDT',
confirmation_policy: 'fast',
expires_in: 600,
metadata: { order_id: 'ord_xyz789' },
})BchainPay validates the policy against the asset and chain at creation
time. Requesting { blocks: 1 } on Solana returns a descriptive 422
error because Solana does not expose raw block-count confirmation in its
commitment API; you must use a named commitment level.
The four webhook states your handler must cover#
Every BchainPay payment traverses up to four event types. Your handler must be idempotent across all of them. (See our webhooks post for the full HMAC-SHA256 verification recipe.)
switch (event.type) {
case 'payment_intent.detected':
// Seen on-chain; do NOT fulfill yet.
await db.orders.updateStatus(orderId, 'payment_detected');
break;
case 'payment_intent.succeeded':
// Confirmation policy met. Safe to fulfill.
await fulfillOrder(orderId, event.data);
break;
case 'payment_intent.invalidated':
// Re-org detected after detection or confirmation.
// Roll back any provisional state.
await cancelProvisionalFulfillment(orderId);
await notifyCustomer(orderId, 'payment_reversed');
break;
case 'payment_intent.timed_out':
// confirmation_timeout elapsed before policy was met.
await db.orders.updateStatus(orderId, 'payment_timeout');
await offerAlternativePayment(orderId);
break;
}Three things worth making explicit:
Never fulfill on detected. A detected payment has zero-conf risk
on Bitcoin and processed-level risk on Solana. You will occasionally
see detected followed by invalidated, and acting on the former
costs you real money.
invalidated after succeeded is possible but rare. It indicates
a reorganization deep enough to reverse a block that had already cleared
your confirmation threshold. This happens on Polygon occasionally.
Handle it gracefully: flag the order for manual review rather than
silently double-charging the customer.
timed_out is recoverable. The payment may still arrive after the
timeout; BchainPay simply stopped tracking it. You can create a new
intent for the same order, or extend the timeout window with
PATCH /v1/payment-intents/{id}.
The payment_intent.succeeded payload includes the confirmation data so
you can log exactly how long finality took:
{
"type": "payment_intent.succeeded",
"data": {
"id": "pi_01HW2BtF3mNxQzRv",
"chain": "ethereum",
"currency": "USDC",
"amount": "250.00",
"confirmations": 12,
"confirmation_policy": { "blocks": 12 },
"finality": "confirmed",
"block_time_ms": 143200,
"transaction": {
"hash": "0xabc123…",
"block_number": 19873421,
"from_address": "0xdef456…"
},
"metadata": { "order_id": "ord_abc123" }
}
}block_time_ms is the elapsed time in milliseconds from when the
transaction was first detected (payment_intent.detected) to when the
confirmation policy was satisfied. Log this per intent. Over a few weeks
you'll have enough data to tune your thresholds based on actual observed
latency rather than theoretical block times.
SLA templates by merchant type#
Digital subscriptions and SaaS upgrades#
Speed matters; chargeback-equivalent risk is lower because access can be
revoked server-side. Use "fast" for amounts under $200 and
"balanced" above that. On Solana and Tron you will typically see
payment_intent.succeeded within 15 seconds.
Physical goods#
You cannot un-ship a parcel. Use "conservative" unconditionally. The
60-minute Bitcoin finality window fits the warehouse pick-and-pack
cycle anyway, and customers expect a delay between payment and
shipment confirmation.
Marketplace payouts to sellers#
Confirm incoming funds before releasing outgoing ones. Mirror the
incoming policy exactly: do not initiate payouts until the underlying
customer payment has fired payment_intent.succeeded.
Subscription billing (recurring)#
Recurring charges are pull-based via the BchainPay Billing API, which
handles the finality SLA internally. Your integration only observes
subscription.payment_succeeded after the underlying intent clears.
You do not need to set confirmation_policy yourself on these.
Setting a timeout, and what happens when it fires#
confirmation_timeout is the maximum number of seconds BchainPay will
wait for your policy to be met before emitting payment_intent.timed_out.
body: JSON.stringify({
amount: '750.00',
currency: 'USDC',
confirmation_policy: { ethereum: { blocks: 12 }, bitcoin: { blocks: 6 } },
confirmation_timeout: 7200, // 2 hours for Bitcoin 6-block finality
expires_in: 1800,
metadata: { order_id: 'ord_btc001' },
})Size the timeout to the slowest chain you accept plus a buffer. Bitcoin at 6 blocks averages 60 minutes but can spike to 90+ during low-fee periods. Two hours gives you the buffer without leaving orders in a permanent pending state.
When payment_intent.timed_out fires, the payment may still complete
on-chain. BchainPay stops listening, but the transaction is not
cancelled. If you want to resume tracking (for instance, if the
customer contacts support), create a new payment intent pointing to
the same metadata.order_id. Your idempotency guard on the business
key prevents a double-fulfillment if both eventually confirm.
Key takeaways#
- Re-org risk is the crypto equivalent of chargeback risk. The fix is the same: do not release goods until payment is final.
- Finality is chain-specific. Bitcoin is probabilistic; Ethereum has Casper FFG checkpoints; Solana exposes named commitment levels; Tron has DPOS solidification. None of them mean the same thing at "1 confirmation."
- Amount tiers matter. A $5 in-game purchase and a $5,000 equipment order deserve different confirmation thresholds on every chain.
- Use
payment_intent.succeededas your fulfillment trigger, notpayment_intent.detected. Every integration that skips this distinction eventually gets caught by a re-org. - Set
confirmation_timeout. An unacknowledged payment that never confirms is a worse customer experience than a clear "we're still waiting" state. - Log
block_time_msandconfirmationsper intent. Real-world data lets you tune thresholds rather than guessing from theoretical block times.
BchainPay's confirmation policies are configurable per intent, and the named presets cover the majority of production use cases out of the box. Sandbox credentials are free at dashboard.bchainpay.com.