Events
PaywallUI is a typed event emitter. Subscribe via paywall.on(event, handler) — the handler receives a strictly typed payload, IDE gives you autocomplete. Every on() returns an unsubscribe function.
const off = paywall.on('purchase_completed', ({ priceId, sessionId, restored }) => {
if (restored) return; // user came back with an existing subscription, not a new purchase
unlockFeature();
});
off(); // unsubscribeEvents vs state. Events are good for one-shot side-effects (confetti, redirect, slack-ping). For rendering, use the PaywallUI state machine — it pairs naturally with useSyncExternalStore.
Lifecycle of a single open()
A typical successful “open → purchase” flow:
open → ready → price_selected → checkout_started → purchase_completed → closeEdge paths:
- User closes the modal:
open → ready → close. - Trial is still active:
trial_blocked(modal does not open — separate event). - Targeting did not match:
visibility_blocked(modal does not open). - Bootstrap failed:
open → error → close.
Full event table
| Event | Payload | When |
|---|---|---|
open | void | Modal mounted. If bootstrap was not cached, ready fires after the data lands. |
ready | PaywallBootstrap | Bootstrap loaded, modal shows content. A good place for impression metrics. |
price_selected | { priceId: string; price: PaywallPrice } | User clicked a plan but has not pressed “Pay” yet. Useful for selection analytics, not for conversion. |
checkout_started | { priceId: string; url: string; acquiring?: Acquiring } | The checkout_url from the backend was received and opened in a new tab. acquiring is one of 'stripe' | 'paddle' | 'chargebee' | 'overpay' | 'freemius'. |
purchase_completed | { priceId: string \| null; sessionId: string \| null; restored?: boolean } | User came back with a successful payment (URL marker) or the SDK watcher detected has_active_subscription=true. restored=true means an active subscription was discovered, not a fresh purchase (good for metrics). |
purchase_failed | { reason: string \| null } | User came back with an error/cancel from the provider. reason: 'failed' | 'cancelled'. |
userChange | PaywallUser | User-state changed: bootstrap snapshot, getUser() refresh, watcher tick. The first callback can arrive right after the constructor if there is a persisted user. |
authChange | { event: AuthChangeEvent; session: AuthSession \| null } | Fires only with auth: true (managed-auth). See AuthChangeEvent. |
trial_blocked | TrialStatus | Trial is active — the modal did not open and the user gets through to the feature. |
trial_expired | void | Trial just expired and the modal is shown for the first time after expiry. Emitted once per PaywallUI instance lifetime. |
visibility_blocked | VisibilityStatus | Targeting did not match (country / device / visibility flag) — modal did not open. |
error | PaywallError | Bootstrap or checkout failed. Does not cover purchase_failed — payment cancel/decline has its own event. |
close | void | Modal closed — × button, ESC, overlay click, paywall.close(), or after a successful purchase + Continue. |
Payload types
PaywallBootstrap
interface PaywallBootstrap {
settings: PaywallSettings;
prices: PaywallPrice[];
offers: PaywallOffer[];
layout?: Layout;
user?: PaywallUser;
locales?: Record<string, LocaleOverrides>;
/** Stable content-hash of the structural part (excluding user). Used with
* `?if_version=` on revalidation. May be undefined on old backends. */
version?: string;
}See BillingClient → Bootstrap for format details.
PaywallUser
interface PaywallUser {
/** The main flag for most integrations. true if the user has an active
* subscription OR a paid lifetime purchase OR an active trial. */
has_active_subscription: boolean;
purchases: PaywallUserPurchase[];
trial: { started_at: string | null; expires_at: string | null } | null;
/** Whether the user ever had a trial on this paywall (including expired
* and cancelled). Anti-abuse flag for UI. */
had_previous_trial: boolean;
}
interface PaywallUserPurchase {
id: string;
status: string | null;
current_period_end: string | null;
cancel_at_period_end: boolean | null;
}TrialStatus
type TrialStatus =
| { mode: 'none'; blocked: false }
| {
mode: 'time';
blocked: boolean;
startedAt: number | null; // Unix ms of the first open()
expiresAt: number | null; // Unix ms of expiry
remainingMs: number; // 0 — expired
totalMs: number; // payload hours × 3_600_000
}
| {
mode: 'opens';
blocked: boolean;
remainingActions: number; // 0 — expired
totalActions: number;
};Discriminate by status.mode:
paywall.on('trial_blocked', (status) => {
if (status.mode === 'time') {
const minutes = Math.ceil(status.remainingMs / 60_000);
showBanner(`${minutes} min of trial left`);
} else if (status.mode === 'opens') {
showBanner(`${status.remainingActions} free actions left`);
}
});VisibilityStatus
interface VisibilityStatus {
visible: boolean;
/** Why `visible=false`. null when `visible=true`. */
reason: 'country_not_match' | 'device_not_match' | 'disabled' | null;
/** ISO country code (by IP). null when not resolved. */
country: string | null;
/** Country tier 1/2/3. null when country is not resolved. */
tier: 1 | 2 | 3 | null;
}AuthChangeEvent
type AuthChangeEvent =
| 'INITIAL_SESSION' // first callback — hydration from storage
| 'SIGNED_IN' // signIn by any method
| 'SIGNED_OUT' // signOut, refresh failed 401, removal in another context
| 'TOKEN_REFRESHED' // token rotation (same user.id)
| 'USER_UPDATED' // upgradeAnonymousToEmail — same user.id, new email
| 'PASSWORD_RECOVERY'; // verifyOtp(type='recovery') — recovery sessionFull details — in Session management.
PaywallError
class PaywallError extends Error {
readonly code: string; // see /docs-v2/sdk-v3/errors
readonly status?: number; // HTTP status when the error comes from the network
readonly cause?: unknown; // original response body — for context-aware handling
}Full list of code values — in Error codes.
Subscribe / unsubscribe
const off = paywall.on('purchase_completed', (data) => { /* ... */ });
off(); // unsubscribe via the returned function
paywall.off('purchase_completed', handler); // or explicitly — for named handlersReact: always unsubscribe in cleanup
useEffect(() => {
return paywall.on('purchase_completed', () => unlockFeature());
}, [paywall]);Subscribe BEFORE or AFTER the constructor?
PaywallUI only emits events after open() — the constructor itself does not emit anything (except userChange through a microtask if there is a persisted user). So subscribing right after creating the instance is safe.
autoDetectReturn: true (the default) — during construction, via queueMicrotask, the SDK scans the URL for ?paywall_status=paid|failed|cancelled. If you subscribed in the same tick, you get the event. If your subscription is deferred (e.g. in useEffect), call paywall.checkReturn() after the subscription for critical post-purchase paths, or read the URL yourself.
Shortcuts
The most common subscriptions have dedicated methods:
| Shortcut | Equivalent |
|---|---|
paywall.onUserChange(cb) | paywall.on('userChange', cb) |
paywall.billing.onBootstrapChange(cb) | BillingClient level — fires only on version change. |
paywall.billing.onBalanceChange(cb, opts?) | BillingClient level — AI-provider balances. |
paywall.auth?.onAuthChange(cb) | AuthClient level — gives you (event, session) directly without a wrapper. |
paywall.onStateChange(cb, opts?) | State machine — one listener instead of stitching open+ready+close+error. |
Internal analytics events
Separately from the public on() API, PaywallUI ships batch analytics to ${apiOrigin}/api/v1/paywall/<id>/events:
paywall_viewed, price_selected, checkout_started, purchase_completed,
purchase_failed, paywall_closed, trial_blocked, trial_expired, visibility_blocked, errorPass analytics: false in PaywallUIOptions to disable entirely. Custom endpoint / batch settings — via analytics: { endpoint, flushIntervalMs, maxBufferSize }. See PaywallUIOptions.analytics.
For your own analytics, subscribe via paywall.on(...) and forward events to your analytics SDK — don’t write to the built-in batch channel directly.
Cookbook
Conversion by acquirer
paywall.on('checkout_started', ({ acquiring, priceId }) => {
analytics.track('checkout_started', { acquiring, price_id: priceId });
});
paywall.on('purchase_completed', ({ priceId, restored }) => {
if (restored) return; // restored is not a conversion — don't move the counter
analytics.track('purchase_completed', { price_id: priceId });
});Loading spinner for slow bootstrap
let loading = false;
paywall.on('open', () => { loading = true; renderSpinner(); });
paywall.on('ready', () => { loading = false; hideSpinner(); });
paywall.on('error', () => { loading = false; hideSpinner(); });
paywall.on('close', () => { loading = false; hideSpinner(); });Or simpler — via onStateChange:
paywall.onStateChange((state) => {
if (state.view === 'loading') showSpinner();
else hideSpinner();
});Re-trigger restore on cross-tab sign-in
paywall.on('authChange', ({ event }) => {
if (event === 'SIGNED_IN') {
// SDK will refetch user on its own; for aggressive UX you can force it
paywall.billing.getUser({ force: true });
}
});Catch one specific purchase
purchase_completed fires for new purchases, restored ones, and from the watcher — fine for unlock, but bad for confetti. Dedupe by sessionId:
const seenSessions = new Set<string>();
paywall.on('purchase_completed', ({ sessionId, restored }) => {
if (restored) return;
if (!sessionId || seenSessions.has(sessionId)) return;
seenSessions.add(sessionId);
confetti();
});Next steps
- PaywallUI — modal state,
getState(),onStateChange(). - Error codes —
PaywallError.codetable. - BillingClient → events —
onBootstrapChange,onUserChange,onBalanceChange.