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