Skip to main content

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 statusMeaning
pending_paymentInvoice created, waiting for payment
processingOn-chain payment detected, awaiting block confirmation
paidPayment confirmed — safe to fulfill
expiredInvoice expired with no payment
underpaidInvoice 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.

routes/checkout.js
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;
}

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:

GET /checkout/result
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.

POST /webhooks/coinsnap
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.

POST /checkout/renew
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)
  • Processing state is handled — do not fulfill on-chain payments until Settled
  • Expired invoices release stock reservations
  • Customers can renew an expired invoice without creating a duplicate order
  • Your redirectUrl result page handles all order states gracefully