draft pending code stabilization
Sourced from: docs/09-CHECKOUT-WIDGET-SPEC.md §1, §5, §6, §7, §9, §10; docs/07-API-REFERENCE.md §2; docs/DECISIONS.md D5.7, D8.1, D8.3, D8.4

Widget embed

This page documents the planned embed surface as specified in docs/09-CHECKOUT-WIDGET-SPEC.md. As of this writing, packages/widget contains the checkout screens and state machine but not yet the ZuPayments.mount/redirect SDK, the /sdk/zupayments.js bundle, or the postMessage bridge described below. Treat the code samples on this page as the target API, not a currently-working integration — check back or watch the ZuPayments changelog before wiring this into production.

Two ways to check out

You don't have to build any checkout UI yourself. Every session you create (see Sessions) can be presented to your customer one of two ways, chosen by the mode field at session creation:

  1. Redirect (mode: "redirect") — send the customer to the checkout_url from the session response. This is a full-page, hosted checkout served entirely by your node. No SDK required.
  2. Embed (mode: "embed") — mount the checkout inside your own page as an iframe, using the JS SDK your node serves at /sdk/zupayments.js.

Both are served by your own node, never by zupayments.com — this preserves the same trust boundary as the rest of ZuPayments: the platform never sees a key, a token, or a coin flow.

Redirect mode

No SDK needed — just navigate the browser to the checkout_url your backend got back from POST /v1/sessions:

// server-side: create the session
const session = await createZuPaymentsSession({ amount: "125", order_id: "order_8842", mode: "redirect", ... });

// client-side or via a 302 response:
window.location = session.checkout_url;

The hosted page handles the entire flow — code display, QR, live status, expiry, and (if you configured success_url/cancel_url) navigates the customer back to your site on completion.

Embed mode (planned SDK surface)

<div id="pay"></div>
<script src="https://<your-node-host>/sdk/zupayments.js"></script>
<script>
  // sessionToken is the `token` field from your backend's POST /v1/sessions
  // response (mode: "embed"). Never create sessions from the browser — your
  // site key must never reach client-side code.
  const checkout = ZuPayments.mount(sessionToken, "#pay", {
    mode: "embed",
    theme: { accent: "#5B3DF5" },   // hint only — your node's settings are authoritative
    onSuccess: (e) => { window.location = "/thank-you?order=" + e.order_id; },
    onExpired: () => { /* widget shows its own retry UI by default */ },
    onCancelled: () => { window.location = "/cart"; },
  });
  // returns a handle: { destroy(), on(type, cb), off(type, cb), sessionId }
</script>

ZuPayments.mount(sessionToken, container, options):

Param Type Notes
sessionToken string The single-session, single-use token your backend received from POST /v1/sessions. The SDK never creates sessions itself.
container string | Element CSS selector or element to host the iframe.
options.mode "embed" Default.
options.theme { accent?, logoUrl? } Client-side hint only — see Theming.
options.onSuccess / onExpired / onCancelled function Sugar over .on(type, cb), mirroring the postMessage events below.

The SDK contains no secrets and holds nothing beyond the opaque checkout token you pass it.

What the widget sends your page (postMessage)

The embedded iframe and your page communicate only through postMessage, with every message carrying source: "zupayments" and a version tag v: 1:

// payment verified, co-signed, session paid
{ "source": "zupayments", "v": 1, "type": "zupayments:success",
  "session_id": "sess_7Qk2...c9", "order_id": "A1B2-9F" }

// 5-minute code lapsed, no payment
{ "source": "zupayments", "v": 1, "type": "zupayments:expired",
  "session_id": "sess_7Qk2...c9", "order_id": "A1B2-9F" }

// customer cancelled
{ "source": "zupayments", "v": 1, "type": "zupayments:cancelled",
  "session_id": "sess_7Qk2...c9", "order_id": "A1B2-9F" }

// auto-sizing — the SDK applies this to the iframe height for you
{ "source": "zupayments", "v": 1, "type": "zupayments:resize", "height": 612 }

// handshake ack once the widget has mounted
{ "source": "zupayments", "v": 1, "type": "zupayments:ready" }

If you're using the SDK's mount() helper, you don't need to listen for these directly — use the onSuccess/onExpired/onCancelled options or the returned handle's .on(type, cb). session_id and order_id are the only identifiers that ever cross the postMessage boundary; no keys, transfer codes, or amounts are treated as authoritative on this channel.

Realtime updates

Whether redirect or embed, the checkout surface stays live via Server-Sent Events at GET /v1/checkout/:token/events, falling back to polling GET /v1/checkout/:token/state every 2 seconds if the SSE connection drops. This is handled entirely by the hosted page/widget — you don't need to implement this yourself. The canonical event vocabulary (state, queue, wallet_allocated, paid, mismatch, expired, cancelled) is defined once in docs/07-API-REFERENCE.md §2 and is what the widget consumes internally.

Theming

Configurable from your node's settings (the source of truth) — logo URL, accent color, and light/dark preference. The SDK's theme option in mount() is only a client-side hint; your node's configuration wins. Only two brand knobs (logo + accent) are supported in v1, by design, to keep every merchant's checkout coherent and accessible. Accent color is contrast-validated at render time.

Sizing

The widget measures its own content and posts zupayments:resize on change; the SDK applies the resulting height to the iframe automatically. Width is always fluid to 100% of the container you provide — set a max-width on your container if you want to constrain it. The iframe requires allow="clipboard-write" (handled by the SDK) so the in-widget "Copy code" button works.

Anti-abuse behavior to expect

  • A checkout token addresses exactly one session — the SDK cannot enumerate or create sessions.
  • If any checkout endpoint returns 429, the widget shows a calm rate-limit message and disables retry/paste/regenerate actions until Retry-After elapses. You don't need to implement this yourself.
  • The widget makes no third-party network calls — only same-origin requests to your node.