Paywall reference
Choosing a recipe? Start with Choose your paywall, which answers the three questions with a popup that reacts as you pick. This page is the reference behind it.
Every paid product answers three questions. The kit asks them in one
file, apps/extension/paywall.config.ts, and everything else follows:
the same wall renders in every surface, locked controls carry a badge,
limits show a meter, and a trial counts itself down. You never build
wall UI.
export const paywallConfig = { // 1. What is free and what is Pro features: { highlight: "free", // core: never gated, counts toward the prompt summarize: { free: 5, per: "day" }, // 5 a day free, unlimited on Pro "export-pdf": "pro", // Pro only "ai-image": { credits: 5 }, // server-metered credits, any plan }, // 2. Is there a trial? trial: { days: 14 }, // full Pro from install, no card; false = none // 3. When do we ask? prompt: { after: { actions: 10, days: 3 }, cooldown: "1d" },} satisfies PaywallConfig;The wizard writes one of the recipes below; switching later is editing a line.
1. What is free and what is Pro
Section titled “1. What is free and what is Pro”features is the plan map. Every id you pass to access is listed
here with what it costs:
| value | meaning | free plan | Pro plan | enforced where |
|---|---|---|---|---|
"free" | core, never gated; each use counts toward the prompt | unlimited | unlimited | nothing to enforce |
"pro" | Pro only; a click raises the wall (reason pro) | locked | unlimited | server entitlement, webhook-written |
{ free: N, per } | N free uses per "day", "week", "month" or "ever", on the user’s local calendar (a daily allowance resets at their midnight); at zero the wall shows the reset time (reason limit) | N per period | unlimited | client counter for the UX, mirrored to usage/{uid} |
{ credits: N } | consumes N credits through /credits/consume before the work runs; at zero the wall shows reason credits | the free grant, if any | plan allowance plus packs | server transaction (credits guide) |
Two options apply to the object forms: signin: true for features that
need an account (sync, cloud), which raises the sign-in wall first, and
dismissible: false, allowed only on Pro features and never on core
ones.
A per-period limit is an honest-user limit: the count lives in the extension and mirrors to the server. When a limit protects something with real cost, use credits, which the server enforces.
2. Is there a trial?
Section titled “2. Is there a trial?”One concept, two mechanics:
trial: { days: 14 }gives every install full Pro for 14 days with no card. The account tab and the on-page status card say “Pro trial”, andTrialBannerstarts a countdownnoticedays before the end (default 3). When it ends, the trial-ended wall appears once (“Keep Pro” or “Continue free”), whateverpromptsays; features fall back to their free value and nothing the user created is locked. Fourteen days is the industry mode and converts better than seven.trial: { days: 7, card: true }starts the trial at checkout instead. Features stay locked until then, and every wall offers the trial (“Start your 7-day free trial”) until the server’strialUsedsays it was taken. The number must match the server’sBILLING_TRIAL_DAYS; the server enforces trial-once.trial: falsefor no trial.
A reinstall restarts an anonymous no-card trial; the client cannot know better. Sign-in makes trial-once per account on the server.
3. When do we ask?
Section titled “3. When do we ask?”A click on a locked feature, or on a spent allowance, always raises its
wall. That is intent, and it is never capped. prompt is the extra ask,
the nudge after the product has shown its value, and the default recipe
does without it because the allowance is the ask:
{ after: { actions: 10, days: 3 }, cooldown: "1d" }: the upgrade prompt appears after 10 free uses or 3 days since install, whichever first, and stays quiet for a day after “Maybe later”."first-run": the offer is visible from the first session, dismissible, and returns after the cooldown.false: no nudge. Walls only from locked clicks, or when you sendpaywallOpenyourself.
The prompt is always dismissible. The evidence behind the default: walls shown after the first value moment start 65% vs 31% of trials (Adapty, 2026), and most paid subscriptions start on day 0 (RevenueCat). The default prompt is visible in the first session, after the product has done something.
The recipes
Section titled “The recipes”Each recipe is the same object with sections filled or removed. The
wizard’s “Paywall recipe” question writes one; --recipe <name>
does the same headlessly, and /change-paywall-recipe <name> in Claude
Code does it with the policy checklist in front of the agent.
| recipe | the config |
|---|---|
freemium (default) | the core action { free: 10, per: "ever" }, Pro extras locked, no prompt |
free-trial | trial: { days: 14 } on top of freemium |
daily-limit | { free: 5, per: "day" } features, prompt: false |
credits | { credits: N } features, prompt: false |
paid-only | paidOnly: true, trial: { days: 7, card: true }, prompt: "first-run" |
manual | prompt: false |
Mixing is the normal case. The AI tool that wants 14 days of full Pro, then 5 summaries a day, image generation on credits, and a nudge after value is all three sections filled in, and nothing new to learn.
Gate a feature
Section titled “Gate a feature”One message does everything. It checks the feature, counts the use on the free plan, consumes credits for credit features, and publishes the wall to every surface when access is denied:
import { sendMessage } from "@/utils/messaging";
const { allowed } = await sendMessage("access", { feature: "export-pdf" });if (!allowed) return; // the wall is already rendering, with copy for the reasonFeature ids are a typed union from paywall.config.ts, so a typo fails
to compile. The verdict also carries remaining and resetsAt for
limited features, and reason (pro, limit, credits, signin,
offline) when denied.
For free features, fire and forget: the use counts toward the prompt and the feature never waits on the engine.
sendMessage("access", { feature: "highlight" }).catch(console.error);The UI pieces
Section titled “The UI pieces”Four components read the same background-written snapshot, so they never disagree with the engine:
| component | shows |
|---|---|
<PaywallOverlay /> | the modal, mounted once per surface; copy keyed by the wall’s reason (pro, limit, credits, signin, prompt, a trial that ended, a card trial on offer), overridable through the copy prop. Its primary action opens the plan picker inside the same overlay, then checkout |
<LockBadge feature="export-pdf" /> | a “Pro” pill that renders only while the feature is locked for this user |
<UsageMeter feature="summarize" /> | “3 of 5 left today”; without a feature, the credit balance |
<TrialBanner onAction={…} /> | the countdown from notice days before a no-card trial ends |
Two hooks for your own UI: useFeature("summarize") returns
{ locked, allowed, remaining, limit, resetsAt, reason }, and
usePlan() returns { plan, source, trialEndsAt, daysLeft }. A user on
a trial is on Pro everywhere.
Why did this wall appear?
Section titled “Why did this wall appear?”Read paywall.config.ts top to bottom; it is the complete explanation:
- A locked click: the feature is
"pro"on the free plan, a limit is spent for the period, or the credit balance is short. - The prompt:
aftercrossed and the cooldown lapsed. - A no-card trial just ended: once, then “Continue free” hands over to the prompt rules.
- A wall did not appear because the user is Pro (paid or on a trial), the prompt is inside its cooldown, or the id is not listed (unregistered ids run free, so a typo never locks a feature).
Signed-out users see the sign-in wall in front of any paywall, because
checkout needs an account. Anonymous guests count as signed out.
Dismissing that sign-in wall silences the prompt it stood in front of.
Whether the popup opens on the sign-in form or as a guest is
auth.signIn in site.config.ts; see when sign-in is
asked.
The Settings tab (dev builds) has Open paywall and Reset paywall state to replay timing.
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 access flushes (via a
1-minute alarm) to POST /gate/events, which increments
usage/{uid}. Anything with stakes (trial-once, credit balances, abuse
caps) reads those server counters, never client numbers.