Checkout Flow

How data moves from session creation to payment confirmation.

Table Relationships

Merchant
   │ has_many
   ▼
CheckoutSession ──belongs_to──▶ Order ──has_one──▶ Invoice ──has_one──▶ Payment
   │                              │                   │                   │
   │ token                        │ order_number      │ payment_request   │ preimage
   │ amount_cents                 │ total_amount_sats │ payment_hash      │ amount_sats
   │ customer_*                   │ items (jsonb)     │                   │ btc_fiat_price
   │ payment_method               │ metadata (jsonb)  │                   │ fiat_variance
   │ success_url / cancel_url     │                   │                   │ confirmed_at
   │ expires_at (15 min)          │                   │ expires_at        │
   │                              │                   │                   │
   └─ UI session (disposable)     └─ Business record  └─ Network request  └─ Settlement proof
                                     (permanent)        (per attempt)       (immutable)

Why Four Tables?

Each table captures a distinct stage with a different lifecycle:

Table Purpose Lifecycle
CheckoutSession Temporary UI session. Captures customer intent and info before any money is involved. Expires in 15 minutes. Disposable
Order Permanent business record. The merchant's receipt — tied to items, amounts, and a wallet. Feeds reports, dashboards, and refunds. Permanent
Invoice Payment-network request. Holds the BOLT-11 string (Lightning) plus confirmation tracking. One Order may need a new Invoice if the first expires. Per attempt
Payment Proof of settlement. Records the exact BTC/fiat price at confirmation, the preimage, and calculates price variance. Immutable
Key insight: Separating Invoice from Order allows a single Order to survive invoice expiration. If a Lightning invoice expires after 1 hour, a new Invoice can be generated for the same Order with an updated BTC price — without losing the order context.

Entry Points

Three ways to start a checkout, one unified flow:

API

POST /api/v1/checkout_sessions

Server-side integration. Returns checkout_url to redirect customer.

Payment Link

GET /pay/:slug

Zero-code. Share a URL via email, social media, or QR code.

Embed Button

data-key="pk_live_..."

JavaScript widget. Opens checkout overlay from any webpage.

All three converge at the same CheckoutController, entering the 3-step pipeline below.

Step-by-Step Data Flow

1
Session Created

API, payment link, or embed widget creates a CheckoutSession. Customer is redirected to the checkout page.

WRITE: checkout_sessions

token, amount_cents, currency, merchant_id, product_id, success_url, cancel_url, metadata, expires_at

2
Collect Customer Info optional

If the merchant requires customer info collection and it was not pre-filled via API, the customer fills out a form.

WRITE: checkout_sessions

customer_email, customer_name, customer_phone, customer_address

4
Order + Invoice Created

On the first visit to the QR code page, the system creates an Order, links it to the session, converts the fiat amount to sats at the current BTC price, and generates an Invoice with a BOLT-11 payment request.

WRITE: orders

merchant_id, wallet_id, total_amount_cents, total_amount_sats, items, metadata, status = pending

WRITE: checkout_sessions

order_id = new order

WRITE: invoices

payment_request, payment_hash, amount_sats, expires_at

5
QR Code + Monitoring

The checkout page displays a QR code. Background jobs begin polling for payment:

PaymentMonitorJob polls every 5 seconds (up to 15 minutes).

The checkout page listens on WebSocket (ActionCable) for instant updates, with HTTP polling every 5 seconds as fallback.

Payment Confirmed

When a background job detects settlement:

WRITE: invoices

status = paid, paid_at

WRITE: orders

status = paid

WRITE: payments

amount_sats, preimage, btc_fiat_price, fiat_value, fiat_variance, confirmed_at

WRITE: checkout_sessions

status = completed

WebSocket broadcasts to checkout page, which redirects the customer to success_url. Webhooks fire asynchronously.

Session Expired (no payment)

If 15 minutes pass with no payment detected:

WRITE: invoices

status = expired

WRITE: checkout_sessions

status = expired

Customer is redirected to cancel_url.

Status Transitions

CheckoutSession
pending ──▶ completed  (paid)
pending ──▶ expired    (15 min)
pending ──▶ cancelled
Order
pending ──▶ invoice_generated
        ──▶ paid
        ──▶ cancelled
paid    ──▶ refunded
Invoice
pending ──▶ paid      (settled)
pending ──▶ expired   (TTL)
pending ──▶ cancelled
Payment
No status enum.
Created once on confirmation.
Immutable after creation.

Proof: preimage (Lightning)

Every Database Write, In Order

# Trigger Table Operation
1 API call / payment link / embed checkout_sessions INSERT token, amount, currency, merchant, product, urls, metadata, expires_at
2 Customer submits info form checkout_sessions UPDATE customer_email, customer_name, customer_phone, customer_address
3 First visit to QR page orders INSERT merchant, wallet, amounts, items, metadata
5 First visit to QR page checkout_sessions UPDATE order_id = new order
6 Invoice generation orders UPDATE total_amount_sats (fresh BTC price)
7 Invoice generation invoices INSERT payment_request, payment_hash, amount_sats, expires_at
8 Background job detects payment invoices UPDATE status = paid, paid_at
9 Background job detects payment orders UPDATE status = paid
10 Background job detects payment payments INSERT amount_sats, preimage, btc_fiat_price, fiat_value, fiat_variance, confirmed_at
11 Background job detects payment checkout_sessions UPDATE status = completed

Ready to integrate?

Create a checkout session with a single API call.