Skip to content
Get the kit

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.

apps/extension/paywall.config.ts
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.

features is the plan map. Every id you pass to access is listed here with what it costs:

valuemeaningfree planPro planenforced where
"free"core, never gated; each use counts toward the promptunlimitedunlimitednothing to enforce
"pro"Pro only; a click raises the wall (reason pro)lockedunlimitedserver 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 periodunlimitedclient 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 creditsthe free grant, if anyplan allowance plus packsserver 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.

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”, and TrialBanner starts a countdown notice days before the end (default 3). When it ends, the trial-ended wall appears once (“Keep Pro” or “Continue free”), whatever prompt says; 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’s trialUsed says it was taken. The number must match the server’s BILLING_TRIAL_DAYS; the server enforces trial-once.
  • trial: false for 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.

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 send paywallOpen yourself.

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.

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.

recipethe config
freemium (default)the core action { free: 10, per: "ever" }, Pro extras locked, no prompt
free-trialtrial: { days: 14 } on top of freemium
daily-limit{ free: 5, per: "day" } features, prompt: false
credits{ credits: N } features, prompt: false
paid-onlypaidOnly: true, trial: { days: 7, card: true }, prompt: "first-run"
manualprompt: 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.

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 reason

Feature 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);

Four components read the same background-written snapshot, so they never disagree with the engine:

componentshows
<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.

Read paywall.config.ts top to bottom; it is the complete explanation:

  1. A locked click: the feature is "pro" on the free plan, a limit is spent for the period, or the credit balance is short.
  2. The prompt: after crossed and the cooldown lapsed.
  3. A no-card trial just ended: once, then “Continue free” hands over to the prompt rules.
  4. 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.

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.