Skip to Content
GuideSDK 3.0 — Chrome Extension

Chrome Extension with SDK 3.0

End-to-end guide for monetizing a Manifest V3 Chrome extension using @monetize.software/sdk-extension. Bundled npm package — passes CWS review (no remote code, no iframe), shares a single auth/bootstrap state across popup, content scripts, and service worker via an offscreen document.

Complexity: Intermediate Perfect for: Chrome / Edge / Firefox MV3 extensions with paid features, browser-side AI assistants, productivity tools Time: ~30 minutes

What We’ll Build

  • Extension with popup + content script + service worker, all sharing one user session
  • Premium feature gated behind a paywall — opens in the popup, unlock visible everywhere
  • Trial counter that decrements across tabs without re-fetching
  • Manifest tuned for CWS review (minimal host_permissions, no web_accessible_resources hack)
  • Webhook handler on your backend to sync subscription state

Why a Separate Package?

A standalone @monetize.software/sdk instance in each context (popup, content, SW) would mean three independent auth sessions, three bootstrap fetches, three trial counters. Users would have to log in every time they opened the popup. @monetize.software/sdk-extension solves this by running one SDK instance inside an offscreen document; popup and content scripts proxy calls to it through chrome.runtime ports.

When You Can Skip the Offscreen Document

The offscreen architecture is needed only when untrusted or DOM-less contexts must touch billing:

  • Content scripts on third-party sites — they run inside a hostile page and must not hold the Bearer token directly (see Security).
  • The service worker — it has no DOM, so PaywallUI can’t render there, and Chrome recycles it.

If your only paywall surfaces are privileged extension-origin pages — the popup, a side panel, a full-page tab (chrome-extension://…), or the options page — you don’t need the offscreen package at all. Those pages already share chrome.storage.local (which the SDK auto-detects), can safely hold the Bearer token, and have a DOM for PaywallUI. Install the core package and instantiate one client per surface:

pnpm add @monetize.software/sdk
import { PaywallUI } from '@monetize.software/sdk'; // One instance per surface (popup / side panel / full-page tab / options). // `auth: true` makes PaywallUI build its own AuthClient + BillingClient; // reach them via paywall.auth / paywall.billing. const paywall = new PaywallUI({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true });

A login, bootstrap or balance update in one surface propagates to the others via chrome.storage.onChanged with no extra wiring — that’s the same cross-context sync the offscreen variant relies on, minus the proxy. If you also need the metered-AI gateway, build the clients explicitly and call billing.createApiGatewayClient():

import { AuthClient, BillingClient } from '@monetize.software/sdk/core'; import { PaywallUI } from '@monetize.software/sdk'; const auth = new AuthClient({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN' }); const billing = new BillingClient({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth }); const paywall = new PaywallUI({ client: billing, auth, paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN' }); const gateway = billing.createApiGatewayClient();

Do not write a custom storage adapter for this. Inside an extension the SDK already resolves chrome.storage.local automatically (it checks chrome.runtime.id) and wires chrome.storage.onChanged for cross-context sync. Hand-rolling a chrome.storage adapter just re-implements the built-in one, and adding your own key prefix permanently couples the stored session to that adapter. Omit storage entirely — supply an adapter only for a genuinely different backend (encrypted store, sessionStorage, server-side KV; see Storage adapters).

Tradeoff: each surface runs its own AuthClient/BillingClient, so two simultaneously-open surfaces each fetch bootstrap and refresh the token on their own timer (with a narrow refresh-rotation race window). Cross-context sync keeps them consistent. If many privileged pages are routinely open at once — or you need content scripts / the SW to read billing — use the single-instance offscreen architecture below instead.

Architecture

All state lives in the offscreen document. Popup/content render UI; SW is a thin router.

Set Up the Paywall

Create the paywall

Create a paywall and pick SDK 3.0 as the SDK version. The same paywall works in any browser context (popup, content script, options page) — SDK 3.0 paywalls have no Client / Server mode toggle.

Add a payment processor

Create a payment processor and connect it. For extensions, Paddle is often easier (handles VAT/sales tax for digital goods).

Note your paywallId

Integrate the SDK

Install

pnpm add @monetize.software/sdk-extension preact

preact is a peer dependency — extensions need an explicit copy in their bundle.

Manifest

{ "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" }, "action": { "default_popup": "popup.html" } }

Add "identity" if you enable OAuth in the paywall. Add "content_scripts" only if you render the paywall on third-party sites.

host_permissions for your API origin is optional. Our API sends Access-Control-Allow-Origin: * on /api/v1/* with every SDK header allowed, and the SDK fetches with credentials: 'omit' — so calls from extension pages, the offscreen document and the service worker all succeed under plain CORS, without any host permission. Keep the line only as a hedge: with host_permissions the extension bypasses CORS entirely and stays immune to future changes in the API’s CORS config. It is not needed for cookie auth (the SDK is Bearer-only) and does not help content scripts — in MV3 they can’t bypass CORS via host permissions, which is exactly why billing routes through the offscreen/background. For the strictest CWS manifest, drop it.

Do NOT set host_permissions: ["<all_urls>"] unless your extension genuinely needs to inject content on every site. CWS reviewers manually audit <all_urls> extensions, and AV vendors (Avast, Kaspersky) flag them. The SDK itself only needs your API origin. See host_permissions deep-dive.

Do NOT add offscreen.html to web_accessible_resources. Offscreen documents are created via chrome.offscreen.createDocument — a chrome-API call, not a <iframe src>. Listing it makes your extension fingerprintable from any page and exposes it to embedding attacks.

Service Worker — sw.js

import { installRouter } from '@monetize.software/sdk-extension/sw'; installRouter({ offscreenUrl: chrome.runtime.getURL('offscreen.html') });

That’s the entire SW. The router lazily creates the offscreen document the first time popup/content sends a message, and proxies subsequent calls.

Offscreen — offscreen.html loads offscreen.js

<!-- offscreen.html --> <!doctype html> <script type="module" src="offscreen.js"></script>
// offscreen.ts import { startOffscreenServer } from '@monetize.software/sdk-extension/offscreen'; startOffscreenServer({ paywallId: '3', apiOrigin: 'https://YOUR_DOMAIN', auth: true });

All HTTP, storage and auth-refresh happens here. Popup/content never touch the network directly.

import { PaywallUI } from '@monetize.software/sdk-extension'; 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

// content-script.ts import { PaywallUI } from '@monetize.software/sdk-extension'; 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. 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