Frontend checkout
Create the order on your own server, then pass its id to the UI. The browser
never receives NWC, provider credentials, or swap_data. It never chooses the
charged amount either.
const order = await createMyOrder(cart);
return <Checkout reference={order.id} prefix="/openreceive" />;
<Checkout> talks to the OpenReceive routes you already mounted under
prefix. Your app owns the order-creation route. It also owns the
authorization that those requests pass through.
- On mount it calls
prepareCheckout(POST/checkouts/prepare). That locks the amount and returns the payment methods. It does not create a Lightning invoice yet. - Bitcoin calls
requestCheckout(POST/checkouts) to mint or reuse a bolt11. - A swap asset starts a swap (POST
/swaps) instead. - Later status and swap calls send the same reference plus the
payment_hash. Yourauthorizehook still runs.
prefix is the path you mounted the router at. In the example it is
/openreceive. Every browser URL is prefix plus a fixed path, so you have
nothing else to keep in sync. If the router sits inside another mount, such as
app.use("/api", openReceiveExpress(...)), pass /api/openreceive.
Pass polling={false} to render a snapshot without polling.
Your application API serves order summaries and resume pages. OpenReceive does not.
Props that matter
Full list: API reference → Browser & React.
reference: the order id, for create mode (the usual path). Or passcheckoutfor a snapshot you already loaded.prefix: the mount path, as the browser sees it.csrfHeader: the name of the header that carries the page’s<meta name="csrf-token">value on every request. The meta tag name is fixed. Only the header name varies by framework:- Rails and Laravel read the default
X-CSRF-Tokenand need nothing. - Django reads
X-CSRFToken. - WordPress REST reads
X-WP-Nonce.
The prop has the same name in all four wrappers. On the custom element it is
csrf-header.- Rails and Laravel read the default
onSettled,onError,onState: settlement, failures, and every attempt the checkout watches. If you want the payer to come back to a refund later, store the swappayment_hashfromonState.resumable: whether a closed tab has a URL that brings the payer back. The checkout infers it fromsyncUrl/routeReference. Set it yourself when your router owns a per-order path the component cannot see. It decides which warning the refund screen shows. See Checkout UX → The refund screens.resumePaymentHash: after prepare, reopen that attempt instead of showing the method grid./checkouts/preparereturns no attempts. If the server will not serve the hash, the checkout ignores it. See Swap refunds. The custom element takes these as attributes; see The way back to a refund.theme,themeToggle,defaultTheme,children,components,classNames: the look around the payment UI. See Theme below.childrenandcomponents/classNamesare React-only. Vue, Svelte, and Angular wrap the same custom element.
There is no image prop. Everything the checkout draws ships inside the
JavaScript: the payment-method icons, the wallet logos and the pay tutorials.
You have no image file to copy or serve and no asset option to set, with any
bundler or none. Deploy the complete JavaScript and CSS build output, including
generated JavaScript chunks. Code-splitting builds may load tutorial
screenshots the first time a tutorial opens. Single-file builds include them
up front. If your Content-Security-Policy has a strict img-src, allow data:
(Provider registry).
To use the same engine with your own layout, use useCheckout, the hook behind
<Checkout>. It returns the live snapshot, status labels, and
copyInvoice / openWallet / retry. Use openWallet on touch devices
only. Use checkoutLabels for any string you put on the screen.
Importing @openreceive/react plus its styles.css adds roughly 530 kB
minified (~150 kB gzipped) to a production chunk. If that size matters for the
rest of your site, lazy-load the checkout route so only payers download it.
Without a bundler
If your site has no JS build step, use the standalone build. Examples are a
WordPress plugin, a Django template, or a plain PHP page. @openreceive/elements
ships the standalone build under dist/standalone/. You can also get it from
npm with import "@openreceive/elements/standalone/openreceive-checkout.js".
Every GitHub release attaches it as standalone-checkout-<version>.tar.gz. It
is two files plus a manifest:
openreceive-checkout.js: one self-contained ES module. It inlines every@openreceive/*dependency. It registers<openreceive-checkout>and<openreceive-theme-toggle>as soon as it loads, and still exports the package’s named API. Identifiers are not mangled. Only whitespace is minified. A source map sits beside it.openreceive-checkout.css: the same scoped stylesheet asstyles.css.MANIFEST.json: the workspace version and a SHA-256 hash per file. Use it to check a copied tree against the release it came from.
Copy the directory somewhere your server serves static files from, and add two tags:
<link rel="stylesheet" href="/static/openreceive/openreceive-checkout.css" />
<script type="module" src="/static/openreceive/openreceive-checkout.js"></script>
<openreceive-checkout
reference="ord_123"
prefix="/openreceive"
></openreceive-checkout>
That is all you serve. Everything the checkout draws is inside
openreceive-checkout.js: the payment-method icons, the wallet logos and the
pay tutorials. The standalone bundle is one file, so the tutorials are not a
separate chunk here. If your Content-Security-Policy has a strict img-src,
allow data: (Provider registry → Assets).
The build is reproducible. Running npm run build:packages in the
repository regenerates the
directory from the same commit, using
tools/package/build-standalone-elements.mjs. npm run check:standalone
checks a copy against its manifest.
The way back to a refund
With swaps on, a payer may have to come back to this page for a refund. See
Swap refunds for why. The element has the same controls as
React’s <Checkout>, as attributes and one event:
| React prop | <openreceive-checkout> |
|---|---|
syncUrl, resumePathPrefix, routeReference |
sync-url, resume-path-prefix, route-reference |
resumable |
resumable |
resumePaymentHash |
resume-payment-hash |
resumePaymentRail |
resume-payment-rail |
onState |
the openreceive-state event, with the snapshot at event.detail.state |
A server-rendered app usually serves the checkout at a per-order URL already,
such as /checkout/ord_123. Say so with resumable. Then keep the swap’s
payment hash, and hand it back on the next visit:
<openreceive-checkout id="checkout" reference="ord_123" prefix="/openreceive" resumable>
</openreceive-checkout>
<script>
const checkout = document.getElementById("checkout");
const key = `openreceive.swap.${checkout.getAttribute("reference")}`;
// Reopen the swap this order already has, instead of the method grid.
const saved = localStorage.getItem(key);
if (saved) checkout.setAttribute("resume-payment-hash", saved);
checkout.addEventListener("openreceive-state", (event) => {
const { state } = event.detail;
if (state.rail === "swap" && state.payment_hash) localStorage.setItem(key, state.payment_hash);
});
</script>
localStorage brings back only the payer’s own browser. To survive a change of
device, post the hash to your own order route and render it into
resume-payment-hash from the server. The checkout ignores a hash the server
will not serve, so a stale one does no harm.
If the page has no per-order URL, add sync-url. The element then pushes
{resume-path-prefix}/{reference} (default /checkout/ord_123) into the
address bar, and your server has to serve the checkout at that path.
Theme
The checkout sets data-theme="light" / "dark" on its root and styles
itself from CSS variables under it. It picks the theme in this order:
- the payer’s stored choice (
localStorage["openreceive.theme"]) defaultTheme- the system
prefers-color-scheme
The stylesheet never reaches outside that root. Every rule in styles.css is
scoped to data-openreceive-root. The checkout, the theme toggle and the
wrapper shell each set that attribute on themselves. Your page keeps its own
resets, headings, buttons and theme variables, whatever order the stylesheets
load in.
If the page that embeds the checkout always uses one theme, lock it and skip all of that:
<Checkout reference={order.id} prefix="/openreceive" theme="dark" />
<openreceive-checkout reference="ord_123" theme="dark"></openreceive-checkout>
The lock wins over the stored preference and any ancestor ThemeScope. It
also hides the toggle, because a locked theme has nothing to toggle.
theme="system" locks to prefers-color-scheme alone.
themeToggle={false} hides the packaged toggle button. The checkout still sets
and styles its resolved theme.
defaultTheme is only the starting point until the payer makes a choice. Use
theme when your site, not the payer, makes the decision.
Server-rendered pages paint the default theme before the stored preference
applies on mount. To avoid that one-frame flash, pass theme or a
server-readable storage.
The “Creating checkout…” and “Could not start checkout.” screens, shown before a checkout is created, use the same resolved theme.
Layout and surface
The checkout lays itself out by its own width, not the viewport’s. Its
root is a CSS size query container (container-type: inline-size, named
openreceive). Every internal breakpoint is a container query at the usual
values:
- one column of payment methods under 40rem of checkout width
- two columns from 40rem
- three from 48rem
- four from 64rem
- the two-column wallet list from 48rem
So a checkout mounted in a 560px card on a 1280px page lays out like a 560px
screen. You only need to do one thing: give the root a width. It is a
block-level element, so any normal container works. As a flex or grid item,
add flex: 1 / width: 100%. An inline-size container cannot size itself from
its contents. Container queries and cqw units have worked in every browser
since 2023.
The root paints the theme’s base-100 surface, and adds its own padding and
rounded corners (p-4, rounded-box). You can change both on
[data-openreceive-root]:
- If you draw your own card around the checkout and want no second surface,
set
--root-bg: transparent. - For a tighter or looser inset, override
padding.
Both work because every rule in the shipped stylesheet is wrapped in :where()
and has zero specificity. Any selector of yours, however plain, wins on the
same property. We promise to keep it that way for compatibility.
Serve the compiled styles.css without Tailwind processing. Import it from
JavaScript with a CSS-capable bundler, or use a plain <link rel="stylesheet">.
Do not @import it into your own Tailwind entry file. Its zero-specificity
rules let your styles override checkout styles. Scoping does not prevent
that.
For a custom element in a flex or grid layout, give the host a width:
openreceive-checkout { display: block; width: 100%; }
Show the payer what they are buying
Return a description beside the price. Both drop-ins print it above the
amount on every screen. It also becomes the invoice memo, so the payer’s wallet
shows it too:
amountFor: async (reference) => {
const order = await orders.find(reference);
if (order === null) return null;
return {
currency: "USD",
value: order.total,
description: `${order.lines.length} items from the shop`,
};
},
config.amount_for = lambda do |reference|
order = Order.find_by(id: reference)
order && { currency: "USD", value: order.total.to_s,
description: "#{order.line_items.size} items from the shop" }
end
It is one display string. It travels only in the prepare and create responses, never in a request body.
For a richer summary, pass markup. React takes children as a node or a
render prop:
<Checkout reference={order.id} prefix="/openreceive">
{(model) => (
<ul className="order-lines">
{cart.lines.map((line) => (
<li key={line.id}>
{line.quantity} × {line.name}
</li>
))}
</ul>
)}
</Checkout>
Children add to the payment UI. They render above it and never replace it.
That is the same position as the custom element’s order slot. To build your
own checkout instead, use useCheckout or the headless API, not children.
The custom element uses a named slot:
<openreceive-checkout reference="ord_123" prefix="/openreceive">
<ul slot="order" class="order-lines">
<li>2 kg Ataulfo mangoes</li>
</ul>
</openreceive-checkout>
Showing the payer their receipt
<TransactionDetails> is the collapsible panel that shows the payment hash and
deposit txid. @openreceive/elements offers the same panel as
renderTransactionDetailsHtml / createTransactionDetailsElement.
<Checkout> already renders it. You can also mount it on your order page:
<TransactionDetails state={await loadCheckoutState(order.id)} />
Building your own UI
The drop-in already follows the rules for what payers see. If you replace it, following those rules is your job. Checkout UX is the short list, and Headless checkout is the API.
Use resumePaymentRail="lightning" (HTML: resume-payment-rail="lightning")
with a host-selected pending Lightning hash to reopen its invoice through the
normal serialized create/reuse path. Omit the rail for existing swap recovery.
Near expiry the create policy may replace a Lightning invoice; swap recovery
continues to load the exact historical attempt for refunds.