# Monetize.software — SDK 3.0 Integration Pack (for AI coding agents) This is a focused, single-file documentation pack for **Monetize.software SDK 3.0**. It is designed to be fed to an AI coding agent (Cursor, Claude Code, Copilot, etc.) together with a short task brief from the integrator. **What's inside this file** - Full SDK 3.0 reference: `AuthClient`, `BillingClient`, `PaywallUI`, `ApiGatewayClient`, bootstrap, events, errors, storage adapters, locale, security model. - Three end-to-end integration guides: Web (SPA), Chrome Extension (MV3), Headless (server / custom UI). - Paywall features (covered inside the SDK reference): anonymous sign-in, trial, tokenization (API Gateway), translations/locale, local pricing (via bootstrap). - Setup context that affects code: custom domains (apiOrigin), webhooks (events for your backend). --- ## How to use this file (instructions for the agent) The integrator will tell you: 1. **`paywallId`** — numeric ID from their dashboard (Paywalls → URL). 2. **`apiOrigin`** — their custom domain, e.g. `https://billing.theirapp.com`. This is **REQUIRED** at SDK init and has no fallback. 3. **Channel** — one of: `web` (SPA), `extension` (Chrome MV3), `headless` (server / custom UI). 4. **(optional)** `providerId` — UUID, if they use API Gateway for metered AI calls. 5. **(optional)** Auth requirements — login needed? Which OAuth providers? ### Hard rules — do not violate 1. `apiOrigin` is required. Never default to `https://appbox.space`, `https://monetize.software`, or any other host. If the integrator didn't give you one, **ask**. 2. SDK 3.0 is **bundled npm only**. There is no CDN/script-tag distribution (CWS policy for extensions, and it's the only supported channel for web/headless too). Do not write ` ``` CDN alternatives — `https://unpkg.com/@monetize.software/sdk@3/dist/index.js` or `https://cdn.jsdelivr.net/npm/@monetize.software/sdk@3/dist/index.js`. **Pin the major version** (`@monetize.software/sdk@3`), don't use `@latest` in production. A surprise major release will break your integration. **CDN is forbidden in Chrome extensions** — CWS review won't pass remote code execution. For extensions, use the bundled npm package only (see below). ## Chrome Extension (MV3) For extensions there's a dedicated package `@monetize.software/sdk-extension`. It wraps SDK 3.0 in an **offscreen document** so a single state (auth session, bootstrap cache, trial counter, analytics) is shared across the popup, content scripts in every tab, and the service worker. **Working reference extension:** [github.com/monetize-software/sdk-examples/tree/main/browser-extension](https://github.com/monetize-software/sdk-examples/tree/main/browser-extension) — full MV3 setup: SW + offscreen + popup + content-script, floating Shadow-DOM widget, ApiGatewayClient with quota-driven auto-open, and a two-config vite build (ESM for SW/offscreen/popup, IIFE for content). Clone, set two env vars, `npm install && npm run build`, load `dist/` in `chrome://extensions`. ```bash pnpm add @monetize.software/sdk-extension preact ``` **When to use `@monetize.software/sdk-extension` vs plain `@monetize.software/sdk`:** Scenario Package Extension with popup + content scripts across tabs — sign-in in the popup should reflect on every tab instantly `@monetize.software/sdk-extension` Popup only, no content scripts; or only one extension page (options) `@monetize.software/sdk` — sufficient ### Minimal `manifest.json` ```json { "manifest_version": 3, "name": "My Extension", "version": "1.0.0", "permissions": ["offscreen", "storage"], "host_permissions": ["https://YOUR_DOMAIN/*"], "background": { "service_worker": "sw.js", "type": "module" } } ``` Optional permissions: - `"content_scripts"` — if you want to render the paywall on third-party pages (not just the popup). OAuth (Google / Apple / etc) **does not** require the `"identity"` permission — the SDK opens a popup window against your `apiOrigin` instead of using `chrome.identity`. ### `host_permissions` — what to pick `host_permissions` control two things: where the extension can `fetch` (from offscreen / SW / content-script) and which origins the content-script can inject into (together with `content_scripts.matches`). This is the **primary signal** for Chrome Web Store review and AV vendors (Avast/Kaspersky/Norton). The narrower `host_permissions`, the less suspicion. Extension host scenario Recommendation Extension already needs `` (recorder, all-site assistant, tool like Grammarly) Keep ``. SDK works as-is. **Note:** CWS review with `` goes through a manual audit and takes longer; AV vendors are more likely to flag such extensions as PUA. That's the cost of broad injection, not an SDK risk. Extension only talks to **your backend** and paywalls its own features (popup tool, side-panel app) Do **NOT** request ``. Your apiOrigin is enough: `["https://api.your-domain.com/*"]`. No content-script injection on every site needed. Hybrid — popup tool plus content script on a narrow list of domains Constrain `host_permissions` + `content_scripts.matches` to those domains: `["https://*.your-target.com/*", "https://api.your-domain.com/*"]`. **Keep `` only when it's genuinely needed for UX**, and be ready to justify it in the CWS "Permission justification" field. "So the paywall works" is a bad justification; the SDK itself doesn't require ``. ### Initializing the three contexts ```ts // service-worker.ts installRouter({ offscreenUrl: chrome.runtime.getURL('offscreen.html') }); ``` ```ts // offscreen.ts (loaded from offscreen.html) startOffscreenServer({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true }); ``` ```ts // content-script.ts (or popup.ts / options.ts) const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true }); paywall.open(); // same API as plain @monetize.software/sdk ``` Under the hood, content-script `PaywallUI` is a proxy via a chrome port into the offscreen document. All network traffic, auth refresh, and storage happen in offscreen; content only paints UI. ## Telegram Mini App Use the same `@monetize.software/sdk` you'd use on the web — a Telegram WebView is a regular browser context. Bundle size matters (Mini App start is blocked by load), so import narrowly: ```ts ``` OAuth inside Telegram has its own quirks — see [Authentication → OAuth](/docs-v2/sdk-v3/auth/oauth#telegram-mini-app). --- # Localization and user language Section: SDK 3.0 (current) URL: https://monetize.software/docs-v2/sdk-v3/locale The paywall decides which language to display itself — it picks overrides from `bootstrap.locales` along a priority chain of sources. So the host app can **sync its own translations** with what the user sees in the modal, the SDK exposes a sync method `getUserLanguage()`. ## How the SDK picks the language Each value in this chain is a candidate. The SDK walks them in order; **the first key present in `bootstrap.locales`** wins. Priority Source Where it comes from 1 `navigator.language` Full BCP-47 tag from the browser (`ru-RU`, `en-US` 2 base tag of `navigator.language` Part before the hyphen: `ru-RU` → `ru`, `en-US` → `en` 3 `settings.locale_default` Server-derived per user country (IP): AT→de, RU→ru, LV→en, FR→fr, others→en If none of the candidates appear in `bootstrap.locales` — the SDK applies no overrides and renders the **base** version of layout/prices (whatever the admin entered in the platform as the default). That means: if the base layout in admin is in English and `locales` only contains `ru`, Russian users see Russian, everyone else sees the English base. Pricing (`currency`, `amount`) is a separate mechanism, **not** tied to language. It's chosen **only by the user's country** (IP→COUNTRY_CURRENCY_MAP), even if browser language and country language differ. That's deliberate: a user in Russia with an English browser still wants to see rubles. See the `price.local` field in `PaywallPrice`. ## API ### `getUserLanguage()` Available on both `PaywallUI` and `BillingClient` — same thing, pick whichever is convenient: ```ts paywall.getUserLanguage(): UserLanguageInfo; paywall.billing.getUserLanguage(): UserLanguageInfo; ``` A sync method, no separate request — the data is already in bootstrap. Call it any time after `paywall.billing.bootstrap()` (and `tag`/`browserLanguage` are available even before bootstrap if `navigator.language` exists). ```ts interface UserLanguageInfo { /** Best-guess BCP-47 tag for the host. Priority: applied → browserLanguage → countryLanguage. * null — bootstrap not loaded and navigator unavailable (e.g. an early call in a SW). */ tag: string | null; /** Key from bootstrap.locales the SDK actually applied to layout/prices. * null = no match, base is rendered. */ applied: string | null; /** navigator.language — what the browser reports. null in environments without navigator. */ browserLanguage: string | null; /** Server-resolved language per user country (IP). From bootstrap.settings.locale_default. */ countryLanguage: string | null; } ``` ### When to use which field Scenario Field Switch host i18n so the surrounding buttons match the paywall's language `tag` (or `applied`, if your translations key off every `locales` entry precisely) Tell apart "paywall really is in Russian" from "SDK guessed but no overrides exist" `applied !== null` → actually applied; `null` → SDK showed the base Analytics: how many users see incomplete locales (have `ru`, lack `fr` compare `tag` with `applied` — if different, locale fell back Tourist in Latvia with a Russian browser — what should your UI show `browserLanguage` (trust the user) vs `countryLanguage` (trust geo) — your call ## Examples ### Sync host i18n with the paywall ```ts const paywall = new PaywallUI({ paywallId: 'pw_123', auth: true }); await paywall.billing.bootstrap(); const { tag } = paywall.getUserLanguage(); if (tag) { await i18n.changeLanguage(tag); } ``` ### React to changes (hot reload of paywall settings) `onBootstrapChange` fires on revalidate when the set of locales or `locale_default` changes. Subscribe and re-compute: ```ts paywall.billing.onBootstrapChange(() => { const { tag } = paywall.getUserLanguage(); if (tag) i18n.changeLanguage(tag); }); ``` ### Fallback analytics ```ts const { tag, applied } = paywall.getUserLanguage(); analytics.track('paywall_locale', { shown: tag, // what the user sees matched: applied, // override actually applied (null = base) fallback: tag !== applied // true → no translation for the user's language }); ``` `tag` is returned in the casing the source provided: `navigator.language` is usually `en-US` (Title-case region), `locale_default` from the server is lowercase (`en`). If your i18n requires strict format (`en-US` vs `en_US` vs `en`) — normalize on your side. ## What `getUserLanguage()` does **not** do - **Does not change** the paywall language — it's a readonly snapshot. The SDK doesn't expose a setter because language is decided by the server (locale_default) and by the browser (navigator.language). If you need to force a language for testing — change the browser language or mock `navigator.language`. - **Does not lazy-load** locales — `bootstrap.locales` arrives as one blob. If the desired language isn't in locales, the SDK won't fetch it later. - **Does not consider** `navigator.languages[]` (the preference list). The SDK only looks at the first element via `navigator.language`. Multi-preference fallback (e.g. user with `['lv', 'ru', 'en']` should get `ru` when `lv` wasn't authored) isn't supported yet — contact support if you need it. --- # Quickstart — zero to paywall in 5 minutes Section: SDK 3.0 (current) URL: https://monetize.software/docs-v2/sdk-v3/quickstart SDK 3.0 covers three scenarios with one npm package: a modal in the browser, popup/content-script in a Chrome extension, and headless calls from a server. All three share **one `paywallId`** — you don't need separate paywalls per channel. **Using an AI coding agent?** Feed it [`llms-sdk-v3.txt`](/llms-sdk-v3.txt) — a single-file pack with the full SDK 3.0 API and integration patterns. Cursor, Claude Code, Copilot and the like produce far more accurate code with it in context. ## Before you start 1. **A paywall is created** in the dashboard → "Paywalls" → "New Paywall". Remember the `paywallId` (the number in the URL). 2. **At least one payment processor is connected** (Stripe / Paddle / Chargebee / Freemius). 3. If you'll be using the API Gateway (metered AI calls) — also have a `providerId` (UUID from the "API Providers" section in the dashboard). `apiOrigin` for every example is `https://YOUR_DOMAIN`. If you have a [custom domain](/docs-v2/custom-domains), substitute yours. ## Pick a scenario Three integration paths — pick by how much UI you want to own and where your auth lives. See [Overview](/docs-v2/sdk-v3) for a side-by-side comparison. ### Drop-in PaywallUI — Web / SPA Install the npm package, open the paywall with one line, listen for checkout success. Ready-made modal in a Shadow DOM with built-in email / OTP / OAuth login. #### Install ```bash pnpm add @monetize.software/sdk # or: npm i @monetize.software/sdk ``` #### Initialize ```ts const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true // turns on built-in email / OTP / OAuth login }); ``` `auth: true` is enough for most integrations — the SDK creates an `AuthClient` itself, persists the session, refreshes tokens. If you already have your own `AuthClient` (or want a [hybrid identity](/docs-v2/sdk-v2/hybrid)), pass `identity` or `auth: ` explicitly. #### Open the paywall ```ts document.getElementById('upgrade-btn').addEventListener('click', () => { paywall.open(); }); ``` Already rendering your own pricing cards and want the click to skip the plan-picker step? Use [`paywall.checkout(priceId)`](/docs-v2/sdk-v3/ui#checkoutpriceid-opts) — the modal still handles preauth signin, popup-blocked retry, and the awaiting-payment screen: ```ts document.querySelectorAll('[data-price-id]').forEach((el) => { el.addEventListener('click', () => paywall.checkout(el.dataset.priceId!)); }); ``` #### React to the purchase ```ts paywall.on('purchase_completed', ({ priceId, sessionId }) => { // update user state, unlock the feature }); paywall.on('close', () => { // user closed the modal without buying }); paywall.on('error', (err) => { console.error('paywall error', err); }); ``` Full event list — in the [`PaywallUI` API](/docs-v2/sdk-v3/ui#events). **What's next:** [BillingClient](/docs-v2/sdk-v3/bootstrap) — when you need prices and user state before opening the modal. [Authentication](/docs-v2/sdk-v3/auth) — customising the login flow. ### Chrome Extension (MV3) The separate package `sdk-extension` wraps the SDK in an offscreen document — one auth/bootstrap state is shared across the popup, content scripts on every tab, and the service worker. #### Install ```bash pnpm add @monetize.software/sdk-extension preact ``` #### `manifest.json` ```json { "manifest_version": 3, "name": "My Extension", "version": "1.0.0", "permissions": ["offscreen", "storage"], "host_permissions": ["https://YOUR_DOMAIN/*"], "background": { "service_worker": "sw.js", "type": "module" } } ``` **Don't request ``** if your extension only talks to its own backend — it stretches CWS review and attracts AV vendors. Details — in [Installation → host_permissions](/docs-v2/sdk-v3/installation#host_permissions--what-to-pick). #### Service worker — `sw.js` ```ts installRouter({ offscreenUrl: chrome.runtime.getURL('offscreen.html') }); ``` #### Offscreen — `offscreen.html` → `offscreen.ts` ```ts startOffscreenServer({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true }); ``` All network traffic, auth refresh and storage live here. Popup / content script just paint the UI. #### Popup / content script ```ts const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true }); paywall.open(); paywall.on('purchase_completed', ({ priceId }) => { // signin/upgrade in the popup — every tab sees it via storage.watch }); ``` The API is identical to the web version — `PaywallUI` proxies calls through a chrome port into offscreen under the hood. **What's next:** [Installation → Chrome Extension](/docs-v2/sdk-v3/installation#chrome-extension-mv3) — why `web_accessible_resources` is NOT needed, what to do about ``, how to pass CWS review. ### Custom UI — your own pricing page and login You render your own pricing cards / login form / "Buy" button; the SDK provides `BillingClient` for prices/checkout and `AuthClient` for our auth flows. #### Install ```bash pnpm add @monetize.software/sdk ``` #### Initialize ```ts const auth = new AuthClient({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN' }); const billing = new BillingClient({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth }); ``` #### Render your own pricing cards ```ts const prices = await billing.getPrices(); // prices: [{ id, amount, currency, interval, label, ... }, ...] // Render them however you want — your JSX, your DOM, your design system. ``` #### Wire the "Buy" button ```ts async function onBuyClick(priceId: string) { if (!auth.user) { // Show your login UI first; once signed in, retry the call. return showLoginModal(); } const { url } = await billing.createCheckout({ priceId, idempotencyKey: crypto.randomUUID() }); window.location.assign(url); } ``` #### Gate a feature ```ts const access = await billing.getAccess?.({}) ?? { granted: false }; if (access.granted) runFeature(); else showUpgradeBanner(); ``` For React, the same flow with hooks instead of imperative calls — see [React / Next.js bindings](/docs-v2/sdk-v3/react). **What's next:** [BillingClient](/docs-v2/sdk-v3/bootstrap) — full method reference. [Authentication](/docs-v2/sdk-v3/auth) — every login flow. [API Gateway](/docs-v2/sdk-v3/api-gateway) — metered AI calls when you don't have a backend. ### Headless (server-side) Your own backend, your own UI? Take only the `core` export (≤ 8 KB gz, no Preact/UI). Two typical headless scenarios: - **A. Server-side billing** — `BillingClient` for bootstrapping prices and starting checkout from your backend. - **B. API Gateway** — metered proxy to AI providers, balances debited automatically. **Not on a JS runtime?** (Python, Go, PHP, Ruby, .NET…) — see the [REST API reference](/docs-v2/sdk-v3/rest-api). Same endpoints, language-agnostic specs with `curl` examples. #### Install ```bash pnpm add @monetize.software/sdk ``` #### Option A: server-side billing ```ts // `apiKey` identifies you (the paywall owner) — server-only, from the dashboard. // `identity` binds the call to a user from YOUR auth system. const billing = new BillingClient({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', apiKey: process.env.MONETIZE_API_KEY!, identity: { email: yourUser.email, userId: yourUser.id // your stable internal ID } }); const bootstrap = await billing.bootstrap(); // bootstrap.prices, .settings, .locales — no identity needed for this read const checkout = await billing.createCheckout({ priceId: bootstrap.prices[0].id, idempotencyKey: crypto.randomUUID() // mandatory for production }); // Redirect the user to checkout.url ``` **Got your own auth?** This is the canonical pattern: `apiKey` server-side + `identity` from your user record. Every server method (`createCheckout`, `listPurchases`, `cancelSubscription`, `getCustomerPortalUrl`, support tickets) accepts this combination — no need to register users in monetize.software's auth. For state syncing, use [webhooks](/docs-v2/webhooks/events). #### Option B: metered AI billing via `debitTokens` You already have a backend talking to OpenAI/Anthropic with your own provider keys. After each successful call, debit credits from the user's balance: ```ts // 1. Call the provider yourself const aiResponse = await openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'Hello' }] }); // 2. Debit credits on success (server-SDK: apiKey + identity) billing.setIdentity({ email: 'user@example.com' }); // or { userId: 'your-stable-id' } const { count } = await billing.debitTokens({ type: 'standard', amount: 1 }); // throws PaywallError('insufficient') if the balance can't cover it // Granting tokens (promo, refund, top-up) is the mirror call: await billing.creditTokens({ type: 'standard', amount: 100 }); ``` Identify the user by `email`, or by your own stable `userId` — which is the monetize.software auth user.id you get from webhook events (`subscription.created` → `data.user.id`) after the first `createCheckout` and store next to your own user record. **Why not `ApiGatewayClient`?** The gateway exists so **browser-only** apps can do metered AI without exposing provider keys. Your backend already has those keys; routing through us would just add a network hop. The gateway is browser/extension scope, not headless. **What's next:** [Headless / server-side](/docs-v2/sdk-v3/headless-server) — full server-side guide (apiKey setup, token credit/debit, webhooks, error handling). [API Gateway](/docs-v2/sdk-v3/api-gateway) — only when you don't have a backend (SPA / Chrome extension / Telegram Mini App). ## What all scenarios have in common - **One `paywallId`** across drop-in / custom UI / headless — switch paths without migrating data. - **One `apiOrigin`** (or one custom domain), or none at all for pure headless. - **Unified auth model** — `AuthClient` behaves the same way in browser scenarios; headless uses `apiKey` + `identity` and gets the same outcome on the backend. - **Identical event signatures** — `purchase_completed`, `authChange`, `userChange` have the same shape whether you're listening from `PaywallUI`, `BillingClient`, or webhooks. ## Next steps - [Installation](/docs-v2/sdk-v3/installation) — Extended install, bundle budget, manifest, host_permissions - [BillingClient](/docs-v2/sdk-v3/bootstrap) — Bootstrap, cache, cross-context sync, optimistic updates - [Authentication](/docs-v2/sdk-v3/auth) — Email/OTP/OAuth, anonymous sessions, hybrid identity - [API Gateway](/docs-v2/sdk-v3/api-gateway) — Metered proxy to AI, balance state, quota handling - [Events](/docs-v2/sdk-v3/events) — Full list of PaywallUI and BillingClient events - [Headless / server-side](/docs-v2/sdk-v3/headless-server) — apiKey, per-user Bearer, token credit/debit, webhooks --- # Components Section: SDK 3.0 (current) URL: https://monetize.software/docs-v2/sdk-v3/react/components Declarative wrappers over the hooks. They cover the most common patterns — 80% of integrations skip `useEffect` boilerplate. For the rest, reach for the hooks directly. ## `` Declarative gate: `loading` → `fallback` → `children`. Powered by [`usePaywallAccess`](/docs-v2/sdk-v3/react/hooks#usepaywallaccessopts). ```tsx } fallback={({ open }) => } > ``` ### Props Prop Type What it does `children` `ReactNode` Premium content. Rendered **only** when `access === 'granted'`. `fallback` `ReactNode \| ((args: BlockedRenderArgs) => ReactNode)` Shown when `access === 'blocked'`. The render function receives `{ result, open }` — `open()` triggers `paywall.open()`. `loading` `ReactNode` Shown while `getAccess()` hasn't returned (initial fetch / Provider mount). Defaults to `null`. `openOnBlocked` `boolean` Automatically call `paywall.open()` on blocked. Defaults to `false` — most hosts want an explicit CTA click first. ```ts interface BlockedRenderArgs { result: Extract; open: () => void; } ``` ### Patterns ```tsx }> ``` Static fallback. The button inside `` calls `usePaywall().open()` itself. ```tsx ``` The modal pops up as soon as the user lands on the component. Useful for feature gates — clicking "AI summary" sends them straight to the paywall. ```tsx } fallback={({ open, result }) => (

Pro feature locked

Your trial ended {timeSince(result.trial?.expiresAt)} ago.

)} >
``` The render function gives you access to `result` — vary the copy by `reason`. ```tsx function PremiumFeatureGated() { const access = usePaywallAccess(); const paywall = usePaywall(); if (access.status === 'loading') return ; if (access.result.access === 'granted') { return ; } // Custom: show "Try trial" if the user hasn't had a trial yet if (!access.result.user?.had_previous_trial) { return ; } return ; } ``` Hook directly — for non-standard branches like "different CTA depending on trial history". `` covers 80% of gating. For the rest, drop down to [`usePaywallAccess`](/docs-v2/sdk-v3/react/hooks#usepaywallaccessopts) — the component is deliberately not configurable for every edge case. ## `` Sugar over `usePaywall().open()`. Renders a native `; ``` Use it when you need direct access to `paywall.billing` / `paywall.auth` / modal methods that aren't covered by the other hooks. ## `usePaywallState()` Modal state: `open`, `view`, `error`. Implemented on top of `paywall.onStateChange` + `paywall.getState` via `useSyncExternalStore` — correct concurrent-rendering semantics (no tearing, snapshot stable within a single React commit) and minimal re-renders (snapshot equality by `Object.is`). ```tsx function PaywallStatus() { const { open, view, error } = usePaywallState(); if (view === 'loading') return ; if (view === 'error') return ; if (view === 'awaiting_payment') return

Paying in new tab…

; return null; } ``` `view` is one of `'loading' | 'error' | 'layout' | 'auth' | 'support' | 'awaiting_payment' | 'popup_blocked' | 'purchased' | null`. Details — in [PaywallUI → state machine](/docs-v2/sdk-v3/ui#state-machine). Before Provider mount / on SSR it returns `{ open: false, view: null, error: null }`. ## `usePaywallUser()` Returns a discriminated `PaywallUserState` union describing **who the current user is** from the host's point of view. The shape combines three signals — Provider readiness, the managed-auth session, and the `BillingClient` user snapshot — so the consumer can branch on a single `status` field instead of guessing what `null` means. ```ts export type PaywallUserState = | { status: 'loading'; user: null; session: null } | { status: 'guest'; user: null; session: null } | { status: 'signed_in'; user: PaywallUser | null; session: AuthSession | null; }; ``` - **`loading`** — `` hasn't mounted the instance yet (SSR / pre-mount / StrictMode double-mount cleanup). Render a skeleton. - **`guest`** — there's no identity: - managed-auth: `paywall.auth.getCachedSession()` returned `null`; - hybrid (no managed-auth): bootstrap finished, but the user snapshot is empty. Safe to show a `` CTA. - **`signed_in`** — there's an identity. `user` is the latest `BillingClient` snapshot (may be `null` while `/me` is in flight after a fresh sign-in — show a skeleton, not a sign-in CTA). `session` is the managed-auth `AuthSession`, or `null` in hybrid mode. ```tsx function Account() { const account = usePaywallUser(); if (account.status === 'loading') return ; if (account.status === 'guest') return ; if (!account.user) return ; return ( ); } ``` The hook subscribes to **both** `userChange` and `authChange`, so sign-in / sign-out transitions rerender consumers automatically — no manual `paywall.on(...)` wiring needed. The snapshot reference is cached internally so `useSyncExternalStore` stays loop-free. `getCachedUser()` returns reference-stable snapshots between no-op refreshes, so React skips re-renders automatically. ## `usePaywallAccess(opts?)` **The main hook for gating features.** Wraps `paywall.getAccess(opts)` with no side-effects: the modal isn't mounted, trial storage isn't moved. Re-fetches automatically on `userChange` and `purchase_completed`. ```tsx export type PaywallAccessState = | { status: 'loading'; result: null } | { status: 'ready'; result: PaywallAccessResult }; ``` ```tsx function PremiumGate() { const access = usePaywallAccess(); const paywall = usePaywall(); if (access.status === 'loading') return ; if (access.result.access === 'blocked') { return ; } return ; } ``` `opts: GetAccessOptions` mirrors `paywall.getAccess(opts)` — `skipTrial?: boolean` / `skipVisibility?: boolean`. The hook only restarts the effect when the actual flag values change, so referential stability of `opts` is unnecessary. `access.result.reason` narrows: - `access === 'granted'`: `'has_subscription' | 'visibility_blocked' | 'trial_blocked'`; - `access === 'blocked'`: `'no_subscription'`. Not to be confused with `` — the component that wraps `usePaywallAccess` plus loading/fallback/children rendering. See [Components → PaywallGate](/docs-v2/sdk-v3/react/components#paywallgate). ## `usePaywallPrices()` Paywall prices for your own pricing page or cards. Initial sync read from `getCachedPrices()` + first `getPrices()` request + subscription to the `ready` event. ```tsx export interface PaywallPricesState { prices: PaywallPrice[] | null; loading: boolean; error: Error | null; } ``` ```tsx function PricingCards() { const { prices, loading, error } = usePaywallPrices(); if (loading && !prices) return ; if (error) return ; return prices?.map((p) => ( )); } ``` Locale overrides for `label`/`description` under `navigator.language` are applied automatically. `local: { currency, amount }` is the geo-converted price (e.g. RUB for a user in Russia), separate from the base `currency`/`amount`. Independent of browser language. ## `usePaywallTrial()` Current `TrialStatus`. `null` until the trial has been checked (host hasn't called `open()` / `getAccess()`) or the trial is disabled in the paywall config. ```tsx function TrialBanner() { const trial = usePaywallTrial(); if (!trial?.blocked) return null; if (trial.mode === 'opens') { return {trial.remainingActions} free actions left; } if (trial.mode === 'time') { const minutes = Math.ceil(trial.remainingMs / 60_000); return {minutes} min of trial left; } return null; } ``` Trial models in detail — in [Trial](/docs-v2/sdk-v3/trial). ## `usePaywallVisibility()` Server-computed visibility snapshot. `null` until bootstrap loads or the backend hasn't computed visibility for this paywall yet. ```tsx function GeoFallback({ children }) { const visibility = usePaywallVisibility(); if (visibility && !visibility.visible) { return ; } return children; } ``` `reason: 'country_not_match' | 'device_not_match' | 'disabled' | null`. Useful for rendering your own fallback ("service unavailable in your country") without opening the modal. ## `usePaywallEvent(event, handler)` Declarative subscription to an SDK event with automatic unsubscribe on unmount. Inside, the handler is held in a `useRef`, so you **don't need `useCallback`** — the subscription only rebuilds when `event` or the paywall instance changes. ```tsx function PurchaseToast() { const queryClient = useQueryClient(); usePaywallEvent('purchase_completed', ({ priceId, restored }) => { if (restored) return; toast.success(`Purchase ${priceId} complete`); queryClient.invalidateQueries(['user']); }); return null; } ``` All event types and payloads — in [Events](/docs-v2/sdk-v3/events). **`usePaywallEvent` vs `useEffect`?** `usePaywallEvent` keeps the callback fresh via a ref — your handler always sees the latest props, subscription doesn't rebuild. Manual `useEffect` + `paywall.on(...)` forces you to either `useCallback` the handler or rebuild on every render. The hook eliminates both. ## SSR notes - On the server, `usePaywall()` is always `null`. Every hook above returns a typed fallback (`null`, `{ open: false }`, `{ status: 'loading' }`). - `usePaywallState()` uses `useSyncExternalStore(_, _, getServerSnapshot)` — `getServerSnapshot` returns a stable `{ open: false, view: null, error: null }` reference to avoid hydration mismatch. - Inside `` the Provider creates `PaywallUI` only in `useEffect` (client-only) — the SDK constructor touches `window/queueMicrotask` and must not run on the server. ## Next steps - [Components](/docs-v2/sdk-v3/react/components) — ``, ``, ``. - [Events](/docs-v2/sdk-v3/events) — types for every event subscribed to by the hooks. - [BillingClient](/docs-v2/sdk-v3/bootstrap) — what's under `usePaywallAccess` / `usePaywallUser` / `usePaywallPrices`. --- # REST API Section: SDK 3.0 (current) URL: https://monetize.software/docs-v2/sdk-v3/rest-api Everything `@monetize.software/sdk` does is a wrapper over HTTPS endpoints — this page is the language-agnostic contract for backends that can't import the SDK (Python, Go, PHP, Ruby, .NET, Rust, etc.). **If your backend is Node / Bun / Deno / Edge** — use the [SDK](/docs-v2/sdk-v3/headless-server). Typed wrappers, identity binding, retries and idempotency are handled for you. The REST API is the canonical contract underneath; both are equivalent. ## Base URL ``` https://YOUR_DOMAIN ``` If you have a [custom domain](/docs-v2/custom-domains) set up for the paywall, use it — that's the canonical `apiOrigin` (it's what the browser SDK uses, and what `start-checkout` falls back to when you don't pass explicit `successUrl`/`errorUrl`). **Pure headless without a custom domain.** Custom domain is a paywall-level setting; it isn't enforced at the REST host level. If you're integrating purely server-to-server, you don't strictly need one — pass explicit `successUrl`/`errorUrl` on every `start-checkout` (your own app's URLs) and you're set. Contact support for the system host to point your requests at. ## Authentication Two paths, pick by where the call originates from. ### `X-Api-Key` — server-side owner key Server-SDK key from **Dashboard → Settings → API keys**. Identifies *you* (the paywall owner) and can act on behalf of any user on your paywall(s). Used for every server-side method. ``` X-Api-Key: sk_live_... ``` **Never expose `X-Api-Key` to clients.** It's a server-only secret — read it from env vars / a secret manager. The SDK refuses to construct in browser context with this key set. ### `Authorization: Bearer` — per-user token Issued by monetize.software's auth (after `signInWith…` flows in the browser SDK). Identifies a single user; the backend resolves `user.id` from the token directly. ``` Authorization: Bearer eyJhbGc... ``` Bearer is the standard path for **browser** SPAs / Chrome extensions / Telegram Mini Apps where the user signed in via our `AuthClient`. For headless integrations with your own auth — use `X-Api-Key` instead. ### Identity passing (apiKey path) When you call a user-scoped method with `X-Api-Key`, the backend needs to know **which user** you're acting on. Pass `email` and your stable `userId` in the request — the backend looks up the matching user on your paywall. The backend additionally verifies the identity is linked to your paywall. Querying users that never interacted with your paywall returns `identity_not_on_paywall` (404). Cross-paywall lookup is blocked by design. ### Other headers | Header | When | Notes | |---|---|---| | `X-Paywall-Id` | Always (set by SDK) | Same value as the `{id}` URL segment | | `X-SDK-Version` | Optional | For our telemetry — when integrating directly, set to your client name + version | | `Idempotency-Key` | `start-checkout` | UUID v4. Duplicate POSTs with the same key return the same checkout URL | ## Endpoints ### `GET /api/v1/paywall/{id}/bootstrap` Returns the structural part of the paywall: settings, prices, offers, layout, locales. **Public** — no auth required for the structural payload. If you pass a Bearer token, the response also includes the user state (`user.has_active_subscription`, `purchases`). **Request** ``` GET /api/v1/paywall/3/bootstrap ``` **Response (200)** — see [BillingClient → bootstrap shape](/docs-v2/sdk-v3/bootstrap) for the full type. Key fields: ```json { "version": "sha256:abc...", "settings": { "id": "3", "name": "Upgrade to Pro", "brand_color": "#000", ... }, "prices": [ { "id": "monthly", "currency": "USD", "amount": 9.99, "interval": "month", "interval_count": 1, "trial_days": 7, "label": "Monthly", "local": { "currency": "EUR", "amount": 9.49 } } ], "offers": [...], "layout": { "type": "modal", "blocks": [...] }, "locales": { "en": {...}, "es": {...} } } ``` **curl** ```bash curl https://YOUR_DOMAIN/api/v1/paywall/3/bootstrap ``` ### `POST /api/v1/paywall/{id}/start-checkout` Creates a checkout session with the configured payment processor and returns its hosted URL. Redirect your user there. **Headers** ``` Content-Type: application/json X-Api-Key: sk_live_... (or Authorization: Bearer ...) Idempotency-Key: (mandatory for production) ``` **Body** ```json { "email": "user@example.com", "priceId": "monthly", "successUrl": "https://app.example.com/success?session=__SESSION__", "errorUrl": "https://app.example.com/checkout-failed", "shopUrl": "https://app.example.com/pricing", "trial_days": null, "ignoreActivePurchase": false, "userMeta": { "source": "email_campaign_q2" }, "localCurrency": "EUR" } ``` - `email` — required in apiKey path. Ignored in Bearer path (server reads it from the token). - `priceId` — required. Get it from `bootstrap.prices[].id`. **Don't hardcode it** — price IDs are dynamic and change when pricing is edited, the payment processor is switched, or a plan is recreated. Always read the current value from `bootstrap` at runtime; a stale hardcoded ID returns `404`. - `successUrl`/`errorUrl` — optional overrides. Default to `settings.success_redirect_url` from the dashboard. - `trial_days` — optional override on the price-level trial. - `ignoreActivePurchase` — pass `true` for renew/upgrade flows; otherwise the backend returns `409` if the user already has an active subscription. - `userMeta` — JSON metadata stored against the user's row on this paywall. - `localCurrency` — ISO 4217 hint for geo pricing (the backend may override based on its own GeoIP). **Response (200)** ```json { "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_test_...", "userId": "auth-uuid", "acquiring": "stripe" } ``` `acquiring` is one of `stripe`, `paddle`, `chargebee`, `freemius`, `overpay`. **Errors** | Status | Code / payload | When | |---|---|---| | 400 | `Missing required parameters: email, priceId` | Missing fields | | 400 | `Invalid successUrl format` etc. | URL doesn't match `https?://` | | 401 | `Invalid API key` | Bad `X-Api-Key` | | 403 | `Access denied: API key owner does not match paywall owner` | Key belongs to a different account | | 409 | `{ hasActivePurchase: true }` | User already has an active sub; retry with `ignoreActivePurchase: true` | **curl** ```bash curl -X POST https://YOUR_DOMAIN/api/v1/paywall/3/start-checkout \ -H "Content-Type: application/json" \ -H "X-Api-Key: $MONETIZE_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "email": "user@example.com", "priceId": "monthly", "successUrl": "https://app.example.com/success" }' ``` ### `GET /api/v1/paywall/{id}/user` Returns the user's purchases, balances, trial state, and computed geo-targeting for this paywall. **Headers** ``` X-Api-Key: sk_live_... (or Authorization: Bearer ...) ``` **Query (apiKey path only)** | Param | Required | Notes | |---|---|---| | `email` | one of two | Email of the user you're querying | | `user_id` | one of two | The user id returned by webhook events / by `start-checkout` response | In Bearer path, identity comes from the token — query params are ignored. **Response (200)** — full shape in [BillingClient docs](/docs-v2/sdk-v3/bootstrap#getuser). Key fields: ```json { "user": { "id": "...", "email": "...", "name": "...", "created_at": "..." }, "tier": 1, "country": "US", "balances": [{ "type": "standard", "count": 42 }], "countryMatch": true, "purchases": [ { "id": "sub_123", "status": "active", "interval": "month", "unit_amount": 999, "currency": "USD", "current_period_end": "2026-06-29T...", "cancel_at_period_end": false } ], "paid": true, "trial": null, "meta": { ... } } ``` In apiKey path, the `user` field is `undefined` — use `purchases`, `paid`, and `balances` instead. The `user` object is only populated in the Bearer path (where the auth session carries the profile). **Errors** | Status | Code | When | |---|---|---| | 400 | `identity_required` | apiKey without `?email=` or `?user_id=` | | 401 | `Unauthorized` | No Bearer and no apiKey | | 404 | `identity_not_found` | Email/userId doesn't exist in our system | | 404 | `identity_not_on_paywall` | Identity exists but never interacted with this paywall | **curl** ```bash # apiKey path curl "https://YOUR_DOMAIN/api/v1/paywall/3/user?email=user@example.com" \ -H "X-Api-Key: $MONETIZE_API_KEY" ``` ### `POST /api/paywall/cancel-subscription` Cancels a subscription at the end of the current billing period (acquirer-side). The DB is updated when the corresponding webhook arrives; the response carries a synthetic shape for immediate UI feedback. **Note the path** — this endpoint lives under `/api/paywall/` (not `/api/v1/paywall/`). **Headers** ``` Content-Type: application/json X-Api-Key: sk_live_... (or Authorization: Bearer ...) ``` **Body** ```json { "subscriptionId": "sub_123", "paywallId": "3", "cancellationReason": "too expensive", "email": "user@example.com", "userId": "your-internal-id" } ``` - `subscriptionId` — required. From `listPurchases()[].id` or your DB (synced via webhooks). - `paywallId` — required in apiKey path (used for cross-paywall protection). Ignored in Bearer path. - `cancellationReason` — required. Non-empty string. - `email` / `userId` — required in apiKey path (one of). Identity of the subscription owner. **Response (200)** ```json { "success": true, "message": "Subscription cancelled successfully", "subscription": { "id": "sub_123", "status": "active", "cancel_at_period_end": true, "cancel_at": "2026-06-29T00:00:00.000Z", "canceled_at": null } } ``` **Errors** | Status | Code | When | |---|---|---| | 400 | `identity_required` | apiKey without `paywallId` / email / userId | | 400 | `Subscription is already cancelled` | Idempotency: the sub was already cancelled | | 403 | `Unauthorized` | Bearer absent and apiKey absent (or anonymous Bearer) | | 404 | `identity_not_on_paywall` | apiKey path: identity exists but not on this paywall | | 404 | `Subscription not found` | `subscriptionId` doesn't match `(user_id, paywall_id)` | **curl** ```bash curl -X POST https://YOUR_DOMAIN/api/paywall/cancel-subscription \ -H "Content-Type: application/json" \ -H "X-Api-Key: $MONETIZE_API_KEY" \ -d '{ "subscriptionId": "sub_123", "paywallId": "3", "cancellationReason": "too expensive", "email": "user@example.com" }' ``` ### `POST /api/v1/paywall/{id}/get-customer-portal` Returns a one-time URL to the acquirer-hosted customer portal (Stripe / Paddle / Chargebee / Overpay). Freemius does not provide a hosted portal — this endpoint returns `403` with `{ isTest }` instead. **Headers** ``` Content-Type: application/json X-Api-Key: sk_live_... (or Authorization: Bearer ...) ``` **Body (apiKey path)** ```json { "email": "user@example.com", "userMeta": { "source": "..." } } ``` In Bearer path, both fields are ignored; identity is taken from the token. **Response (200)** ```json { "url": "https://billing.stripe.com/p/session/test_..." } ``` **Errors** | Status | Code | When | |---|---|---| | 400 | `Email is required` | apiKey path, body missing email | | 401 | `invalid_token` | Bearer present but invalid | | 403 | `{ isTest: "1" or "0" }` | Acquirer (e.g. Freemius) has no hosted portal, or the user has no active sub | **curl** ```bash curl -X POST https://YOUR_DOMAIN/api/v1/paywall/3/get-customer-portal \ -H "Content-Type: application/json" \ -H "X-Api-Key: $MONETIZE_API_KEY" \ -d '{ "email": "user@example.com" }' ``` ### `POST /api/v1/paywall/{id}/support/ticket` Creates a support ticket on behalf of a user. Two transports — JSON for text only, multipart for attachments (images up to 10 MB each, max 5 files; MIME: `image/jpeg`, `image/png`, `image/webp`). **Auth** — `Authorization: Bearer` (recommended) or unauthenticated guest with `customer_email` in the body. **JSON body** ```json { "subject": "Refund request", "content": "I would like to cancel and request a refund...", "customer_email": "user@example.com" } ``` - `subject` — required, 3–200 chars. - `content` — required, 1–5000 chars. - `customer_email` — required for guests. Ignored if Bearer is present (server uses the session email — anti-spoofing). **Multipart body** — same fields plus `files`: ``` files: files: ``` **Response (200)** ```json { "ticket": { "id": 12345, "status": "open" } } ``` **Errors** | Status | Code | When | |---|---|---| | 400 | `invalid_payload` | Missing `subject` or `content` | | 400 | `subject_length` / `content_length` | Outside bounds | | 400 | `email_required` | Guest without `customer_email` | | 400 | `too_many_files`, `invalid_file` | Multipart constraints | | 429 | `rate_limit_exceeded` | More than 2 tickets per 24 h per user/email | **curl** ```bash curl -X POST https://YOUR_DOMAIN/api/v1/paywall/3/support/ticket \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $USER_BEARER" \ -d '{ "subject": "Refund request", "content": "I would like to cancel..." }' ``` ### `POST /api/v1/paywall/{id}/balances` Credit **or** debit a user's token balance for a tokenized paywall. This is the SDK 3.0 way (preferred over the legacy `/api/v1/withdraw-tokens` below) — it backs `billing.creditTokens()` / `billing.debitTokens()`. Use it to grant tokens (promo, refund, manual top-up) or to debit after your backend called an AI provider itself. **Auth** — `X-Api-Key` only (server-side). Bearer is **not** accepted: a user must never be able to change their own balance. **Headers** ``` Content-Type: application/json X-Api-Key: sk_live_... ``` **Body** — identify the user by `email` or `user_id` (same identity model as the other v1 endpoints; the user must be linked to this paywall): ```json { "email": "user@example.com", "type": "gpt-4", "amount": 100, "op": "credit" } ``` - `email` *or* `user_id` — required (one of). `user_id` is the monetize.software auth `user.id`. - `type` — required. Must match a `tokenization_queries[].type` configured on the paywall. - `amount` — required, positive integer. - `op` — required: `"credit"` (add) or `"debit"` (subtract). The change is **atomic** on the backend (no lost updates vs concurrent `api-gateway` debits). A credit above the daily-trial limit is **not** clawed back — the daily trial top-up only raises balances up to the limit, never reduces a higher one. **Response (200)** ```json { "success": true, "user_id": "auth-uuid", "type": "gpt-4", "count": 142, "balances": [{ "type": "gpt-4", "count": 142 }] } ``` `count` is the new balance for `type`. **Errors** — JSON `{ error, code }`: | Status | `code` | When | |---|---|---| | 400 | `insufficient` (+ `available`) | A debit would drop below 0 — balance left unchanged | | 400 | `invalid_op` / `invalid_amount` / `type_required` | Bad body | | 401 | — | `X-Api-Key` missing/invalid | | 403 | — | Key owner is not the paywall creator | | 404 | `identity_not_found` | No user with that `email`/`user_id` linked to this paywall | **curl** ```bash # credit 100 tokens curl -X POST https://YOUR_DOMAIN/api/v1/paywall/3/balances \ -H "Content-Type: application/json" \ -H "X-Api-Key: $MONETIZE_API_KEY" \ -d '{ "email": "user@example.com", "type": "gpt-4", "amount": 100, "op": "credit" }' # debit 5 tokens curl -X POST https://YOUR_DOMAIN/api/v1/paywall/3/balances \ -H "Content-Type: application/json" \ -H "X-Api-Key: $MONETIZE_API_KEY" \ -d '{ "user_id": "auth-uuid", "type": "gpt-4", "amount": 5, "op": "debit" }' ``` ### `POST /api/v1/withdraw-tokens` **Legacy.** Predates SDK 3.0 — debit-only, with `paywall_id` + `user_id` in the body. Still supported for back-compat, but new integrations should use `POST /api/v1/paywall/{id}/balances` above (credit + debit, atomic, identity by email/userId). Debits credits from a user's balance for a tokenized paywall. Use this when your backend calls AI providers directly (your own OpenAI/Anthropic keys) and only needs to charge after a successful response. **Auth** — `X-Api-Key` only. Bearer not accepted on this endpoint. **Headers** ``` Content-Type: application/json X-Api-Key: sk_live_... ``` **Body** — note `snake_case`: ```json { "paywall_id": "3", "user_id": "auth-uuid", "tokens": 1, "token_type": "standard" } ``` - `paywall_id` — required. - `user_id` — required. **This is the monetize.software auth user.id**, not your own internal id. Get it from the `subscription.created` webhook (`event.data.user.id`) after the user goes through `start-checkout` for the first time, and store it alongside your own user row. - `tokens` — required, > 0. - `token_type` — optional, default `standard`. Must match a `tokenization_queries[].type` configured on the paywall. **Response (200)** ```json { "success": true, "remaining": 41 } ``` **Errors** | Status | Code | When | |---|---|---| | 400 | `Insufficient tokens` + `{ available, requested }` | Balance not enough | | 400 | `Token type X not found in user balance` | Wrong `token_type` | | 401 | `Invalid API key` | Bad / missing key | | 403 | `Unauthorized. You are not the owner of this paywall` | Key belongs to a different account | | 404 | `No balance found for this user and paywall` | The user has no balance on this paywall yet (e.g., hasn't completed a tokenized checkout or trial) | **curl** ```bash curl -X POST https://YOUR_DOMAIN/api/v1/withdraw-tokens \ -H "Content-Type: application/json" \ -H "X-Api-Key: $MONETIZE_API_KEY" \ -d '{ "paywall_id": "3", "user_id": "auth-uuid", "tokens": 1, "token_type": "standard" }' ``` ## Idempotency Only `start-checkout` requires explicit idempotency — pass a UUID v4 in `Idempotency-Key`. Duplicate requests with the same key (within ~24 h) return the same `checkoutUrl` without a second call to the payment processor. Without the header the SDK auto-generates one, but a direct REST caller should send one to protect against double-submits on flaky networks. `cancelSubscription` is naturally idempotent on the acquirer side — sending the same cancellation twice has no effect; the second call returns `400 Subscription is already cancelled`. The balance endpoints (`POST /api/v1/paywall/{id}/balances` and the legacy `withdrawTokens`) are **not** idempotent — every call credits/debits. Build your own dedup if your retry logic needs it (e.g., store an `idempotency_key` next to each AI-call record in your DB and skip the adjustment if it's already there). ## Webhooks State-of-truth syncing — your backend receives signed events when payments/subscriptions change. Full payload format and signature verification: - [Webhook events](/docs-v2/webhooks/events) — Full payload reference for subscription.*, payment.*, refund.* - [Create a webhook](/docs-v2/webhooks/create-webhook) — Endpoint setup and HMAC secret management ## Error format All errors return JSON: ```json { "error": "code_or_short_message", "message": "Human-readable detail (optional)" } ``` Some endpoints return additional fields (e.g. `hasActivePurchase`, `available`, `requested`). Status codes follow standard HTTP semantics — `400` for validation, `401` for missing/invalid auth, `403` for not-owner / forbidden, `404` for not-found, `409` for conflicts, `429` for rate limits, `5xx` for unexpected server errors. ## SDK reference (when you can use it) If you're on a JS runtime (Node / Bun / Deno / Edge / Workers) — these are the same endpoints typed and wrapped: - [BillingClient](/docs-v2/sdk-v3/bootstrap) — bootstrap, createCheckout, listPurchases, cancelSubscription, getCustomerPortalUrl, support tickets - [Headless / server-side](/docs-v2/sdk-v3/headless-server) — apiKey + identity pattern, creditTokens/debitTokens, webhook handler, per-user storage - [Customer portal](/docs-v2/sdk-v3/customer-portal) — Building your own My Subscriptions UI on top of these endpoints --- # Security Section: SDK 3.0 (current) URL: https://monetize.software/docs-v2/sdk-v3/security SDK 3.0 is designed for public clients — browsers, Chrome extensions, Telegram Mini Apps. This page covers defensive assumptions, known trade-offs, and best practices for integrators. ## Threat model What we defend against: - Charging other people's accounts / draining their quotas (defended via Bearer resolution on the backend). - Forged checkout URLs (server-driven, the SDK doesn't sign anything). - Cross-popup OAuth attacks (PKCE state nonce + popup-name matching). What we don't defend against: - XSS on the host page — if an attacker runs JS in your origin, the tokens are theirs (see below). - Reverse engineering the bundle — it's public code; any "API key" hardcoded inside is useless (the server-side `apiKey` option throws `apikey_in_browser` if it ever reaches a browser, so it can't end up in a bundle by accident). - Attacks on the platform's auth backend — out of scope for the SDK. ## Token storage and XSS By default `AuthClient` stores `access_token` and `refresh_token` in `localStorage` (web) or `chrome.storage.local` (extension). This is a deliberate trade-off: Approach For Against **localStorage** (default) Survives reloads, works in Chrome extensions, no cookie issues with SameSite/iframe Available to any JS on the page → XSS leaks it HttpOnly cookies JS can't see the token; XSS won't leak it immediately Doesn't work in Chrome extensions; cross-origin headaches; SDK 3.0 is intentionally cookie-free sessionStorage Doesn't survive reload (smaller attack window) User logs in again on every new tab — UX hurt In-memory only Minimum attack surface Reload = logout, useless for most SaaS **Mitigations built into the SDK:** 1. **Refresh-token rotation on the backend** — every successful refresh invalidates the previous refresh token. A leaked refresh token is likely already invalid by the time the attacker uses it (if the victim refreshed at least once after the leak). 2. **Short access-token lifetime** — 1 hour. A leaked access token can't be extended without the refresh token. 3. **`destroy()` on teardown** — on hot reload / SPA route changes, storage subscriptions are removed so no dangling references hold stale tokens. **What the host should do:** 1. **CSP** — `Content-Security-Policy: script-src 'self' ` blocks unknown JS injection (the main XSS vector). 2. **Subresource integrity for CDN scripts** — if you use ` ``` ```ts // offscreen.ts startOffscreenServer({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true }); ``` All HTTP, storage and auth-refresh happens here. Popup/content never touch the network directly. ### Popup — `popup.ts` ```ts const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true }); document.getElementById('upgrade')!.addEventListener('click', () => paywall.open()); paywall.on('purchase_completed', ({ priceId }) => { // popup will close shortly after; the unlock is already in storage }); paywall.on('authChange', ({ event, session }) => { if (event === 'SIGNED_IN') { // user just signed in — refresh popup UI } // INITIAL_SESSION fires once per subscriber after the popup mounts — use it // to paint the restored state, not as a signal that something just changed. }); ``` ### Gate a premium feature in a content script ```ts // content-script.ts const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true }); async function onPremiumAction() { const user = await paywall.billing.getUser(); if (user.has_active_subscription) { runPremiumFlow(); return; } paywall.open(); // same flow as popup; result is shared } ``` A purchase from the popup is visible here on the next `paywall.billing.getUser()` call (and via `userChange` event). No reload needed. ## Sync Subscriptions on Your Backend Same shape as web — see [SaaS Web guide → backend webhooks](/docs-v2/guide/sdk-v3-web#sync-subscriptions-on-your-backend). The only extension-specific note: don't try to persist the canonical user state in `chrome.storage` — Chrome wipes extension storage on uninstall/reinstall and on profile switches. Treat the backend (driven by webhooks) as the source of truth. ## CWS Review Checklist Submitting to the Chrome Web Store with paid features attracts extra scrutiny. - [ ] `host_permissions` is the **narrowest** set you actually use — for the API origin it's optional (our API is `ACAO: *`, SDK is Bearer + `credentials: 'omit'`); keep only as a CORS hedge or add specific domains if you have content scripts - [ ] Privacy policy link in the listing covers payment data, email collection, and analytics - [ ] “Permission justification” fields explain *each* permission with a user-facing reason - [ ] You don't list `offscreen.html` (or any internal page) in `web_accessible_resources` - [ ] No remote code execution: all paywall logic ships bundled in your `.zip` — the SDK 3.0 enforces this - [ ] Single Purpose declaration matches what the extension does (not “monetize stuff”) - [ ] You test the trial flow with a fresh profile, then a profile that already used the trial ## Production Checklist - [ ] You handle the offscreen-document lifecycle (Chrome can recycle it; `installRouter` re-creates on demand) - [ ] Webhook handler in your backend writes to a DB the extension's backend can query — `chrome.storage` is **not** the source of truth - [ ] If you use trials: backend webhook on `subscription.created` (with `status: trialing`) is what actually starts the trial clock, not the extension - [ ] You unsubscribe from `paywall.on(...)` listeners when the popup closes (DOMContentUnload) ## Next Steps - [Installation deep dive](/docs-v2/sdk-v3/installation) — Permissions, manifest variants, Telegram Mini App - [BillingClient](/docs-v2/sdk-v3/bootstrap) — Cross-context sync via storage.watch - [Security](/docs-v2/sdk-v3/security) — Token storage in extension contexts - [Authentication](/docs-v2/sdk-v3/auth) — OAuth in extensions, identity permission --- # Headless Billing with SDK 3.0 (Server / Custom UI) Section: Integration Guides URL: https://monetize.software/docs-v2/guide/sdk-v3-headless End-to-end guide for using SDK 3.0 **without the built-in UI**. You either render the paywall yourself or proxy AI calls from your backend. The same npm package as for the web — but you import only `@monetize.software/sdk/core` (≤ 8 KB gz, no Preact, no DOM). ## What “Headless” Means Here SDK 3.0 has three sub-exports of one package: - `@monetize.software/sdk/ui` — `PaywallUI`, Shadow DOM modal (browser) - `@monetize.software/sdk/core` — `BillingClient`, `AuthClient`, `ApiGatewayClient`, `EventTracker` (no UI, runs anywhere) - `@monetize.software/sdk` — both at once “Headless” = importing **only `/core`**. There is no separate “server-sdk” package and no `server_mode` flag in the dashboard — one `paywallId` serves UI, extension, and headless callers simultaneously. ## What We'll Build - Server-side checkout: Node backend fetches prices, starts checkout via `BillingClient`, returns a checkout URL to your frontend - Metered AI proxy: `ApiGatewayClient` forwards user requests to OpenAI/Anthropic and deducts tokens from the user's balance - Custom upgrade UX: catch `QuotaExceededError`, show your own upgrade screen instead of the built-in modal - Webhook sync so your DB knows who is paid ## Architecture ## Set Up the Paywall ### Create the paywall [Create a paywall](/docs-v2/paywall/create-paywall) and pick **SDK 3.0** as the SDK version (no Client / Server mode toggle on SDK 3.0 — every paywall is usable headlessly via `BillingClient`). For metered AI you'll likely want **Tokenized** pricing — see [Tokenization](/docs-v2/paywall/tokenization). ### Add a payment processor [Create a payment processor](/docs-v2/payment-processor/create-payment-processor) and [connect it](/docs-v2/payment-processor/connect-payment-processor). ### Add an API Provider (only for metered AI proxy) In **API Providers** → **New API Provider** configure your upstream (OpenAI / Anthropic / custom). The dashboard issues a `providerId` (UUID) you'll pass to `ApiGatewayClient`. Set per-request token costs in the provider's settings. ### Note your IDs You'll need `paywallId` (numeric, from paywall URL) and — for the gateway — `providerId` (UUID, from API Providers). ## Server-Side Checkout The simplest headless scenario: your backend fetches prices and starts checkout on the user's behalf. ### Install ```bash pnpm add @monetize.software/sdk ``` ### Initialize on your backend ```ts // server/billing.ts // One instance per process. apiKey identifies you (the paywall owner); // per-request identity is set just before the call (next step). export const billing = new BillingClient({ paywallId: process.env.MONETIZE_PAYWALL_ID!, apiOrigin: 'https://YOUR_DOMAIN', apiKey: process.env.MONETIZE_API_KEY! // server-only key from the dashboard }); ``` `apiKey` is generated in Dashboard → Settings → API keys. **Never put it in client code** — the SDK warns if it detects `window`. **Bring-your-own auth.** You don't register users in monetize.software's auth — pass their `email` + your stable `userId` per request via `setIdentity` (next step). For endpoints that require Bearer (`listPurchases`, `cancelSubscription`), see [Per-user Bearer](/docs-v2/sdk-v3/headless-server#per-user-bearer). ### Expose prices to your frontend ```ts // GET /api/prices app.get('/api/prices', async (_req, res) => { const boot = await billing.bootstrap(); res.json( boot.offers.flatMap((o) => o.prices.map((p) => ({ id: p.id, interval: p.interval, amount: p.amount, currency: p.currency })) ) ); }); ``` `bootstrap()` is cached — subsequent calls within ~10 minutes hit memory, not the network. ### Start a checkout ```ts // POST /api/checkout app.post('/api/checkout', async (req, res) => { const user = await yourAuth.requireUser(req); // your own session check // Bind this call to the user from your DB. billing.setIdentity({ email: user.email, userId: user.id // your stable ID — used to map subscriptions back to your DB }); const checkout = await billing.createCheckout({ priceId: req.body.priceId, idempotencyKey: crypto.randomUUID() // mandatory for production }); res.json({ url: checkout.url }); }); ``` Your frontend redirects the user to `checkout.url`. Everything from there (card form, 3DS, success page) is hosted by the payment processor. The webhook (later in this guide) tells you when the subscription is active. **`billing` is a singleton — `setIdentity` mutates shared state.** In a long-running worker, call `setIdentity` immediately before `createCheckout` on each request. Don't rely on the previous request's identity sticking around. For multi-tenant or concurrent flows, instantiate a fresh `BillingClient` per request (per [Headless / server-side → Per-request](/docs-v2/sdk-v3/headless-server)). ## Metered AI Proxy with `ApiGatewayClient` If you sell per-request AI access (chat, image gen, embedding), proxy the calls through Monetize's gateway. It deducts tokens, rejects when balance is empty, and works without `BillingClient`. ### Instantiate the gateway ```ts // server/ai.ts const gateway = new ApiGatewayClient({ paywallId: process.env.MONETIZE_PAYWALL_ID!, apiOrigin: 'https://YOUR_DOMAIN', userId: 'usr_external_123' // your stable user ID; sent as X-User-ID }); ``` `userId` is **headless-only** — it ships as the `X-User-ID` header. The same client used from a browser would emit a warning, because users could forge the header. Use `auth: ` (Bearer token) on the client. ### Proxy a single request ```ts // POST /api/ai/chat app.post('/api/ai/chat', async (req, res) => { try { const upstream = await gateway.call({ providerId: process.env.MONETIZE_OPENAI_PROVIDER_ID!, path: '/v1/chat/completions', method: 'POST', body: { model: 'gpt-4o-mini', messages: req.body.messages } }); // .call() returns the raw upstream Response — JSON, SSE, multipart, anything res.status(upstream.status); upstream.headers.forEach((v, k) => res.setHeader(k, v)); upstream.body?.pipeTo(/* write to res */ res as any); } catch (err) { if (err instanceof QuotaExceededError) { return res.status(402).json({ error: 'quota_exceeded', currentBalance: err.currentBalance }); } throw err; } }); ``` ### Stream responses `.call()` returns a `Response`, so SSE / token streaming works without buffering: ```ts const upstream = await gateway.call({ providerId, path: '/v1/chat/completions', body: { model: 'gpt-4o', stream: true, messages } }); return new Response(upstream.body, { headers: upstream.headers }); ``` ## Custom Upgrade UX on Quota Exceeded In headless mode no modal pops up — your code decides what happens. Two patterns: ```ts try { await gateway.call({ providerId, path, body }); } catch (err) { if (err instanceof QuotaExceededError) { // Return a 402 to your frontend; the frontend opens its own upgrade screen // and on confirm calls /api/checkout (above) return { needsUpgrade: true, currentBalance: err.currentBalance }; } throw err; } ``` ```ts const gateway = new ApiGatewayClient({ paywallId, userId, onQuotaExceeded: (err) => { // Single point of telemetry / Slack alerting / ticket creation metrics.increment('quota_exceeded', { user: userId }); } }); ``` The callback fires *in addition* to the thrown `QuotaExceededError` — useful for cross-cutting concerns. ## Sync Subscriptions on Your Backend In headless mode webhooks are even more important — there's no UI event to fall back on. ### Subscribe to events [Create a webhook](/docs-v2/webhooks/create-webhook) for `subscription.*`, `payment.completed`, `refund.created`. See [event payload reference](/docs-v2/webhooks/events). ### Verify and persist ```ts app.post('/api/webhooks/monetize', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.header('x-signature') ?? ''; const expected = crypto .createHmac('sha256', process.env.MONETIZE_WEBHOOK_SECRET!) .update(req.body) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return res.status(401).send('bad signature'); } const event = JSON.parse(req.body.toString()); switch (event.type) { case 'subscription.created': case 'subscription.updated': upsertSubscription(event.data.user.id, event.data.subscription); break; case 'subscription.cancelled': markCancelled(event.data.user.id); break; case 'refund.created': reverseEntitlements(event.data.payment.id); break; } res.send('ok'); }); ``` **Idempotency.** Paddle can resend `subscription.updated` for the same subscription with identical data — your handler must be idempotent. Use the event's `id` as a dedup key in a small table. ## Production Checklist - [ ] `paywallId`, `providerId`, API keys live in env vars / secret manager, never in code - [ ] `userId` passed to `ApiGatewayClient` is **stable** for the lifetime of the customer — losing it means a new user with a new trial balance - [ ] Quota errors return `402 Payment Required` to your frontend (not `500` - [ ] Webhook handler is idempotent and signature-verified - [ ] Webhook handler responds `2xx` within 10s — heavy work (emails, syncs) goes to a queue - [ ] You don't ship `userId` from your browser code — it's headless-only and forgeable from the client - [ ] If you use streaming AI responses, your frontend handles partial failures (quota can run out mid-stream) ## Next Steps - [API Gateway deep dive](/docs-v2/sdk-v3/api-gateway) — Balance state, optimistic decrements, providers - [BillingClient](/docs-v2/sdk-v3/bootstrap) — Bootstrap, prices, checkout flow - [Authentication](/docs-v2/sdk-v3/auth) — AuthClient methods, anonymous sessions, OAuth - [Storage adapters](/docs-v2/sdk-v3/storage) — Backing storage for server / mobile / extension - [Tokenization](/docs-v2/paywall/tokenization) — Per-query pricing setup for AI products - [Webhook events](/docs-v2/webhooks/events) — Full event payload reference --- # SaaS Subscription with SDK 3.0 (Web) Section: Integration Guides URL: https://monetize.software/docs-v2/guide/sdk-v3-web End-to-end guide for adding a subscription paywall to a web app / SPA using **SDK 3.0**. Bundled npm package, no iframe, Shadow DOM rendering, built-in auth. ## What We'll Build - Paywall modal that opens on “Upgrade” click — no iframe, no layout conflicts - Built-in email / OTP / OAuth login (managed by `AuthClient` - Subscription gate: check the user's plan on page load, show premium features only to paying users - Reaction to `purchase_completed` — instantly unlock the feature without page reload ## Architecture ## Set Up the Paywall ### Create the paywall [Create a paywall](/docs-v2/paywall/create-paywall) and pick **SDK 3.0** as the SDK version. SDK 3.0 paywalls have no separate Client / Server mode toggle — the SDK handles both the modal and headless flows. ### Add a payment processor [Create a payment processor](/docs-v2/payment-processor/create-payment-processor) (Stripe / Paddle / Chargebee / Freemius), then [connect it to the paywall](/docs-v2/payment-processor/connect-payment-processor). Start in test mode. ### Add subscription plans In the paywall settings, add at least one recurring plan (e.g. monthly + yearly). Mark one as recommended — it gets the highlight in the modal. ### Note your `paywallId` Take the numeric ID from the paywall URL in the dashboard — you'll pass it to the SDK. ## Integrate the SDK ### Install ```bash pnpm add @monetize.software/sdk # or: npm i @monetize.software/sdk ``` ### Initialize once at app boot ```ts // src/paywall.ts export const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true // managed AuthClient: email + OTP + OAuth }); ``` `auth: true` is enough for most apps — the SDK creates an `AuthClient` internally, persists the session, and refreshes tokens. If your app already has its own login, you can pass user data into the SDK with [`billing.setIdentity({ email, userId })`](/docs-v2/sdk-v3/bootstrap#setidentityidentity--getidentity) or run the [headless flow](/docs-v2/guide/sdk-v3-headless) entirely from your backend. ### Open the modal ```ts document.getElementById('upgrade-btn')!.addEventListener('click', () => { paywall.open(); }); ``` ### Gate features by subscription Use the `BillingClient` exposed on `paywall.billing` to read user state. It's bootstrapped lazily on first call and cached cross-tab via storage events. ```ts async function gateFeature() { const user = await paywall.billing.getUser(); if (user.has_active_subscription) { enablePremium(); return; } // Not subscribed — open paywall and wait for purchase paywall.open(); } ``` `user.has_active_subscription` is the coarse boolean — covers active subscription, lifetime payment, or active trial. For per-plan logic, walk `user.purchases` (each item has `id`, `status`, `current_period_end`, `cancel_at_period_end`). For renewal / cancel UI, use `billing.listPurchases()` — see [Customer portal](/docs-v2/sdk-v3/customer-portal). For a side-effect-free check (no `bootstrap` call, no modal mount) prefer `paywall.getAccess()` — it returns a discriminated union `granted | blocked` with `reason: 'has_subscription' | 'trial_blocked' | 'visibility_blocked' | 'no_subscription'`: ```ts const access = await paywall.getAccess(); if (access.access === 'granted') enablePremium(); else paywall.open(); ``` ### React to purchase ```ts const unsubscribe = paywall.on('purchase_completed', ({ priceId, sessionId }) => { enablePremium(); // Optional: tell your backend the user just paid, so you can pre-warm their workspace fetch('/api/post-purchase', { method: 'POST', body: JSON.stringify({ sessionId, priceId }) }); }); paywall.on('close', () => { // user dismissed the modal without buying }); paywall.on('error', (err) => { // network / config errors; payment failures come via 'purchase_failed' console.error(err); }); ``` `paywall.on()` returns an unsubscribe function — call it on component unmount. Full event list: [Events](/docs-v2/sdk-v3/events). ## Production Checklist - [ ] `paywallId` and `apiOrigin` come from env vars, not hardcoded strings - [ ] In dev you use a test paywall + test payment processor (separate `paywallId` - [ ] You unsubscribe from `paywall.on(...)` listeners on component unmount (React/Vue) - [ ] `auth: true` matches your privacy policy — review where the SDK stores tokens ([Security](/docs-v2/sdk-v3/security)) **Need server-side subscription sync?** Webhooks are the source of truth for the database state — `purchase_completed` can be missed if the tab closes mid-checkout. See the [Headless guide → Sync Subscriptions on Your Backend](/docs-v2/guide/sdk-v3-headless#sync-subscriptions-on-your-backend) for the full handler. ## Next Steps - [BillingClient API](/docs-v2/sdk-v3/bootstrap) — Bootstrap cache, cross-tab sync, optimistic updates - [PaywallUI API](/docs-v2/sdk-v3/ui) — All events, options, state machine - [Authentication](/docs-v2/sdk-v3/auth) — Email / OTP / OAuth, anonymous sessions - [Security](/docs-v2/sdk-v3/security) — Token storage, CSRF, allowed origins