Headless checkout

There are two supported ways to build a checkout UI:

  1. Drop-in: <Checkout> from @openreceive/react, or <openreceive-checkout> from @openreceive/elements, plus the Vue/Svelte/Angular wrappers. Start here: Frontend checkout.
  2. Headless: your own components, built on @openreceive/browser/headless. This surface is supported and covered by semver. The Buy a Button example is a mobx-keystone store built on this engine.

OpenReceive’s own renderers import exactly this surface. Anything they can do, a headless integration can do too. Everything not listed here is private to the package.

Checkout UX has the rules for what to render. This page is the API.

One URL: prefix

Every server call takes prefix, the path where you mounted the shipped router. The default is /openreceive. Each call adds its own route to it: /checkouts, /checkouts/prepare, /payments/check, /swaps, /swaps/quote, /swaps/status, /swaps/refunds. You cannot override a single route.

const snapshot = await prepareCheckout({ reference, prefix: "/openreceive" });
const refresh = createStatusFetcher({ prefix: "/openreceive", snapshot });
const started = await startSwapRequest({
  fetch: globalThis.fetch,
  prefix: "/openreceive",
  reference,
  payInAsset: "USDT_TRON",
});

The @openreceive/browser/headless surface

Start with two objects. If you skip past them, you will likely end up rewriting a poll loop.

Checkout lifecycle:

Payment methods and wizard:

Swap flows:

Refunds:

Rendering:

Formatting and labels:

The receipt:

Styling tokens, shared with the shipped styles.css:

The custom-element helpers live on @openreceive/elements, not here: defineElements, createThemeToggleElement, OPENRECEIVE_CHECKOUT_ELEMENT_TAG_NAME / _ATTRIBUTES / _EVENTS.

Progress is a status, not a position

Do not draw a Cart → Pay → Done bar. Render createCheckoutStatusModel’s title / detail / countdownLabel, and read the model’s phase. To let the payer go back, use checkoutLabels.switchPaymentMethod.

When the countdown runs out in the browser, hide the payment instructions and the countdown. Settlement monitoring keeps going. Hide expired QR codes and deposit instructions. The controller keeps checking a pending Lightning payment until the server resolves it. It tracks swap refunds separately, because a failed or expired wallet payment can still need a provider refund.

A headless CheckoutSession uses reference() and prefix() as its identity.

When the identity changes, clear your order-specific selection and refund draft. The session aborts the requests it can abort. It discards stale results, errors and loading updates. Theme changes keep the current attempt. The shipped framework bindings handle this lifecycle for you.

The method picker, and what to say about a method you cannot offer

All four use the same figures and rounding. Pick the one that matches the shape you are rendering.

Network selection: only ask when it is a real question

payment_methods groups by label. USDT has several networks. SOL and ETH have one each. A group with one option has no network question, so start the swap straight from the tile.

The deposit values are the payer’s to reproduce

On token rails, the deposit QR holds only the address. The payer types the amount by hand. Give each of these fields its own labelled copy row:

Field Row
depositAddress Address
depositMemo Memo. When present, it is part of the address.
depositAmount Amount, copied bare (no asset symbol)

createSwapDisplayModel already builds copyRows this way.

Refunds

Exactly one provider state allows a refund: refund_required. When you confirm, the server re-reads the live state and may answer 409. Treat that as a normal outcome.

A refund takes two steps. Only the second submits:

await controller.stageSwapRefund({ attemptId: swap.attemptId, refundAddress });
await controller.confirmSwapRefund({ attemptId: swap.attemptId, refundAddress });

Validate with getSwapRefundFormError(payInAsset, address, networkLabel) before submit.

enterCheckoutResumePath writes the per-order URL into browser history. Tell the display model whether the payer can come back:

createSwapDisplayModel(invoice, { resumable: true });

and render display.refundReturnLabel. The resume helpers (createGuestCheckoutResume, createGuestOrderFetcher) are on @openreceive/browser, not /headless. See What is deliberately not on this surface and Swap refunds.

State Meaning
creating_provider_order, awaiting_deposit, confirming, exchanging, paying_invoice in progress
completed the provider is done. This is not settlement.
refund_required → refund_pending → refunded the refund path
expired, failed, attention terminal, or needs a human

refund_reason is underpaid, overpaid, late_deposit, underpaid_and_late, or overpaid_and_late. An overpayment is refunded like any other emergency. The whole deposit comes back, never just the surplus.

The receipt is not debug output

After settlement, the payer has a payment hash. On a swap they also have a deposit txid. Show them both.

interface TransactionDetailRow {
  label: string;
  value: string;       // possibly shortened for display
  copyValue?: string;  // the full string — copy this
  href?: string;
  hrefLabel?: string;
}

Copy row.copyValue ?? row.value. The bolt11 gets a decode link only when you pass decodeLinkUrl. Render the panel collapsed, both on the live checkout and on the order page. @openreceive/react’s <TransactionDetails> mounts the same panel.

Symbol inventory

The sections above name the symbols a custom UI actually calls. A script checks that nothing is missing: every export the prose does not name is listed below. The full sorted list is in docs/internal/headless-surface.md.

Also on the surface, in no group above (98 symbols) — element and theme plumbing, wizard/icon helpers, attribute parsers and log types:

What is deliberately not on this surface

@openreceive/browser has two entry points. /headless is the engine under a custom UI. The main entry is the drop-in’s own surface. They do not re-export each other.

createGuestCheckoutResume and createGuestOrderFetcher are the resume helpers a swap checkout needs for an honest refund form. They live on the main entry because they depend on your app: your storage and your order fetch.

18 names on @openreceive/browser that /headless does not carry: