Skip to content

Paywalls & free limits

This page controls when a sign-in wall or paywall appears. Pick a preset, list your premium features; the same wall then renders in every surface, and you never build wall UI. Every timing number lives in one file: apps/extension/entrypoints/background/gates.ts.

preset paywall appears use when
value-first (default) premium-feature moment, or after 10 actions / 3 days most products
day-zero first session (dismissible) strong day-0 conversion focus
metered credit balance exhausted (+ premium moments) AI/usage products with credit billing
silent only when you call open("paywall") maximum review safety

The wizard writes your chosen preset into background/gates.ts (--billing-model hybrid-credits/credits-only default it to metered); switching later is a one-line edit:

defineGates({
gates: valueFirst({ premiumFeatures: ["export-pdf"], paywallAfterActions: 10 }),
})

Every preset except silent accepts: premiumFeatures (feature IDs that surface the paywall immediately), paywallAfterActions (default 10), paywallAfterDays (default 3), cooldownMinutes (quiet period after a dismissal, default 24 h), and escalateAfterDismissals (dismissals before the wall turns non-dismissible; default 0 = never, and leave it that way for core features).

Two message types instrument your product; both return null for “proceed” or a decision meaning “the wall is up; stop the action”:

import { sendMessage } from "@/utils/messaging";
// A premium feature moment: call before running the feature:
const decision = await sendMessage("gateFeature", { feature: "export-pdf" });
if (decision) return; // wall is already rendering everywhere; just stop
// Counting generic usage toward the actions threshold:
await sendMessage("gateAction", { name: "highlight" });

Then add the feature ID to the preset’s premiumFeatures in background/gates.ts. That’s the whole integration: the engine handles the wall UI, cooldowns, and the sign-in chain. (Under the hood: the background evaluates once, publishes the decision to storage.local.gateDecision, and useGateDecision() + <GateWall> render it everywhere.)

Metered (credit-billed) features use creditsConsume instead of gateFeature: consume first, then work:

const result = await sendMessage("creditsConsume", { feature: "summarize" });
if (!result.ok) return; // out of credits (or offline) — the top-up wall is up

The metered preset’s creditsExhausted() trigger reads the real balance: the background mirrors creditsRemaining into the gate engine on every change. The wall fires the instant a consume hits 0 and clears when a pack purchase or allowance reset raises it. A paid metered subscriber with an exhausted balance doesn’t count as satisfied for paywall walls; that’s what lets the top-up wall reach them. Enforcement stays server-side; the wall is conversion UX. See the credits model.

Manual control (e.g. with the silent preset) uses sendMessage("gateOpen", { gateId: "paywall" }). The dev tools in the Settings tab (dev builds) reset all local gate state.

Everything is answerable from one file: apps/extension/entrypoints/background/gates.ts. Reading it top to bottom:

  1. PREMIUM_FEATURES = ["premium-demo"]: any gateFeature call with one of these IDs raises the paywall immediately.
  2. The valueFirst({ ... }) call spells out every timing parameter inline: paywallAfterActions: 10, paywallAfterDays: 3, cooldownMinutes: 60 * 24. It expands to exactly two gate definitions:
    • signin: trigger manual(); it can never fire on its own.
    • paywall: trigger any(featureAccess(...), actionCount(10), daysSinceInstall(3)), with requires: "signin" and dismissible: true.
  3. So a wall appeared because (a) a listed premium feature was requested, (b) the 10th gateAction was recorded, or (c) it’s been 3 days since install. If the user was signed out, the sign-in wall renders first, because the paywall requires: "signin".
  4. A wall didn’t appear because the user is paid, the gate is inside its 24-hour post-dismissal cooldown, or the identity check (getIdentity() in the same file) says the requirement is already satisfied. The anonymous-first rule is visible right there: anonymous guests count as signed out.

Every number that controls wall timing is in this file’s valueFirst({ ... }) call; change it there, nowhere else. If a wall surprises you, this file is the complete explanation.

CWS’s single-purpose policy expects the functionality in your listing to work when the user installs. Review accounts are fresh installs; a wall they can’t get past reads as bait-and-switch, a rejection/suspension class. The kit’s guardrails:

  1. Core value stays usable pre-wall. Gate premium extras via premiumFeatures; never put the action from your store listing’s first sentence behind a paywall trigger. If everything in your product is paid, say so in the listing (“subscription required”): that’s allowed; hiding it is not.
  2. Walls are dismissible by default. Every preset ships dismissible: true; escalateAfterDismissals is opt-in and belongs only on non-core features.
  3. No wall at install. No preset triggers a non-dismissible wall on day 0; day-zero is dismissible by design. Don’t hand-write an install-time hard wall.
  4. Sign-in is never forced standalone; chained only.
  5. For a review-sensitive product, use silent: nothing fires automatically; you invoke walls from your own UX.

Walls are conversion UX; the features they gate are enforced by entitlements and credits on the server. Every recorded gate event is also queued and flushed (via a 1-minute alarm) to POST /gate/events, which increments usage/{uid} in Firestore. Anything with stakes (trial-once, credit balances, abuse caps) reads those server counters, never client numbers. Trial-once is enforced from the entitlement doc: once a trial has started, later checkouts omit the trial automatically. Anonymous usage stays local until sign-in.

GET /gateConfig serves flags/values/experiments as remote data: sanctioned, unlike remote code (the rejection class). Wiring those values into gate thresholds is left to you via a config transform before defineGates.