BchainPay logoBchainPay
EngineeringBitcoinLightning NetworkPaymentsBOLT

Lightning Network invoice lifecycle for online merchant checkout

BOLT-11 invoice anatomy, HTLC lifecycle, preimage-as-receipt, and routing failures: integrating Bitcoin Lightning Network into a BchainPay payment intent.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for Lightning Network invoice lifecycle for online merchant checkout

Bitcoin on-chain payments give merchants cryptographic settlement, but they ask customers to wait 10-60 minutes and require wallets to watch the mempool for fee spikes. Lightning Network solves both: a payment settles in 1-3 seconds at sub-satoshi routing fees, and the merchant receives a 32-byte secret — the payment preimage — that proves settlement without any block confirmation.

This post is the mechanics behind that receipt: BOLT-11 invoice format, the HTLC lifecycle from creation to settlement, the failure modes that trip up engineers integrating Lightning for the first time, and how BchainPay wraps all of it behind the same payment-intent API you already know.

The BOLT-11 invoice: what lives inside the string#

A Lightning invoice is a bech32-encoded string. It starts with lnbc (mainnet), lntb (testnet), or lnbcrt (regtest), followed by an optional amount and a data payload. Decode one with any BOLT-11 library and you get:

{
  "payment_hash":           "7f3a9c4b8d2f1e0a5c7b3d9e2f1a4c6b8d0e3f2a1c5b7d9e2f3a4c6b8d0e1f",
  "amount_msat":            49056000,
  "timestamp":              1745756400,
  "expiry":                 1800,
  "description":            "BchainPay:pi_01HW4ABCDE",
  "min_final_cltv_expiry":  18,
  "routing_hints":          []
}

The fields that matter most for merchant checkout:

  • payment_hash: SHA-256 of the 32-byte preimage the merchant's node holds. The payer uses this to route. You store it in the payment intent row as the primary lookup key for settlement confirmation.
  • amount_msat: Amount in millisatoshis. Denominating in msat (1 sat = 1,000 msat) allows sub-satoshi fee arithmetic across hops.
  • expiry: How many seconds the invoice is valid. After this the payment hash is invalid and any in-flight HTLC will fail. Always set it to match expires_in on the BchainPay intent exactly.
  • min_final_cltv_expiry: The minimum block window the last hop must leave for the final HTLC. A higher value is safer for the merchant but makes the invoice harder to route through nodes with tight CLTV delta policies. The LND default of 18 blocks works for production.
  • routing_hints: If your node has no public channels, include private channel hints here so the payer's wallet can find a path. BchainPay's hosted Lightning includes these automatically.

The HTLC lifecycle#

An HTLC (Hash Time-Locked Contract) is the primitive that moves value across a Lightning path. Each channel in the route holds one HTLC simultaneously, and they all settle — or all time out — atomically.

Payer ──[HTLC A]──► Node 1 ──[HTLC B]──► Node 2 ──[HTLC C]──► Merchant
       amt + fees          amt + fees            amt

Phase 1 — Set up (HTLC add). The payer sends an update_add_htlc to its channel partner with: the payment_hash, an amount, and a CLTV expiry height. The message also contains an onion-encrypted routing packet (Sphinx onion) that tells each intermediate node only the next hop, not the full path. Each node forwards a new HTLC downstream, reducing the amount by its own routing fee and subtracting one CLTV level.

Phase 2 — Settle (preimage reveal). The merchant's node holds the preimage R such that SHA-256(R) == payment_hash. Once the final HTLC arrives, the node calls update_fulfill_htlc(R) on Node 2. Node 2 now has proof it delivered the payment, so it calls update_fulfill_htlc(R) on Node 1. Node 1 calls it on the payer. All HTLCs settle atomically in a cascade flowing backward along the path. The merchant's balance increases; each routing node captures its fee.

Phase 3 — Timeout (HTLC fail). If the preimage is never revealed before the CLTV expiry height arrives on-chain, every HTLC times out and funds return to their respective senders. There is no partial settlement. The entire payment either completes or reverts.

Generating an invoice via LND#

BchainPay runs regionally distributed LND nodes. When a merchant creates a Lightning intent, the platform calls LND's POST /v1/invoices:

const lndRes = await fetch(`${LND_REST_HOST}/v1/invoices`, {
  method: 'POST',
  headers: {
    'Grpc-Metadata-macaroon': INVOICE_MACAROON,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    memo:            `BchainPay:${intentId}`,
    value_msat:      amountMsat,
    expiry:          1800,
    private:         false,
    description_hash: descHashHex, // SHA-256 of full intent JSON
  }),
});
 
const { payment_request, r_hash } = await lndRes.json();
// r_hash is base64(payment_hash) — store it for settlement lookup
await db.query(
  `UPDATE payment_intents SET lightning_payment_hash = $1,
   lightning_invoice = $2 WHERE id = $3`,
  [Buffer.from(r_hash, 'base64').toString('hex'), payment_request, intentId]
);

The payment_request string goes into the intent response and is QR-encoded in the checkout UI. The hex r_hash is what you query against when LND fires a settlement event.

BchainPay payment intent for Lightning#

From the merchant API surface, a Lightning intent is created the same way as any other:

curl -X POST https://api.bchainpay.com/v1/payment_intents \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount":     { "value": 49.99, "currency": "USD" },
    "accept":     ["BTC.lightning"],
    "metadata":   { "order_id": "ord_88712" },
    "expires_in": 1800
  }'

Response (abbreviated):

{
  "id":              "pi_01HW4ABCDE",
  "status":          "awaiting_payment",
  "chain":           "bitcoin.lightning",
  "payment_request": "lnbc490560n1pn0xjggpp5...",
  "payment_hash":    "7f3a9c...b81e",
  "amount_msat":     49056000,
  "expires_at":      "2026-04-27T13:00:07Z"
}

Pass payment_request to any BOLT-11-compatible wallet. The payment_hash is your handle on the payment for webhook reconciliation.

Confirming settlement: the preimage is the receipt#

Settlement happens before any block is mined. LND streams invoice state changes via its InvoiceSubscription endpoint. When an invoice settles, the update carries the preimage:

const stream = await fetch(`${LND_REST_HOST}/v1/invoices/subscribe`, {
  headers: { 'Grpc-Metadata-macaroon': INVOICE_MACAROON },
});
 
for await (const chunk of stream.body) {
  const inv = JSON.parse(chunk);
  if (inv.result?.state !== 'SETTLED') continue;
 
  const preimageHex = Buffer.from(inv.result.r_preimage, 'base64').toString('hex');
  // Sanity check: SHA-256(preimage) must equal the stored payment_hash
  const derivedHash = crypto.createHash('sha256')
    .update(Buffer.from(preimageHex, 'hex'))
    .digest('hex');
 
  const intent = await db.query(
    `SELECT * FROM payment_intents WHERE lightning_payment_hash = $1`,
    [derivedHash]
  );
  if (!intent) continue;
 
  await markIntentConfirmed(intent.id, {
    preimage:            preimageHex,
    amount_received_msat: inv.result.amt_paid_msat,
    settled_at:          inv.result.settle_date,
  });
}

The preimage check matters: you are verifying that SHA-256(preimage) == payment_hash. LND does this internally, but verifying it yourself in the settlement handler costs one hash and prevents a software bug from crediting a payment with a mismatched hash.

One subtlety: amt_paid_msat can exceed amount_msat if the payer overpaid. Accept overpayments silently (they happen with some wallets doing rounding); treat underpayments below 0.1% of the required amount as full payment to absorb millisatoshi rounding.

Failure modes#

Routing failures#

The payer's wallet performs source-based routing with onion packets. If it cannot find a path with sufficient liquidity, payment fails before it reaches the merchant's node. The merchant sees nothing; the payer's wallet displays an error. Common failure reasons and their implications:

Failure Cause Merchant action
NO_ROUTE No path with enough liquidity Show on-chain fallback QR
TEMPORARY_CHANNEL_FAILURE Intermediate node offline or channel depleted Payer retries; no action needed
INCORRECT_PAYMENT_DETAILS Wrong payment hash or amount in invoice Check invoice generation logic
MPP_TIMEOUT Multi-part payment timed out assembling parts Payer retries; invoice still valid

Routing failures do not cancel the invoice. The payment_request remains valid until expiry elapses. Your checkout UI should poll GET /v1/payment_intents/:id rather than waiting for a payer-pushed event.

Invoice expiry#

After expiry seconds, LND rejects any attempt to pay the invoice with INVOICE_EXPIRED. Any in-flight HTLCs time out. The BchainPay intent transitions to expired at the same instant.

Do not attempt to extend a BOLT-11 invoice. There is no mechanism to do so; the payment hash is bound to the invoice at creation. Create a fresh intent instead; the new intent generates a new preimage, a new hash, and a new payment_request.

Channel liquidity exhaustion#

Your node needs inbound liquidity to receive payments. Inbound capacity is the remote side of a channel: if every channel you have is fully local, routing nodes cannot push sats to you. An invoice from a node with zero inbound capacity will fail with NO_ROUTE for all payers, silently.

Diagnosis: compare local_balance vs remote_balance across channels from GET /v1/channels. A healthy receiving node has substantial remote balance.

BchainPay's hosted Lightning manages inbound liquidity automatically through a just-in-time (JIT) channel service: when a payment arrives that would fail due to insufficient inbound, the LSP opens a new channel and routes the payment through it in the same payment attempt. Self-hosted integrations should provision channels from a well-connected LSP and monitor inbound headroom as an operational metric.

Watchtower configuration#

If your LND node goes offline while channels have pending HTLCs, a counterparty can broadcast a revoked commitment transaction (force-close fraud). A watchtower monitors the chain and publishes your penalty transaction if this happens.

Configure a watchtower in lnd.conf before going to production:

[Wtclient]
wtclient.active=true
wtclient.sweep-fee-rate=50

BchainPay's hosted Lightning includes watchtower coverage by default. For self-hosted nodes, use a separate watchtower daemon (LND's built-in watchtower subcommand or a third-party tower) running on an always-on host separate from the Lightning node itself.

BOLT-12 offers: one code for any payment#

BOLT-11 is single-use: one invoice, one payment. BOLT-12 offers are reusable. A merchant publishes a static offer string; the payer's wallet fetches a fresh invoice from the merchant's node via onion messages at payment time. No server-side invoice generation, no expiry management, no new QR code per order.

BOLT-12 requires option_onion_messages support in connected peers. LND 0.18+ and CLN 24.02+ both support it. BchainPay Lightning intents support BOLT-12 via the bolt12 flag:

curl -X POST https://api.bchainpay.com/v1/payment_intents \
  -d '{ "accept": ["BTC.lightning"], "bolt12": true, ... }'

The response includes both payment_request (BOLT-11, universal fallback) and offer (BOLT-12, reusable). Wallets that support BOLT-12 will use the offer; older wallets fall back to the BOLT-11 invoice. Ship both until BOLT-12 wallet penetration is high enough to drop the fallback.

Key takeaways#

  • The payment_hash in a BOLT-11 invoice is your lookup key. The preimage returned at settlement is the cryptographic receipt. Store both, and always re-derive the hash from the preimage in your settlement handler.
  • HTLCs settle atomically across all hops or time out entirely. There is no partial settlement state and no block confirmation required.
  • Subscribe to LND's InvoiceSubscription stream for real-time settlement detection. Do not rely on payer callbacks or polling the payment_request expiry.
  • Match expires_in on the BchainPay intent with expiry on the LND invoice exactly. A mismatch creates a window where the intent expires but the invoice is still payable, or vice versa.
  • Monitor channel inbound liquidity as an operational metric. Zero inbound capacity causes all payments to fail with NO_ROUTE at the routing layer, invisible from the merchant's node.
  • Configure a watchtower before any self-hosted LND node goes live. It is a three-line config change and prevents force-close fraud during downtime.
  • Ship BOLT-12 and BOLT-11 together. BOLT-12 offers are the right long-term primitive; BOLT-11 is the compatibility layer until wallet coverage catches up.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading