Skip to Content
SDK v3newBillingClient

BillingClient — bootstrap and caching

BillingClient.bootstrap() loads the entire “structural” half of the paywall in a single round-trip: settings, prices, offers, layout, locales, and a snapshot of the current user state. This is the SDK’s first network call on the page — how fast the modal becomes visible depends on it.

import { PaywallUI } from '@monetize.software/sdk/ui'; const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://api.example.com' }); await paywall.billing.bootstrap(); // 1 request, ≤1s on a cold tab

The SDK caches the response aggressively — for typical integrations (popup + content scripts) bootstrap rarely hits the network after that.

What gets persisted

Bootstrap is written to the StorageAdapter under the key pw-{paywallId}-bootstrap-v1:

EnvironmentLocationShared across
Chrome Extensionchrome.storage.localpopup, content scripts in every tab, service worker
WeblocalStorageevery tab on the same origin
SSR / e2e / Nodein-memory mapcurrent process only

The user part of the snapshot is persisted under a separate, identity-bound key — switching accounts won’t leak the previous user’s state into the new session.

Lifecycle of a single bootstrap()

The decision to hit the network vs return cache is made against three thresholds:

Age of cached payloadBehaviourNetwork requests
≤ 5 minutesReturn cached instantly, no revalidate0
5 minutes — 1 hour (stale-while-revalidate)Return cached instantly + background request with ?if_version=1 (short 304-like response when the version is unchanged)
> 1 hour (TTL expired)Blocking full request1
bootstrap({ force: true })Blocking request, cache ignored1

Parallel calls

Two simultaneous bootstrap() calls (e.g. popup mount in parallel with content-script init on the same page) share one HTTP request — both callers resolve from the same response.

Cross-context sync

Any context (popup, content script in another tab, service worker) that writes a fresh bootstrap to storage triggers chrome.storage.onChanged (or the storage event on web). Every other BillingClient instance gets the fresh payload without a network request through StorageAdapter.watch().

Example: the user opens the popup → the SDK fetches bootstrap → writes it to chrome.storage.local → 12 open tabs with content scripts refresh their caches without an HTTP request.

Invalidation: what the client sees after an admin edit

The backend returns a version hash of the structural part of the payload (settings, prices, offers, layout, locales — everything except user). Background revalidates send ?if_version=<v>; on a match the backend returns a tiny { unchanged: true, version, user }, so cache hits stay cheap.

The full chain from “admin clicked Save” to “user sees the change”:

StageLatencyBottleneck
1. DB write (platform store)instant
2. revalidateTag on the server cache (online)instant after fan-out from platformCache fan-out across edge nodes — a partial failure can leave stale responses on a few nodes briefly
3. Client with an active persisted cache (≤5 min)Won’t auto-refresh — needs a new bootstrap() after crossing the threshold5-minute stale threshold
4. Client with a stale cache (5min — 1h)UI renders from stale; 1–2s after revalidate onBootstrapChange swaps in fresh data
5. Client with an expired cache (>1h) or a cold tabFresh data on the next bootstrap()1-hour persist TTL

A user who keeps the tab open and doesn’t invoke bootstrap() again won’t see the change automatically. The SDK doesn’t push updates — there’s no WebSocket/SSE. To forcibly refresh active instances, either call paywall.billing.bootstrap({ force: true }) explicitly, or wait for the next UI mount (popup / modal open).

API

bootstrap(opts?)

billing.bootstrap(): Promise<PaywallBootstrap>; billing.bootstrap(force: boolean): Promise<PaywallBootstrap>; // legacy billing.bootstrap({ force?: boolean; signal?: AbortSignal }): Promise<PaywallBootstrap>;
  • force: true — ignores cache and TTL, always hits the network. Use after explicit actions that definitely changed paywall state (admin mode, hotfix flows).
  • signalAbortSignal for cancellation. Useful when unmounting a React component.

onBootstrapChange(cb)

const unsubscribe = billing.onBootstrapChange((bootstrap) => { console.log('bootstrap updated, new version:', bootstrap.version); // re-render UI });

Fires only on a real version change: a background revalidate that returned unchanged: true does not invoke the listener. Ideal for UI frameworks — no spurious re-renders.

getCachedBootstrap()

billing.getCachedBootstrap(): PaywallBootstrap | null;

Sync access to the last loaded bootstrap. null means no successful request yet. Handy for post-checkout logic that needs to read settings.success_redirect_url without an await.

getPrices(opts?) / getCachedPrices()

A shortcut over bootstrap() — returns the paywall’s price array so the host can render pricing cards outside the modal (on a landing page, “/pricing” page, tier widget).

billing.getPrices(opts?: { force?: boolean; signal?: AbortSignal }): Promise<PaywallPrice[]>; billing.getCachedPrices(): PaywallPrice[] | null; // Same thing at the PaywallUI level: paywall.getPrices(opts?): Promise<PaywallPrice[]>; paywall.getCachedPrices(): PaywallPrice[] | null;

Cache semantics are identical to bootstrap() (5 min stale-while-revalidate + 1 hour persist TTL, in-flight dedupe). Locale overrides for label/description under navigator.language are applied automatically — the array is render-ready.

Prices are part of bootstrap, so getPrices() doesn’t fire a separate request. If bootstrap is already loaded — synchronous return from cache. If not — one network request for the whole paywall structure, after which getCachedBootstrap() is also populated.

Using @monetize.software/sdk-react? See usePaywallPrices — it wraps getCachedPrices + onBootstrapChange with proper useSyncExternalStore semantics.

For subscriptions to price changes (admin updates a plan → revalidate picks up the new version) use onBootstrapChangegetPrices does not expose its own listener (that would be redundant API on the same source of truth).

PaywallPrice fields

interface PaywallPrice { id: string; currency: string; // ISO 4217: 'USD', 'EUR', ... amount: number; // in minor units (cents) interval: 'month' | 'year' | 'week' | 'day' | 'lifetime' | null; interval_count: number | null; trial_days: number | null; // card trial: free days AFTER payment label?: string | null; // override from admin / locale description?: string | null; local?: { currency: string; amount: number } | null; // geo-converted price }

getUser(opts?) / getCachedUser() / onUserChange(cb)

billing.getUser(opts?: { force?: boolean; signal?: AbortSignal }): Promise<PaywallUser>; billing.getCachedUser(): PaywallUser | null; billing.onUserChange(cb: (user: PaywallUser) => void, opts?: { immediate?: 'microtask' | 'sync' | 'none' }): () => void;

Returns the paywall user (has_active_subscription / purchases / trial / had_previous_trial). Uses the same persistence + stale-while-revalidate model as bootstrap, plus identity-aware storage key.

const user = await billing.getUser(); if (user.has_active_subscription) enablePremium();

PaywallUser shape — in Events → PaywallUser.

createCheckout(params)

Returns a checkout URL for the chosen priceId. Use it when you drive your own buy buttons (custom pricing page, landing-page cards, in-app “Upgrade” button) and want to redirect to the payment provider yourself — instead of opening the bundled paywall via paywall.open().

billing.createCheckout(params: { priceId: string; successUrl?: string; errorUrl?: string; shopUrl?: string; trialDays?: number; /** Idempotency key — duplicate clicks on the same key return the same * checkout URL. SDK generates a UUID v4 if omitted. */ idempotencyKey?: string; /** Renewal/upgrade flow — passes `ignoreActivePurchase: true` to the backend * so /start-checkout doesn't return 409 for an already-subscribed user. */ ignoreActivePurchase?: boolean; signal?: AbortSignal; }): Promise<CheckoutResult>;
interface CheckoutResult { url: string; sessionId?: string; acquiring?: 'stripe' | 'paddle' | 'chargebee' | 'overpay' | 'freemius'; }

Requires an identity.email (or a connected AuthClient). Without one — throws identity_required. If the user already has an active subscription the backend returns 409 + { hasActivePurchase: true } — the SDK normalizes this into PaywallError('already_purchased', { status: 409 }). To bypass for renewal/upgrade flows, pass ignoreActivePurchase: true.

Parallel clicks on the same priceId (or idempotencyKey) collapse to one request — both callers get the same checkout URL.

getBalances(opts?) / getCachedBalances() / onBalanceChange(cb, opts?) / refreshBalances() / decrementBalanceLocal(queryType)

Balance APIs for AI providers (tokenization_queries). Same persistence model as bootstrap with tighter thresholds (30s stale, 5min TTL) and identity-bound storage keys.

billing.getBalances(): Promise<Balance[]>; billing.getCachedBalances(): Balance[] | null; billing.onBalanceChange(cb: (balances: Balance[]) => void, opts?: { immediate?: 'microtask' | 'sync' | 'none' }): () => void; billing.refreshBalances(): Promise<Balance[]>; billing.decrementBalanceLocal(queryType: string | undefined): void; // optimistic

Balance: { type: string; count: number }. Details and SSE example — in API Gateway.

Customer portal — getCustomerPortalUrl() / listPurchases() / cancelSubscription()

For your own customer-portal UI (“My subscriptions” page). Bearer auth required (or a server apiKey).

billing.getCustomerPortalUrl(opts?: { signal?: AbortSignal }): Promise<{ url: string }>; billing.listPurchases(opts?: { signal?: AbortSignal }): Promise<PaywallPurchaseDetailed[]>; billing.cancelSubscription(params: { subscriptionId: string; reason: string; signal?: AbortSignal; }): Promise<{ subscription: { status, canceled_at, cancel_at, cancel_at_period_end } }>;

See Customer portal for the full end-to-end pattern (list → cancel/renew → open external portal).

createSupportTicket(payload)

billing.createSupportTicket(payload: { subject: string; content: string; email?: string; // explicit override, otherwise identity.email files?: File[]; // when present, switches the request to multipart/form-data }): Promise<{ ticket: { id: number; status: string } }>;

Useful when you want to drive the support form yourself instead of opening paywall.openSupport(). Bearer auth, if present, overrides any email on the backend to prevent spoofing.

setIdentity(identity?) / getIdentity()

Manually swap the identity that BillingClient uses for headers (X-User-Id), checkout email and identity-bound storage keys. With a connected AuthClient, identity is auto-synced from auth.user on every onAuthChange — explicit setIdentity will be overwritten after the next auth event.

billing.setIdentity({ email: 'user@example.com', userId: 'usr_123' }); billing.getIdentity(); // → { email, userId, anonymousId } | undefined

getVisitorId() / getCachedVisitorId() / getStorage()

Low-level helpers. getVisitorId() resolves the stable UUID v4 from storage (used by EventTracker); getStorage() exposes the StorageAdapter instance for callers that want to write under custom keys.

setBootstrap(partial)

billing.setBootstrap(partial: Partial<PaywallBootstrap>): void;

Preview/editor-only setter. In preview: true mode the host can mutate the cached bootstrap for live preview in the admin editor. In production mode this is a no-op.

Balances — same approach

BillingClient.getBalances() (AI providers × tokenization queries) uses the same model: persist + stale-while-revalidate + cross-context sync. Differences:

  • Identity-bound storage: balances are scoped to the current identity. Calling setIdentity transparently rebinds the cross-context watcher to the new user’s data.
  • Tighter thresholds: 30-second stale, 5-minute persist TTL. Balance only changes after payment or an API-gateway call, so it doesn’t need an hour-long TTL.
  • Optimistic updates: decrementBalanceLocal() (invoked automatically after a successful ApiGatewayClient.call()) updates the local balance immediately. Other tabs pick up the change without an HTTP request.
Age of cached balancesBehaviour
≤ 30 secondsReturn cached, no network
30 seconds — 5 minutesStale-while-revalidate: cached instantly + background /balances
> 5 minutesBlocking /balances
force: trueBlocking, cache ignored

Unlike bootstrap, balances have no version/if_version — it’s just an array of { type, count }. Cross-context sync resolves conflicts by timestamp (at): whoever wrote latest wins.

Cache windows

Defaults work well for Chrome extensions with a popup and content scripts running across many tabs:

  • Stale threshold — 5 minutes. Repeated bootstrap() calls within this window return cached data instantly, no network.
  • Persist TTL — 1 hour. The longest the SDK trusts a persisted payload without a fresh network check.

If you need faster reaction to admin edits, force a refresh on the actions that should reflect new state:

await paywall.billing.bootstrap({ force: true });

Server-side usage

Every BillingClient method works headlessly. For apiKey-based flows (createCheckout, getCustomerPortalUrl, createSupportTicket), per-user Bearer (listPurchases, cancelSubscription), token credit/debit, webhook handlers, and storage adapters for Node / Edge runtimes — see Headless / server-side.