PaywallUI
PaywallUI is the UI wrapper around BillingClient / AuthClient. It opens a Shadow DOM modal, handles the auth-gate and checkout flow, emits typed events and exposes a public state machine for the host.
Opening
import { PaywallUI } from '@monetize.software/sdk';
const paywall = new PaywallUI({ paywallId: 'pw_123', auth: true });
document.getElementById('upgrade').onclick = () => paywall.open();
document.getElementById('login').onclick = () => paywall.openSignin();
document.getElementById('register').onclick = () => paywall.openSignup();
document.getElementById('help').onclick = () => paywall.openSupport();| Method | What it does |
|---|---|
open(opts?) | Opens the modal with the layout (prices + auth_panel/current_session per paywall config). opts: OpenOptions — identity, skipTrial, skipVisibility, renew. |
openAuth(opts?) | Opens only the auth-gate (signin form). Alias for openSignin() — kept for backwards compatibility. |
openSignin(opts?) | Opens the auth-gate on the signin form. Equivalent to openAuth(), just a more explicit name. |
openSignup(opts?) | Opens the auth-gate in signup mode (email/password/repeat). If the admin disabled allow_signup in the layout — falls back to signin. |
signInAnonymously() | Headless signInAnonymously without opening the modal. Returns Promise<AuthSession>. Idempotent (already anonymous → instant return) → resumes the same user_id via anonRefreshToken in storage → fresh /auth/anonymous/signin. After signOut() (without forgetAnonymous) a repeat call returns the same anonymous user — balance/trial preserved. Parallel calls deduplicate. |
openSupport(opts?) | Opens only the support form. Back closes. |
checkout(priceId, opts?) | Direct checkout for a specific price — skips the plan-picker layout and goes straight to /start-checkout. Reuses the preauth-gate, popup-blocked and awaiting-payment screens. Already-paid is a headless reject: emits purchase_completed { restored: true } without showing the “Subscription restored” view. See direct checkout below. |
close() | Closes the modal. Emits close. |
preload({signal?}) | Warms up bootstrap + balances in the background. Best-effort — never throws. |
destroy() | Full teardown: unmounts the modal, clears listeners, releases managed-auth. |
getAccess(opts?) | Side-effect-free check “should I gate this feature?”. Returns discriminated granted / blocked. See Trial → getAccess. |
getState() / onStateChange(cb, opts?) | State machine snapshot + subscription. See below. |
getTrialStatus() | Last known TrialStatus | null (sync, no network). null — open() / getAccess() not called yet, or trial disabled. |
getVisibility() | Last server-computed VisibilityStatus | null (sync). Useful for rendering a “service unavailable in your country” fallback without opening the modal. |
resetTrial() | Clears trial state in storage. For dev mode / admin “replay scenario” buttons. In production hosts rarely call this. |
getPrices(opts?) / getCachedPrices() | Shortcuts over billing.getPrices() for rendering pricing cards outside the modal. See BillingClient → getPrices. |
getUserLanguage() | Snapshot of the user’s resolved language for syncing your i18n with what the paywall shows. See Locale & language. |
checkReturn() | Scans the current URL for ?paywall_status=paid|failed|cancelled markers and emits purchase_completed / purchase_failed. The constructor runs this in a microtask when autoDetectReturn: true (default). |
on(event, cb) / off(event, cb) | Typed event subscription. See Events. |
onUserChange(cb) | Shortcut for paywall.on('userChange', cb) — the most common subscription. |
checkout(priceId, opts?)
Direct checkout: get a hosted checkout URL for a specific price and open it in a new tab, without showing the plan list. Useful when the host renders its own pricing cards / table and the click on “Buy” should land in Stripe / Paddle / Chargebee / Freemius / Overpay directly.
import { PaywallUI } from '@monetize.software/sdk';
const paywall = new PaywallUI({ paywallId: 'pw_123', auth: true });
document.querySelectorAll<HTMLButtonElement>('[data-price-id]').forEach((el) => {
el.addEventListener('click', () => {
paywall.checkout(el.dataset.priceId!);
});
});
paywall.on('purchase_completed', (p) => {
if (p.restored) showToast('You already have an active subscription.');
else unlockPremium();
});Late-mount UX
Unlike open(), no modal appears while the SDK is preparing the checkout. Bootstrap, visibility / trial gates, the active-subscription pre-check and createCheckout all run headless — there’s no loading-flash modal in front of the user. The host’s button is the only “I’m working” surface during these 200–500 ms.
Subscribe to state.processing (or use <PaywallButton priceId> in React) and show a spinner directly on your CTA:
paywall.onStateChange((state) => {
myButton.disabled = state.processing;
myButton.classList.toggle('busy', state.processing);
});The modal is mounted only when actual UI is needed:
checkout_mode: 'preauth'+ managed-auth + no real session → auth-gate (signin form). After success the modal auto-resumes intocreateCheckout()with the samepriceId.- Popup blocked by the browser (lost transient activation after async signin, aggressive mobile blockers) →
popup_blockedview with a retry button under a fresh user gesture. - Popup opened successfully →
awaiting_paymentview with “I’ve paid” verify, “Open checkout again” and “Tab closed? Try again”.
Headless-reject paths (no modal)
- Already-paid. When the user already has an active subscription — detected via cached user, fresh bootstrap, the preauth signin, or a
409 hasActivePurchasefrom the backend — the SDK emitspurchase_completed { restored: true }and does not show a “Subscription restored” view. The host surfaces it however it likes (toast, redirect, badge update viauserChange). - Gate blocked.
visibility_blocked/trial_blockedfire just like inopen(). No modal. - Errors.
errorfires (identity.emailmissing, bootstrap failure,createCheckoutrejection). No modal.
The plan-picker layout never appears in direct-checkout flow, not even as an error fallback — the host already owns plan selection.
Offers applied automatically
checkout(priceId) resolves the active offer for the price through getOfferForPrice(priceId) and threads its offerId into /start-checkout. This is required for duration-offers (countdown stored in clientStorage — the backend can’t validate the timer, so without an explicit offerId the discount would silently not apply on the hosted checkout, even though the UI showed it). End-date offers go through too; the backend re-verifies applicability against country/email/mode.
Requirements
identity.emailmust be set before the call — viapaywall.checkout(priceId, { identity }), an earlierpaywall.open({ identity }),setIdentity(), or managed-auth. Without an email the backend rejects/start-checkoutwith400; the SDK emitserror.- In
checkout_mode: 'preauth'without managed-auth, the host is responsible for collecting the email before the call — the SDK has nowhere to show a signin form.
Fully headless (no modal at all)
If the host wants only the checkout URL (e.g. renders a custom in-page “awaiting payment” screen instead of the SDK’s), bypass the modal entirely:
import { findApplicableOffer } from '@monetize.software/sdk';
const offer = findApplicableOffer(paywall.billing.getCachedOffers(), '42');
const result = await paywall.billing.createCheckout({
priceId: '42',
offerId: offer?.id // required for duration-offers
});
window.open(result.url, '_blank');In that path the host owns auth-gate UX, popup-blocked retry, the awaiting-payment screen, and purchase_completed wiring (via paywall.on('purchase_completed', …) or checkReturn() on the success page). For most apps paywall.checkout() is the better trade-off — same control over the storefront, fewer screens to build.
State machine
PaywallUI keeps a public snapshot of the modal’s state. Useful for:
useSyncExternalStorein React — single source of truth instead of stitching a dozenon()listeners;- Funnel analytics — track every transition (closed → loading → ready → …);
- Conditional UI — the host renders its own Upgrade button based on
state.view.
type PaywallStateSnapshot = {
open: boolean; // modal is visible
view: // what's shown (null when closed)
| 'loading'
| 'error'
| 'layout'
| 'auth'
| 'support'
| 'awaiting_payment' // checkout opened in a new tab
| 'popup_blocked' // browser blocked the popup
| 'purchased' // success view with Continue
| null;
error: PaywallError | null;
/** Direct-checkout (`paywall.checkout(priceId)`) is doing background work —
* bootstrap + visibility/trial gates + createCheckout — before any UI is
* needed. Host's CTA button should show busy/disabled while true so the
* user gets feedback without a modal-flash. Always false during the
* `paywall.open()` flow (the modal mounts immediately with its own
* LoadingView). */
processing: boolean;
};Using with React
import { useSyncExternalStore } from 'react';
import { paywall } from './paywall'; // singleton PaywallUI
function PaywallStatus() {
const state = useSyncExternalStore(
(cb) => paywall.onStateChange(cb, { immediate: 'none' }),
() => paywall.getState(),
() => paywall.getState() // SSR fallback (closed state)
);
if (state.view === 'loading') return <Spinner />;
if (state.view === 'error') return <ErrorBox err={state.error!} />;
if (state.view === 'awaiting_payment') return <p>Paying in new tab…</p>;
return null;
}useSyncExternalStore calls getState() itself after subscribe — no initial snapshot needed via the listener, hence immediate: 'none'.
Manual usage (Vue / Svelte / vanilla)
const off = paywall.onStateChange(
(state) => {
if (state.view === 'purchased') confetti();
if (state.view === 'error') showToast(state.error!.message);
},
{ immediate: 'sync' } // get current snapshot synchronously
);
// off() to unsubscribeThe immediate option
| Value | When |
|---|---|
'microtask' (default) | Initial snapshot via queueMicrotask. Safe for most integrations — the host gets to reset state in the same tick. |
'sync' | Initial snapshot right now. Handy for Vue/Svelte/vanilla when you need an immediate read. |
'none' | Skip initial — only real changes. For useSyncExternalStore (it reads the snapshot itself). |
Snapshot objects are stable: while state hasn’t changed, repeated getState() returns the same ===-equal object. Important for useSyncExternalStore — otherwise React would re-render on every tick.
Events vs state
Both events and state expose what’s happening — the choice depends on the task.
| Scenario | Event | State |
|---|---|---|
| ”User successfully purchased” (one-shot side-effect: confetti, redirect, slack ping) | ✅ purchase_completed | state.view='purchased' is sticky — on re-open you may retrigger |
| ”Is the modal open right now?” | Have to stitch open+close | ✅ state.open |
| React component rendering a loader | Hard (multiple events, race) | ✅ useSyncExternalStore |
| Conversion funnel analytics | ✅ Clean stream: open → ready → price_selected → checkout_started → purchase_completed | State is coarser — not every event maps to a transition |
Rule of thumb: events for side-effects, state for rendering.
Constructor options (PaywallUIOptions)
interface PaywallUIOptions extends Omit<BillingClientOptions, 'auth'> {
paywallId: string;
apiOrigin: string;
identity?: Identity;
storage?: StorageAdapter;
capabilities?: string[];
fetch?: typeof fetch;
apiKey?: string;
client?: BillingClient;
host?: HTMLElement;
/** Wire managed-auth. true (creates AuthClient), AuthClient (use existing),
* or Partial<AuthClientOptions> (custom options). */
auth?: true | AuthClient | Partial<AuthClientOptions>;
/** Auto-scan URL for ?paywall_status= markers on construction. Default: true. */
autoDetectReturn?: boolean;
/** Shadow DOM mode. Default 'closed' (full isolation). 'open' — for Playwright
* e2e and live admin preview. */
shadowMode?: 'open' | 'closed';
/** Analytics. false to disable, object for custom batch settings or endpoint. */
analytics?: boolean | AnalyticsOptions;
/** When bootstrap isn't cached: render the modal **immediately** with a
* spinner and run gates after data arrives (`true`, default), or **wait**
* for bootstrap before mounting (`false`). */
mountThenLoad?: boolean;
}Auth resolution:
auth: true— SDK creates a newAuthClientwith the samepaywallId/apiOrigin/storageas BillingClient.auth: AuthClient— host supplies an existing instance (useful for multi-paywall apps or pre-construction signin).auth: Partial<AuthClientOptions>—paywallIdis filled in, the rest comes from your overrides.- Omitted — hybrid mode: identity is passed via
opts.identityorpaywall.open({ identity }).
Lifecycle hooks for hosts
// On host unmount (SPA route change, hot reload, tests):
paywall.destroy();destroy() is idempotent and safe to call multiple times. It unmounts the Shadow DOM modal, removes every event listener, releases the managed AuthClient (only if auth: true was used — externally provided AuthClient lifecycle stays with the host), and stops the analytics flush timer.