Checkout Flow
This guide walks through a production-ready eCommerce checkout flow: order creation, payment, and fulfillment — including expiry handling, on-chain confirmation delays, and payment edge cases.
If you just need the basics, start with Accept Bitcoin Payments. Come back here when you need:
- A pending state while on-chain payments await block confirmation
- Expiry handling — letting customers renew an expired invoice without losing their order
- Overpayment and underpayment handling in your order state machine
The flow
Customer confirms cart
→ Your server creates an order + invoice
→ Customer pays on the Coinsnap checkout page
Lightning → Settled immediately
On-chain → Processing → Settled (~10 min)
→ Coinsnap sends webhook to your server
→ Your server fulfills the order
Order states
Before writing any code, define the states your order can be in:
| Order status | Meaning |
|---|---|
pending_payment | Invoice created, waiting for payment |
processing | On-chain payment detected, awaiting block confirmation |
paid | Payment confirmed — safe to fulfill |
expired | Invoice expired with no payment |
underpaid | Invoice expired with a partial payment — manual review needed |
Step 1 — Create order and invoice
When a customer confirms their cart, create your internal order and the Coinsnap invoice together. Store the invoiceId immediately so you can match it when the webhook arrives.
- Node.js
- PHP
async function startBitcoinCheckout(cart, customer) {
// Create your order first
const order = await db.orders.create({
customerId: customer.id,
items: cart.items,
total: cart.total,
currency: cart.currency,
status: 'pending_payment',
});
// Create the Coinsnap invoice
const response = await fetch(
`https://app.coinsnap.io/api/v1/stores/${process.env.COINSNAP_STORE_ID}/invoices`,
{
method: 'POST',
headers: {
'x-api-key': process.env.COINSNAP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: cart.total,
currency: cart.currency,
orderId: order.id,
buyerEmail: customer.email,
redirectUrl: `${process.env.APP_URL}/checkout/result?orderId=${order.id}`,
}),
}
);
if (!response.ok) throw new Error('Failed to create Coinsnap invoice');
const invoice = await response.json();
// Link the invoice to the order
await db.orders.update({ id: order.id }, {
coinsnapInvoiceId: invoice.id,
});
return invoice.checkoutLink;
}
<?php
function startBitcoinCheckout(array $cart, array $customer): string {
global $db;
// Create your order first
$orderId = $db->createOrder([
'customer_id' => $customer['id'],
'total' => $cart['total'],
'currency' => $cart['currency'],
'status' => 'pending_payment',
]);
// Create the Coinsnap invoice
$ch = curl_init("https://app.coinsnap.io/api/v1/stores/" . getenv('COINSNAP_STORE_ID') . "/invoices");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . getenv('COINSNAP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => $cart['total'],
'currency' => $cart['currency'],
'orderId' => $orderId,
'buyerEmail' => $customer['email'],
'redirectUrl' => getenv('APP_URL') . '/checkout/result?orderId=' . $orderId,
]),
]);
$invoice = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400) throw new RuntimeException('Failed to create Coinsnap invoice');
// Link the invoice to the order
$db->updateOrder($orderId, ['coinsnap_invoice_id' => $invoice['id']]);
return $invoice['checkoutLink'];
}
Step 2 — Redirect to checkout
Send the customer to the Coinsnap-hosted payment page. It handles QR display, Lightning/on-chain switching, and real-time payment detection.
res.redirect(await startBitcoinCheckout(cart, customer));
Step 3 — Handle the return redirect
After payment (or expiry), Coinsnap redirects the customer to your redirectUrl. This is a UX signal only — not a payment confirmation. The webhook (Step 4) is the only reliable confirmation.
The customer may arrive in several states:
router.get('/checkout/result', async (req, res) => {
const order = await db.orders.findById(req.query.orderId);
switch (order.status) {
case 'paid':
return res.render('checkout/confirmed', { order });
case 'processing':
// On-chain payment detected — waiting for block confirmation
return res.render('checkout/processing', { order });
case 'expired':
return res.render('checkout/expired', { order });
default:
// Still pending — Lightning payment may be in-flight
return res.render('checkout/pending', { order });
}
});
Step 4 — Handle webhooks
Register your webhook URL in Coinsnap Dashboard → Webhooks. Always verify the signature before processing. See Webhooks for the verification code.
Handle all relevant event types and make each handler idempotent — a webhook may be redelivered manually.
router.post('/coinsnap', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify signature first (see Webhooks page for full verification code)
if (!verifyWebhookSignature(req.body, req.headers['x-coinsnap-sig'], process.env.COINSNAP_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
const event = JSON.parse(req.body);
const orderId = event.metadata?.orderId;
const order = await db.orders.findById(orderId);
if (!order) return res.status(200).send('OK'); // unknown order — ignore
switch (event.type) {
case 'Settled': {
if (order.status === 'paid') break; // idempotency guard
await db.orders.update({ id: orderId }, { status: 'paid' });
await sendOrderConfirmationEmail(orderId);
await triggerFulfillment(orderId);
break;
}
case 'Processing': {
// On-chain payment detected — waiting for block confirmation (~10 min)
// Do not fulfill yet. Show a "payment detected" message if the customer is waiting.
await db.orders.update({ id: orderId }, { status: 'processing' });
break;
}
case 'Expired': {
if (order.status === 'paid') break; // settled before expiry webhook arrived
if (event.additionalStatus === 'Underpaid') {
await db.orders.update({ id: orderId }, { status: 'underpaid' });
await notifySupport(orderId, 'Underpayment');
} else {
await db.orders.update({ id: orderId }, { status: 'expired' });
await releaseStockReservation(orderId);
}
break;
}
}
res.status(200).send('OK');
});
Why check order.status === 'paid' before Expired? In rare cases a Settled and Expired webhook can arrive in quick succession (e.g. an on-chain payment confirmed just as the invoice clock hit zero). The guard prevents marking a paid order as expired.
Step 5 — Let customers renew an expired invoice
When an invoice expires, the Coinsnap checkout page shows an expired state but does not redirect the customer back to your site automatically. The customer has to return themselves — so your expired page should offer a "Try again" button.
On renewal, create a fresh invoice for the same order. Do not create a new order.
router.post('/checkout/renew', async (req, res) => {
const order = await db.orders.findById(req.body.orderId);
if (!['expired', 'pending_payment'].includes(order.status)) {
return res.status(400).json({ error: 'Order cannot be renewed' });
}
// Same as initial checkout — create a new invoice for the existing order
const invoice = await createCoinsnapInvoice({
amount: order.total,
currency: order.currency,
orderId: order.id,
buyerEmail: order.customerEmail,
redirectUrl: `${process.env.APP_URL}/checkout/result?orderId=${order.id}`,
});
await db.orders.update({ id: order.id }, {
coinsnapInvoiceId: invoice.id,
status: 'pending_payment',
});
res.redirect(invoice.checkoutLink);
});
Order state machine
pending_payment
├── Processing webhook → processing
│ └── Settled webhook → paid → fulfill
├── Settled webhook → paid → fulfill
├── Expired webhook → expired → release stock, offer renewal
└── Expired + Underpaid → underpaid → manual review
Checklist
Before going live:
- Signature verification is in place before any webhook processing
- All webhook handlers are idempotent (safe to run twice)
-
Processingstate is handled — do not fulfill on-chain payments untilSettled - Expired invoices release stock reservations
- Customers can renew an expired invoice without creating a duplicate order
- Your
redirectUrlresult page handles all order states gracefully