Gate a whole app behind a one-time or monthly payment on the organization's own Stripe
account — linked with Connect with Stripe (the admin signs in on Stripe; the platform
stores only the account id and drives it with ibl.ai's key plus a Stripe-Account header) or
by a pasted restricted key. The admin answers one question in the app, at
/paywall/setup — free, a one-time fee or a monthly fee, in USD — and connects Stripe there;
the agent never touches Stripe. The ibl.ai platform (DM) owns entitlement end to end: it
mints every Checkout Session, verifies the buyer's session read-after-write, records payments
durably, caches answers (grants 60 s, denies 15 s — a session_id punches through a cached
deny), checks subscriptions live, and keeps recorded payers in during a Stripe outage. The app
holds no platform key for any of it: members pay on their own session token, and the two
admin routes forward the admin's own. No cookies, no webhooks, no local payment ledger. It
is the model of ibl.ai's vibe-agent reference app, gated at the door instead of at the first
message.
How a visit flows, after AuthProvider has signed the visitor in:
- An organization admin goes straight in. While the paywall question is unanswered,
PaywallGatetakes them to/paywall/setup— once per session, from whatever page they opened. - A member goes in without a Stripe call while nothing is for sale (free, or not answered
yet). Otherwise the platform's verdict lets them in, or sends them to
/paywall→ Continue to payment → Stripe's form in a modal → paid → the app.
Common setup (brand, conventions, env files, verification): see docs/skill-setup.md.
This rail vs the Connect family
| This skill (direct rail) | /iblai-vibe-monetization-checkout (Connect rail) |
|
|---|---|---|
| Sells | Entry to the whole app | Individual items (agents, courses…) in-platform |
| Stripe account | Organization's own: a Standard account linked with Connect with Stripe (OAuth), or a pasted restricted key (rk_…) |
Stripe Connect Express, ibl.ai-managed |
| Commission | None | ibl.ai commission on each sale |
| Reconciliation | DM records + live checks, no webhooks | Webhook-reconciled subscriptions |
| Platform flag | None required | enable_monetization |
Selling items inside the app instead? Use the Connect family — start at
/iblai-vibe-monetization.
Prerequisites
- A scaffolded vibe-starter app with working SSO auth, in server mode:
next.config.*must NOT setoutput: 'export'— the two admin routes are route handlers. iblai.envwithPLATFORM+TOKEN, for the probes below and — on an upgrade — reading the old setup.IBLAI_USERNAMEcomes from the environment (the ibl.ai desktop app exports it) or fromiblai.env; if neither has it, ask the user once and persist it (same Step 1 as/iblai-vibe-ops-deploy).- Members' own checkout needs the Students role's
Ibl.Mentor/StripePaywallSelf/action: organizations seeded before it existed need an ibl.ai operator to runseed_rbac_data, or every member meets the platform's 403 at the gate. - The agent never asks for or accepts a Stripe key in chat, and never creates Stripe products or prices: the admin's setup page does it.
Step 1: Install (once per app)
Read iblai.env with the val() reader (do not source it) and resolve IBLAI_USERNAME
(env → iblai.env → ask once; both exactly as in /iblai-vibe-ops-deploy Step 1), then:
DM="https://api.$DOMAIN/dm"; AUTH="Authorization: Api-Token $TOKEN"
PAY="$DM/api/ai-mentor/orgs/$PLATFORM/users/$IBLAI_USERNAME/providers/stripe/payments"
CONNECT="$DM/api/ai-mentor/orgs/$PLATFORM/users/$IBLAI_USERNAME/providers/stripe/connect/"
META="$DM/api/core/orgs/$PLATFORM/metadata/"
-
Probe the backend.
GET $PAY/paywall/access/?app=probe→ a 404 means the platform predates the paywall endpoints (ibl-dm-pro PR #2977);GET $CONNECT→ a 404 means it predates Connect with Stripe and the member rail (ibl-dm-pro 4.377.0). Either way stop and tell the user — nothing app-side can work around it.available: falseon$CONNECTmeans a new connect cannot start yet (an operator issue); a pasted key still works. -
Upgrading from the previous release (the app has
app/api/paywall/checkout/, orPAYWALL_PRICE_IDSin.env.local)? Do Upgrading first. -
Copy the files — ready-made, typecheck- and unit-test-gated copies ship as ops-init assets (same resolution as vibe-starter itself):
cp -a <skills-dir>/iblai-vibe-ops-init/assets/stripe-components/. .If the staged skills carry no
assets/(some installers strip them), fall back to the drop-in bodies inreferences/app-files.md— identical content. -
Pin the slug — append to
.env.local:NEXT_PUBLIC_PAYWALL_APP_SLUG=my-app # the lowercased package name: letters, digits, - or _, ≤ 64It keys the choice under
apps.<slug>in the organization's metadata, tags the Stripe product (metadata.app), and names the app on every checkout — and the platform records payments under it. Never change it afterwards: a new value strands everyone who paid. It has no default on purpose: two apps on one organization must never share one. -
Make the three edits (
references/app-files.mdshows each in full):-
app/(app)/layout.tsx— wrap{children}in<PaywallGate>(from@/components/paywall-gate). -
middleware.ts(orproxy.ts) — let Stripe's embedded checkout load. The SDK's default policy allowsjs.stripe.combut notcheckout.stripe.com, so without this the form never renders on a production build:return applyCsp(request, { requestHeaders, mode: process.env.NODE_ENV === 'development' ? 'report-only' : undefined, // Stripe's embedded checkout (the paywall's pay modal). scriptSrc: ['https://checkout.stripe.com'], connectSrc: ['https://checkout.stripe.com'], frameSrc: ['https://checkout.stripe.com'], imgSrc: ['https://*.stripe.com'], }); -
app/(app)/account/page.tsx— the quiet Payments setup link for admins, the way back to the question: nothing new in the navbar.
-
-
pnpm typecheck && pnpm test— the copy brings__tests__/paywall*.test.ts.
| File | Role | ~Lines |
|---|---|---|
lib/paywall-client.ts |
Browser rail on the member's own token: the catalogue from public metadata, embedded checkout, access check, the setup check, the admin's texts | 361 |
lib/paywall.ts |
Server helpers for the admin routes: identity from the admin's own token, the Stripe proxy and Connect calls on their own path, the metadata record | 217 |
app/api/paywall/admin/setup/route.ts |
The whole setup in one call: source check → retire the old price → product (tagged) → price → record; free records only | 156 |
app/api/paywall/admin/connect/route.ts |
Connect with Stripe relay: GET / POST / DELETE, statuses verbatim | 44 |
components/paywall-gate.tsx |
The gate around (app): admins in (and to setup while unanswered), members by the platform's verdict |
71 |
components/paywall-setup.tsx |
The setup screen: the question, whose paid answer's button is Connect with Stripe; reconnect, disconnect | 390 |
components/pay-modal.tsx |
Stripe's embedded checkout in a modal, then the platform's confirmation | 169 |
app/paywall/page.tsx |
Where unpaid members land: the price, Continue to payment, Restore access | 105 |
app/paywall/setup/page.tsx |
The setup page, outside (app); Stripe returns to it |
12 |
Trust rules (non-negotiable):
- Identity comes ONLY from the platform: the admin routes verify the caller's own token with
core/token/verify/— never a client-sent username — and the platform enforces the admin role on every call they forward. - No platform key and no Stripe secret in the app: never add
IBLAI_API_KEYor ask_/rk_key to the paywall. Only ids, amounts and the publishable key go into the organization's metadata — a public read. - Surface the platform's 4xx bodies verbatim — they are actionable.
Upgrading from the previous release
The previous paywall sold through two Api-Token server routes, with the agent creating the
Stripe objects and a PRICES constant on the page. In order:
-
Read the old setup:
PAYWALL_APP_SLUGfrom.env.local,GET $META(apps.<slug>.stripe) andGET $CONNECT(source,publishable_key). -
Stop if
sourceis"key"andpublishable_keyis empty — apps that ran withPAYWALL_EMBEDDED=0. Checkout is embedded only now, so every buyer would be refused: have the admin add the account's publishable key (pk_…, a public value) to the organization'sstripecredential on ibl.ai first. -
Remove what the new files replace —
cp -akeeps old files, and these no longer compile:rm -rf app/api/paywall/checkout app/api/paywall/access app/paywall/return \ app/paywall/paywall-actions.tsx components/paywall-connect.tsx \ components/paywall-embedded.tsx lib/paywall-connect.ts __tests__/paywall-connect.test.ts -
Copy (Step 1, item 3), then set
NEXT_PUBLIC_PAYWALL_APP_SLUGto the oldPAYWALL_APP_SLUG— members who paid are recorded under it — and delete thePAYWALL_PRICE_IDS,PAYWALL_APP_SLUGandPAYWALL_EMBEDDEDlines.IBLAI_API_KEYstays: other starter routes use it. -
The edits (Step 1, item 5); the layout one is already there.
-
An app that sold several prices recorded none, and the admin's first save records one that binds every checkout, the old deployment's included — so have the admin answer
/paywall/setupright before deploying the upgrade.
Existing payers keep their access as long as the slug is unchanged: the platform grants per
member and app, not per price. A record the old skill wrote (stripe with no access) keeps
selling its price until the admin answers, and the gate sends them to answer.
Step 2: Hand over the setup page
Send the admin to <app URL>/paywall/setup — locally http://localhost:3000/paywall/setup,
on the port pnpm dev printed — not to the app's root; when you open the local preview, open
that page. (The root works too: the gate sends an admin whose question is unanswered there.
Name the page anyway.) There they:
- choose Free access, a One-time fee or a Monthly fee, with the USD price;
- for a paid answer while the organization has no Stripe source, the button reads Connect
with Stripe — sign in on Stripe (or create an account there), consent, and they are back on
/paywall/setup, where the answer saves itself.
Nothing is typed or copied, and nothing about Stripe goes through you. Say once that the
quiet Payments setup link on /account reopens it: a later "change the price" or "make it
free" is that page again, not you. The page refuses a paid plan while the Stripe source has
no publishable key, naming the fix — and on a connected account says when Stripe has not
enabled payments yet. Its reference — every call, the error reasons, and curl equivalents for
scripted setup or debugging — is references/setup-api.md.
Step 3: Deploy
Server mode is required (no output: 'export'). /iblai-vibe-ops-deploy regenerates
.env.production from .env.local and copies every NEXT_PUBLIC_* key; before uploading,
confirm grep NEXT_PUBLIC_PAYWALL_APP_SLUG .env.production. The CSP edit (Step 1, item 5) is
what lets Stripe's form render once deployed: pnpm dev only reports policy violations, a
production build enforces them. Connect with Stripe's return_url is validated against the
organization's own deployed apps (*.vercel.app hosts), its custom domains, and localhost — a
400 naming the host at Connect with Stripe means the app isn't deployed under this
organization yet: deploy first, or attach the domain.
Step 4: Verify
- As an admin with the question unanswered, opening
/lands on/paywall/setup - Monthly $29 with no Stripe source → Connect with Stripe → Stripe → back on
/paywall/setup→ saved → the app -
GET $METAshowsapps.<slug>withaccess,amount,stripe.price_idandstripe.publishable_key - With a member account (admins never pay):
/→/paywallwith the price → Continue to payment → Stripe's form in the modal → test card4242 4242 4242 4242→ the app - The same on a production build (
pnpm build && pnpm start) or the deployed host — the only place the CSP is enforced - A second unpaid member is held on
/paywall; Restore access reports no payment found -
GET $PAY/paywall/payments/?app=<slug>lists the test payment - Free: members go straight in, and no Stripe call is made
- Subscription only: cancel in the Stripe dashboard → access lapses within ~75 s (platform cache) plus up to 60 s held in the page
Deliberately not built
- Webhooks — the platform records at the buyer's return and re-checks live; there is nothing to receive.
- Several prices per app — one question, one recorded price, and members can buy only that.
- Hosted (redirect) checkout — the modal is the only checkout; the setup refuses a paid plan on a source with no publishable key, so a buy cannot fail on it.
- Self-serve cancel UI — the admin cancels in their Stripe dashboard; access lapses within the cache window.
- Refund auto-revoke — a recorded one-time payment is permanent entitlement by design.
- Guest checkout — the app is login-first; anonymous buying belongs to the Connect rail.
- Opening self-join and putting the price on the sign-in page —
vibe-agentdoes both; they are organization-wide settings, and a starter app's organization may serve other apps.
Common mistakes
- Putting
/paywallor/paywall/setupinside(app)— the gate would loop, or bounce the admin who came to set it up. Both live outside it, inside the root providers. - Adding
^/paywalltoPUBLIC_ROUTES— checkout needs a signed-in member; login-first is the design. - Skipping the CSP edit because it works under
pnpm dev— dev only reports; once deployed, nobody can pay. - Changing
NEXT_PUBLIC_PAYWALL_APP_SLUGafter a sale, or giving two apps on one organization the same one — payments are recorded under the slug. - Testing the purchase as an admin — admins go straight in; use a member account.
- Disconnecting Stripe while a paid plan is recorded — the platform checks every member's access on the Stripe source, so members who paid are locked out too until an account is linked again. To stop charging, choose Free access (the page asks before unlinking).
- A member's 403 at the gate — the organization predates the member rail's permission
(
seed_rbac_data), or the browser'sapp_tenantnames another organization thanNEXT_PUBLIC_MAIN_TENANT_KEY: the platform refuses a token for one organization on another's path. ?stripe_connect=error&reason=invalid_grant— Stripe refused the one-time code at the platform's exchange. Nothing the app sends reaches that exchange: a code spent twice, or — when it repeats — the ibl.ai instance's Connect credentials mixing test and live mode, which ibl.ai support fixes. A pasted restricted key plus its publishable key is the way round.- Creating products and prices with curls, or collecting a Stripe key — the setup page does it;
the curls in
references/setup-api.mdare for CI and debugging only. - Expecting instant lockout after a subscription cancel — the window is the platform's cache (≤ ~75 s) plus up to 60 s held in the page.
Related skills
/iblai-vibe-monetization— family index; item-level Connect rail overview/iblai-vibe-monetization-checkout— sell individual items in-platform (Connect)/iblai-vibe-monetization-onboard— Stripe Connect Express onboarding (item rail only; not the Connect with Stripe link above)/iblai-vibe-ops-deploy— ships the app +.env.productionvia ibl.ai hosting/iblai-vibe-ops-test— validate before showing work/iblai-vibe-auth— SSO token wiring the gate depends on- BRAND.md — visual language for the pricing page