Manifest V3 Guide
Common MV3 pitfalls when building paid Chrome extensions, and how crxpay solves them.
Why MV3 makes payments harder
Manifest V3 introduced several changes that break existing payment flows:
- Service workers replace background pages — no persistent state, workers can be killed at any time
- Content scripts can't make cross-origin requests — direct API calls from content scripts fail with CORS errors
- No remote code execution — can't load payment scripts from a CDN
- Stricter CSP — limits how checkout pages can be embedded
crxpay is built for MV3 from day one. Here's how it handles each challenge.
Challenge 1: Service worker lifecycle
Problem: MV3 service workers are terminated after ~30 seconds of inactivity. Any in-memory subscription state is lost.
Solution: crxpay uses an HMAC-signed cache in chrome.storage.local. When the service worker restarts:
- Read cache from
chrome.storage.local(persists across restarts) - Verify HMAC signature (prevents tampering)
- If cache is fresh (< 4 hours): return immediately, no network
- If stale: fetch from API, re-sign, re-cache
// This works even after the service worker restarts
const result = await CrxPay.getSubscription();
// result.data._source === 'cache' (from signed local storage)
Challenge 2: Background refresh
Solution: crxpay uses chrome.alarms, the MV3-safe replacement:
CrxPay.configure({
apiKey: 'crxpay_pub_...',
refreshInterval: 'hourly', // uses chrome.alarms internally
});
The alarm fires even if the service worker was killed — Chrome wakes it up to handle the alarm.
Challenge 3: CORS in content scripts
Problem: Content scripts run in the web page's origin. fetch('https://api.crxpay.io/...') from a content script fails with a CORS error.
Solution: crxpay's content script context (@crxpay/sdk/content) routes all network calls through the background service worker via chrome.runtime.sendMessage:
Content Script Background Service Worker
│ │
├── sendMessage({ │
│ type: 'CRXPAY_GET_SUBSCRIPTION'│
│ }) ──────────────────────────────►│
│ ├── fetch(api.crxpay.io)
│ │ ✓ Works! (extension origin)
│◄────────────────────────────────────┤
│ { ok: true, data: subscription } │
You don't need to think about this — it's automatic:
// content.js — identical API to popup.js
import { CrxPay } from '@crxpay/sdk/content';
const result = await CrxPay.getSubscription(); // routed through background
Challenge 4: Checkout flow
Problem: You can't embed a Stripe checkout iframe in an extension popup (CSP blocks it). You can't use window.open() from a service worker (no DOM).
Solution: crxpay opens checkout in a new tab via chrome.tabs.create:
await CrxPay.openCheckout('price_...');
// Opens: https://checkout.stripe.com/... in a new tab
// After payment: Stripe webhook → crxpay API → subscription updated
// Next getSubscription() call returns the new state
Challenge 5: Extension context invalidation
Problem: When an extension updates or reloads, existing content scripts become "orphaned" — chrome.runtime.sendMessage throws "Extension context invalidated."
Solution: crxpay catches this and returns a typed error instead of throwing:
const result = await CrxPay.getSubscription();
if (!result.ok && result.error.code === 'EXTENSION_CONTEXT_INVALID') {
// Extension was reloaded — show a "please refresh" message
showRefreshBanner();
}
Challenge 6: Subscription status disappears
Problem: ExtPay and similar solutions check subscription status via a network call on every read. When the network fails (airplane mode, flaky connection), the subscription status becomes unknown and users lose access to features they paid for.
Solution: crxpay's offline-first signed cache means subscription status is always available:
Network OK → fresh data from API (re-cached locally)
Network down → cached data from chrome.storage.local
No cache → { status: 'unknown', isActive: false }
The cache is HMAC-SHA256 signed so users can't tamper with it to fake a subscription.
Was this page helpful?
Your feedback shapes what we document next.