One URL per invoice — /pay/<trackId>. Server-rendered HTML, server-side QR generation, live countdown, coin picker with fiat pricing, no-JS fallback, strict CSP. The details a customer feels without knowing why: no spinner, no hydration flash, no blank-white page while the QR loads.
lightning: deep-link variant.LIVEexpiresAt so the server isn't pinged on every tick.LIVE<form> that GET-submits ?pay_currency=X, the server mutates + 303-redirects. Works with JavaScript disabled — lets us hit the 'unsafe-inline'-free CSP.LIVE<script> tags. XSS into an injected script tag is blocked. Every resource self-hosted; no third-party trackers, no GA, no Intercom.LIVEexpiresAt < now, the page derives "Expired" client-side. Prevents the reload loop that stale invoices cause on slow DB ticks.LIVEreturn_url is set.ENDYes — use POST /v1/payment/white-label instead of invoice. You receive a track_id + deposit address and render the UI yourself. The hosted checkout is optional.
We set X-Frame-Options: SAMEORIGIN by default; iframing the page from a third-party origin is blocked. Use the white-label API if you need iframe embedding.
The QR encodes a lightning:lnbc… URI; the "Open in wallet" button deep-links the same. On the pay page the countdown shows invoice expiry (typically 1h); settlement is atomic, so PAID appears within seconds of the payment being routed.
The page stays in WAITING until either the full amount is received or the invoice expires. Over-payment credits the merchant fully; under-payment holds. If you expect partial payments (some subscriptions do), use static-address + per-user attribution instead of one-shot invoices.
Semantic HTML, keyboard-navigable forms, visible focus, high-contrast palette on dark. No critical functionality gated on JS. Screen reader tested on VoiceOver + NVDA for the WAITING and PAID screens.
No white-flash hydration, no third-party tracker on your checkout, no "powered by" footer. Branding you configured, infra we run.