@monetize.software/sdk-extension
v3.5.1
Published
Monetize SDK for Chrome extensions — single source of truth via offscreen document, drop-in compatible with @monetize.software/sdk public API
Maintainers
Readme
@monetize.software/sdk-extension
SDK for Chrome extensions. A single offscreen document holds the BillingClient, AuthClient and EventTracker — the single source of truth for all tabs, popups, side panels, and extension pages.
The content-script public API is drop-in compatible with @monetize.software/sdk —
the host writes import { PaywallUI } from '@monetize.software/sdk-extension' and
gets the same class with the same method set.
⚠️ Bundle as an npm dependency. Do not load from a CDN. Chrome Web Store MV3 policy forbids remote code execution — every line of JS your extension runs must be reviewable at submission time and ship inside the extension package.
pnpm add @monetize.software/sdk-extensionand bundle it with Vite/Rollup/webpack like any other npm dep. Loading this package (or@monetize.software/sdk, or@monetize.software/sdk-react) fromesm.sh/unpkg/jsDelivrfrom a content script, popup, or service worker will get the extension rejected by review, or removed retroactively if the policy violation is spotted later. This is also why we publishsdk-extensionas a separate package — its content-script bundle has all dependencies inlined, no runtime fetch of code.
Architecture
content script (per tab) ──port──▶ service worker ──port──▶ offscreen
│ (forwarder) │
Shadow DOM modal BillingClient
RemoteBillingClient AuthClient
EventTracker
UserWatcher- content-script: UI + RemoteBillingClient (proxy over a port into offscreen).
- service worker: content↔offscreen router. OAuth uses a popup window opened
against your
apiOrigin(custom_domain) —chrome.identityis not used. - offscreen: the real SDK state, survives tab closes, the sole coordination point for auth refresh / trial counter / analytics batching.
Usage
In the extension:
// service-worker.ts
import { installRouter } from '@monetize.software/sdk-extension/sw';
installRouter({
offscreenUrl: chrome.runtime.getURL('offscreen.html'),
apiOrigin: 'https://your-paywall-domain.com' // see "Surviving surfaces" below
});// offscreen.html → offscreen.ts
import { startOffscreenServer } from '@monetize.software/sdk-extension/offscreen';
startOffscreenServer({ paywallId: '...', apiOrigin: 'https://...' });// content-script.ts (in every tab)
import { PaywallUI } from '@monetize.software/sdk-extension';
const paywall = new PaywallUI({ paywallId: '...', apiOrigin: '...' });
paywall.open(); // exactly like @monetize.software/sdkOn websites — keep using @monetize.software/sdk, nothing changes.
Advanced: calling the metered API gateway yourself
PaywallUI covers the paywall + checkout flow. If you also hit the metered AI proxy
(/api/v1/api-gateway/...) directly, build ApiGatewayClient from
@monetize.software/sdk/core and pass the extension's RemoteAuthClient as the auth
source — the Bearer is resolved in offscreen via getAccessToken(), so there is still a
single AuthClient and no token duplication:
// content-script.ts / popup.ts
import { ApiGatewayClient, QuotaExceededError } from '@monetize.software/sdk/core';
import type { AuthClient } from '@monetize.software/sdk/core';
const gateway = new ApiGatewayClient({
paywallId,
apiOrigin, // same custom_domain as PaywallUI
auth: paywall.auth as unknown as AuthClient, // RemoteAuthClient, duck-typed
onQuotaExceeded: () => paywall.open() // 402 → show the paywall
});
try {
const res = await gateway.call({ providerId, path: 'v1/chat/completions', body });
// res is the raw Response — res.json() / res.body.getReader() for SSE.
} catch (err) {
if (err instanceof QuotaExceededError) { /* out of quota */ }
}Import
ApiGatewayClientandQuotaExceededErrorfrom the same@monetize.software/sdk/coreentry.sdk-extensiondeliberately does not re-export them: its content bundle inlines a copy ofsdk, so aQuotaExceededErrorre-exported fromsdk-extensionwould be a different class identity and silently breakinstanceofat the call site.ApiGatewayClientitself is transport-agnostic infra and belongs incore, not in the chrome-remoting package.
Manifest: what to declare in the host extension
The SDK itself does not add anything to the manifest — the host extension picks permissions to match its own UX. Minimum for the SDK to work:
{
"permissions": ["offscreen", "storage"],
"host_permissions": ["https://your-paywall-domain.com/*"],
"background": { "service_worker": "sw.js", "type": "module" }
}host_permissions must list your apiOrigin — the custom_domain configured
for your paywall in the platform (the same value you pass to new PaywallUI({ apiOrigin })).
This is the only origin the SDK calls from offscreen / SW / content-script (bootstrap,
checkout, billing, auth). There is no api.monetize.software — every customer ships
their own custom domain.
web_accessible_resources for offscreen.html is not required — the document
is created by the service worker via chrome.offscreen.createDocument, a Chrome API
that doesn't need WAR. Listing it adds attack surface (any site could <iframe> your
offscreen, plus it fingerprints your extension ID).
The SDK does not use chrome.identity — OAuth runs via a popup window opened
against your apiOrigin, so no "identity" permission is needed.
Surviving surfaces: pass apiOrigin to installRouter
OAuth normally returns the auth code by postMessage to the window that started
it. A toolbar action popup does not survive that: Chrome closes it the moment
the provider window takes focus, and whether that happens is up to the OS window
manager — the same extension signs in fine on one machine and silently fails on
another (users see the popup vanish and nothing happen; opening DevTools "fixes"
it, because DevTools keeps the popup alive).
Passing apiOrigin to installRouter closes that hole. The service worker
watches for the provider's redirect landing on your callback page, reads the code
from the URL, and hands it to offscreen, which owns the PKCE verifier and
completes the sign-in on its own. The originating surface no longer needs to be
alive; every surface still open gets the usual authChange, and the next one to
open picks the session up from storage.
No new manifest permission is required — the URL is already visible to the worker
through the host_permissions entry you declare for apiOrigin. Omit the option
and behaviour is exactly as before.
The purchase continues too. When the sign-in was gating a checkout
(checkout_mode: 'preauth'), the intent travels with the flow: offscreen creates
the checkout as soon as the session lands, and the worker opens it as a tab in
the window the user is actually working in. They go provider → payment without
reopening an extension and clicking buy a second time. Nothing to configure —
PaywallUI passes the pending purchase automatically.
The payment page deliberately does not reuse the provider window: that window closes itself moments after the redirect lands, and it is a 480×640 popup, which is no place to enter card details. If the user turns out to already own the subscription, no checkout is opened at all — the next surface they open shows the restored state.
Two caveats worth knowing:
- If the offscreen document itself died mid-flow (extension reload/update, browser restart, OOM), the verifier is gone and nothing can be adopted — the user signs in again.
- Provider errors come back in the URL fragment, which a worker cannot read. Those still surface the old way: the window closes without a code and the flow reports a cancellation.
For a paywall that must work in a popup regardless, a side panel or a full-page extension tab remains the sturdiest surface — neither is tied to focus.
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 be
injected into (together with content_scripts.matches).
| Scenario | Recommendation |
|---|---|
| Host extension already needs <all_urls> (recorder, all-sites tool, assistant) | Keep <all_urls>. SDK works as-is. Risk: Chrome Web Store review for <all_urls> is a manual audit and takes longer; AV vendors (Avast/Kaspersky/etc.) are more likely to flag such extensions as PUA. That's the price of broad injection — it's a property of your use case, not an SDK risk. |
| Host extension only talks to your backend and gates its own features (popup tool, side-panel app) | Do NOT request <all_urls>. Your apiOrigin (custom_domain) is enough: ["https://your-paywall-domain.com/*"]. No content-script injection on every site needed. |
| Hybrid — popup tool, but content-script needed on a narrow list of domains | Constrain both host_permissions and content_scripts.matches to those domains: ["https://*.your-target.com/*", "https://your-paywall-domain.com/*"]. |
The main signal to CWS/AV: the narrower host_permissions, the less suspicion.
Keep <all_urls> only when it's genuinely required for your UX, and be ready to
justify it in CWS review (the "Permission justification" field).
Demo extension: build modes
For self-testing and e2e there's demo-extension/ — a reference implementation.
Two builds are available:
pnpm build:demo # production build (= the template clients can copy)
pnpm build:demo:e2e # debug build — exposes window.__paywall for Playwrightbuild:demo does NOT put window.__paywall into the bundle (dead-code-eliminated
via import.meta.env.MODE !== 'e2e'). The template clients copy stays clean: any
script on the page could otherwise call paywall.open() / paywall.track() and
abuse someone else's extension.
pnpm dev:demo builds in e2e mode (handy for live debugging from the DevTools console).
