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.
Pick a preset
Section titled “Pick a preset”| 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).
Gate a feature
Section titled “Gate a feature”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 upThe 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.
“Why did this wall appear?”
Section titled ““Why did this wall appear?””Everything is answerable from one file: apps/extension/entrypoints/background/gates.ts. Reading it top to bottom:
PREMIUM_FEATURES = ["premium-demo"]: anygateFeaturecall with one of these IDs raises the paywall immediately.- 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)), withrequires: "signin"anddismissible: true.
- signin: trigger
- So a wall appeared because (a) a listed premium feature was requested, (b) the 10th
gateActionwas recorded, or (c) it’s been 3 days since install. If the user was signed out, the sign-in wall renders first, because the paywallrequires: "signin". - 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.
Stay inside Chrome Web Store policy
Section titled “Stay inside Chrome Web Store policy”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:
- 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. - Walls are dismissible by default. Every preset ships
dismissible: true;escalateAfterDismissalsis opt-in and belongs only on non-core features. - No wall at install. No preset triggers a non-dismissible wall on day 0;
day-zerois dismissible by design. Don’t hand-write an install-time hard wall. - Sign-in is never forced standalone; chained only.
- For a review-sensitive product, use
silent: nothing fires automatically; you invoke walls from your own UX.
The server-side mirror
Section titled “The server-side mirror”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.
Remote config
Section titled “Remote config”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.
