Core concepts
Customers, products, prices, entitlements, offerings, and the signed cache — the mental model behind every API.
Most crxpay confusion comes from mixing up these five concepts. This page is the conceptual map; once it clicks, every dashboard page and SDK method makes sense.
The five primitives
┌─────────────┐ ┌───────────────┐ ┌────────────────┐
│ Customer │ ←─┤ Subscription │←─→│ Price │──→ Product ──→ Entitlement
│ (a person) │ │ (the contract)│ │ (USD/month) │
└─────────────┘ └───────────────┘ └────────────────┘
Offering = a curated list of Prices to show on a paywall
Customer
A person who has identified themselves to your extension (or paid, which implicitly identifies them). One row per email per extension, in either test or live mode.
Created via either:
await CrxPay.identify('jane@example.com')from the SDK- The
checkout.session.completedwebhook, when an anonymous user pays — Stripe collected their email at checkout and we link it back
Fields you can read in the dashboard or via the API: email, installId, createdAt, processorCustomerId (Stripe cus_…), metadata, attached subscriptions, lifetime revenue.
Product
What you sell. "Pro", "Lifetime Pro", "AI add-on". Mirrors a Stripe Product (prod_…) on your connected account.
You don't usually compare two products — you compare two prices of the same product. Naming is for humans (dashboard, hosted /pay page); the SDK rarely cares about products directly.
Price
How much, in what currency, how often. Mirrors a Stripe Price (price_…). One product can have many prices:
Product "Pro"
├─ price_monthly_usd $9.99 / month
├─ price_annual_usd $79.99 / year
├─ price_monthly_eur €9.49 / month
└─ price_lifetime_usd $149 (one-time)
When you call CrxPay.openCheckout(priceId), you're picking which of these prices to charge. Omit the argument and the SDK opens your default offering.
Entitlement
A capability flag the SDK checks. Decoupled from products so you can rearrange pricing without touching extension code.
Product "Pro" ─┐
Product "Lifetime Pro" ─┼─→ entitlement: "pro"
Product "Pro · Annual" ─┘
Product "AI add-on" ──→ entitlement: "ai_features"
In code: subscription.hasEntitlement('pro'). A user can have multiple entitlements active at once (paid for Pro and bought the AI add-on as a separate subscription).
Offering
A curated bundle of prices the SDK shows on your paywall. Lets you A/B test pricing or run campaign-specific paywalls without changing extension code.
Offering "default" Offering "blackfriday"
├─ price_monthly_usd ├─ price_monthly_usd_50off
└─ price_annual_usd └─ price_annual_usd_50off
Switch which one is "current" in the dashboard, and every extension that calls CrxPay.openCheckout() with no args immediately gets the new prices.
The signed offline cache
Every other concept above is server-side state. The cache is what makes the SDK feel instant and survive flaky networks.
What it stores
{
"data": {
"status": "active",
"currentPeriodEnd": 1734567890,
"entitlements": [
{ "id": "pro", "expiresAt": 1734567890 }
]
},
"timestamp": 1731975890,
"signature": "<HMAC-SHA256 over data + timestamp + extensionId + apiKey>"
}
Persisted to chrome.storage.local, which survives:
- Service worker termination (~30 seconds of inactivity)
- Browser restart
- Device offline / airplane mode
- Chrome's "lol I'll just kill your worker" decisions during high memory pressure
Read flow
SDK.getSubscription()
│
├── Read cache from chrome.storage.local
├── Recompute HMAC; compare to stored signature
│ └── ❌ Mismatch → discard cache, treat as no-cache
│
├── Cache fresh (<4h)? → return cached data, _source: 'cache'
│
└── Cache stale or absent → fetch from /v1/sdk/subscription
├── Network OK → re-sign, write to cache, return data
└── Network failed → return last-known cached data with stale flag
(or `unknown` if no cache at all)
Why HMAC?
Without a signature, a savvy user could open DevTools, edit chrome.storage.local, change status: 'active', and unlock Pro for free. The HMAC key is derived from chrome.runtime.id + apiKey, both of which the user can read but can't compute over arbitrary data without breaking SHA-256.
If they tamper, the signature recomputed at read time won't match. We discard the cache and force a network refresh — which the server (which they don't control) returns the truth from.
Why a 4-hour stale window?
Long enough that subscription state survives realistic offline periods (transatlantic flights, day-long Wi-Fi outages, you on the train). Short enough that a refund or cancellation propagates within a single session.
You can override it: CrxPay.configure({ refreshInterval: 30 }) (minutes) tightens the window. We don't recommend going below 5 minutes — every refresh is a network round-trip that defeats the offline-first promise.
How identity works
The SDK tracks two IDs:
- Install ID — random UUID generated on first SDK init, persisted in
chrome.storage.local. Every customer has one. Lets us link anonymous usage analytics to a user even before they identify. - Customer ID — the
Customerrow's UUID, set afteridentify()or after a successful payment. Persists across devices because it's keyed on email.
When a user reinstalls, they get a new install ID. As soon as they identify() with the same email or pay with the same email, we link the new install ID to the existing customer record — they don't lose their subscription.
Test mode vs live mode
Two parallel universes. Same dashboard, same SDK code, completely separated data:
| Test mode | Live mode | |
|---|---|---|
| Stripe key | sk_test_… | sk_live_… |
| Stripe customers | cus_test_… (no real billing) | Real customers |
| Webhook secret | whsec_test_… | whsec_live_… |
| Database rows | mode = 'test' | mode = 'live' |
| Topbar toggle | "Test mode" | "Live mode" |
The SDK auto-detects which mode to use:
testMode: true→ always testtestMode: false→ always livetestMode: 'auto'(recommended) → test if extension is unpacked (noupdate_urlin manifest), else live
That means your extension's source code is identical in dev and prod — no env var swaps, no if (DEV) branches.
Next
- SDK reference — every method that exposes these concepts
- Security & trust — deeper dive on the HMAC scheme
- Manifest V3 guide — why we store cache in
chrome.storage.localnot memory
Was this page helpful?
Your feedback shapes what we document next.