crxpaydocs

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.completed webhook, 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 Customer row's UUID, set after identify() 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 modeLive mode
Stripe keysk_test_…sk_live_…
Stripe customerscus_test_… (no real billing)Real customers
Webhook secretwhsec_test_…whsec_live_…
Database rowsmode = 'test'mode = 'live'
Topbar toggle"Test mode""Live mode"

The SDK auto-detects which mode to use:

  • testMode: true → always test
  • testMode: false → always live
  • testMode: 'auto' (recommended) → test if extension is unpacked (no update_url in 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

Was this page helpful?

Your feedback shapes what we document next.