@crxpay/sdk
v0.1.2
Published
Chrome Extension Subscriptions, Paywalls & A/B Testing SDK — Stripe Connect, MV3-native, signed offline cache.
Maintainers
Readme
@crxpay/sdk
Chrome Extension Subscriptions, Paywalls & A/B Testing — drop-in SDK for Manifest V3.
@crxpay/sdk handles the parts of charging for a Chrome extension that
shouldn't be left to a developer in a hurry: subscription state, entitlement
checks, signed offline cache, paywall opening, license-key activation,
and Stripe Checkout / Customer Portal integration. It is designed to be
dropped into a Manifest V3 background service worker and forgotten.
If you have used ExtPay, the API is intentionally similar — but the SDK is MV3-native, the cache survives offline, and the subscription state is signed so it cannot be flipped to "active" from DevTools.
Install
npm install @crxpay/sdk
# or
pnpm add @crxpay/sdkPeer dependencies: react >= 18 (only when using the /react entry — the
core SDK has no React dependency).
Quick start (background service worker)
Where to get your publishable key: dashboard.crxpay.io → register your extension → API keys in the project sidebar. The key starts with
crxpay_pub_. Safe to commit (it's the public key — like Stripe'spk_test_/pk_live_).
// background.ts
import { CrxPay } from '@crxpay/sdk/background';
const client = CrxPay.configure({
apiKey: 'crxpay_pub_…', // copied from /p/<your-extension>/api-keys
mode: 'test', // 'test' for sandbox, 'live' for production. Default: 'live'.
});
// Required: forward popup / options / content-script messages to the SDK.
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg?.type?.startsWith('CRXPAY_')) {
client.handleMessage(msg, sender).then(sendResponse);
return true; // keep the message channel open for the async response
}
});
// Fires when the user transitions from non-paid → active or trialing.
client.onPaid.addListener((subscription) => {
// unlock premium features here
});Reading subscription state from a popup or content script
// popup.ts
import { CrxPay } from '@crxpay/sdk/content';
const subscription = await CrxPay.getSubscription();
if (subscription.hasEntitlement('pro')) {
showProUI();
} else {
await CrxPay.openCheckout(); // opens hosted checkout in a new tab
}Bundling for MV3 — read this before you ship
The SDK ships as ESM with xstate and zod declared as bare external
imports (you bring your own copies, bundlers tree-shake the unused parts).
This is normal for npm libraries — but Manifest V3 service workers cannot
resolve bare module specifiers like import { x } from 'xstate'. They only
accept relative paths (./foo.js) or chrome-extension:// URLs.
Symptom if you skip this: the service worker silently fails on first load. No console error, no SDK behavior, your popup looks dead. Ask us how we know.
Solution: bundle your background script with esbuild (or any bundler
that inlines transitive deps). A minimal build.mjs:
import { build } from 'esbuild';
await build({
entryPoints: ['background.js'],
bundle: true, // inline xstate + zod + the SDK
format: 'esm',
platform: 'browser',
target: ['chrome120'],
outfile: 'background.bundle.js',
});Then point your manifest at the bundled file:
{
"background": { "service_worker": "background.bundle.js", "type": "module" }
}If you'd rather see real working examples, check apps/extension-test/ and
apps/extension-test-2/ in the repo — both ship a build-bg.mjs that
demonstrates the pattern.
Verifying boot in dev
Drop this into your popup or an options page to confirm the SDK actually came up — not just imported:
const reply = await chrome.runtime.sendMessage({ type: 'CRXPAY_HEALTH' });
// reply.status === 'ok' — booted cleanly
// === 'booting' — still starting (call again in a sec)
// === 'degraded' — booted but with errors (see reply.errors)(In your background script, route CRXPAY_HEALTH to
client.healthCheck() — see the message-router snippet below.)
manifest.json — what the SDK needs
{
"manifest_version": 3,
"background": { "service_worker": "background.js", "type": "module" },
"permissions": ["storage", "alarms", "tabs"],
"host_permissions": ["https://api.crxpay.io/*"]
}That's it. No web_accessible_resources, no content_scripts unless you
want SDK access from a content script (in which case import from
@crxpay/sdk/content).
API surface
| Entry | What's exported | Use it from |
|---|---|---|
| @crxpay/sdk/background | CrxPay.configure({...}) returning a client with handleMessage, onPaid, etc. | Background service worker only |
| @crxpay/sdk/content | CrxPay.getSubscription(), openCheckout(), etc. — every call is forwarded to the background via chrome.runtime.sendMessage | Popup, options page, content scripts |
| @crxpay/sdk/react | <CrxPayProvider>, useSubscription(), useOpenCheckout() | Any React UI inside the extension |
| @crxpay/sdk/server | License-key signature verifier (offline) | Your own backend, if you want to validate keys before hitting our API |
| @crxpay/sdk | Re-exports types | Anywhere |
The full TypeScript surface ships with the package — every method has inline JSDoc.
Result envelopes — the SDK never throws
Every public method returns a Result<T>:
type Result<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string } };This is the same pattern Stripe's newer SDKs use. It means a misconfigured
SDK can never crash your background worker — the worst case is an
{ ok: false } you can branch on.
Security model
What we defend against
- Tampered local cache — every cache entry is HMAC-signed with a key
derived from the extension's
chrome.runtime.idplus the publishable API key. A user opening DevTools and editing the cached subscription to flipstatus: "none"→"active"will see the cache discarded silently on the next read. Verified insrc/__tests__/security-smoke.test.ts. - Cross-extension cache replay — copying a cache entry from extension A into extension B does not work; the HMAC key is install-scoped.
- Sibling extension lifting license keys / customer JWTs — license
keys and the customer JWT issued after magic-link verification are
encrypted with AES-GCM (key derived from
chrome.runtime.idvia PBKDF2-100k). A different extension installed in the same browser profile cannot decrypt them. - Foreign-extension messages —
client.handleMessagerejects messages whosesender.iddoes not matchchrome.runtime.id. Defense in depth if you ever wireexternally_connectable. - Hostile public input — every value passed to a network call goes
through a Zod pre-flight (
identify,openCheckout,activateKey, …). Malformed input is rejected before it touches the API.
What we do NOT defend against
- A motivated attacker with persistent local access can re-derive the AES-GCM key (PBKDF2 inputs are public). The encryption raises the bar against sibling extensions and DevTools snooping; it is not designed to defeat a forensic adversary.
- A customer who captures the encrypted cache while subscribed and
restores the snapshot after canceling will appear "active" until the
cache TTL expires — 60 seconds for license-backed subscriptions, 4
hours otherwise. If you need hard-cancel-now semantics, force a
client.syncSubscription()on every entitlement check. - The publishable API key is, by design, public. It can be read from any packaged extension. Authorization for sensitive operations happens server-side via the customer JWT and org-binding on the extension row.
Reporting vulnerabilities
Please email [email protected]. Do not open public issues for security reports.
Development
pnpm install
pnpm test # 135 tests, including security smoke tests
pnpm build # outputs to dist/ — no .map files, no source leakLicense
MIT — see LICENSE in this package.
