/iblai-vibe-monetization-app-paywall
Gate a whole app behind a one-time or subscription payment on the organization's
own Stripe account — linked by a pasted restricted key, or by Connect with
Stripe (the admin signs in on Stripe; the DM stores only the account id and
drives it with ibl.ai's key plus a Stripe-Account header). The ibl.ai
platform (DM) owns entitlement end to end:
it mints the Stripe Checkout session, verifies the buyer's return
read-after-write, records payments durably, caches answers (grants 60s,
denies 15s — a session_id punches through a cached deny), checks
subscriptions live so cancellation bites within the cache window, and keeps
recorded payers in during a Stripe outage (stale: true) while failing
closed for unknown users. The app stays thin: two server routes, one client
gate, one pricing page — or no server routes at all on the member self-service
rail (below), where the browser pays on the member's own token. No cookies,
no webhooks, no local payment ledger.
How a visit flows: anonymous visitor → AuthProvider → hosted Auth SPA →
back logged in → PaywallGate (inside the (app) layout) asks
GET /api/paywall/access → denied → /paywall pricing page → buy → Stripe
Checkout → /paywall/return?session_id=… → access confirmed → 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 account via the DM Stripe proxy: a pasted restricted key (rk_…), or a Standard account linked with Connect with Stripe (OAuth) |
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
iblai.env with PLATFORM + TOKEN (platform API key). IBLAI_USERNAME
comes from the environment (the ibl.ai desktop app exports it) or from
iblai.env; if neither has it, ask the user once and persist it (same
Step 1 as /iblai-vibe-ops-deploy).
- A scaffolded vibe-starter app with working SSO auth.
- The organization has a Stripe source: an integration credential named
stripe holding a restricted key, or an account linked with Connect with
Stripe (Step 1 can drive that: the admin signs in on Stripe in their
browser; nothing is typed or copied). A pasted key wins when both exist.
The agent never asks for or accepts a Stripe key in chat.
- Server mode:
next.config.* must NOT set output: 'export' — the
paywall needs API routes.
- Backend version: probe
GET $PAY/paywall/access/?app=probe (see Step 1 shorthand). A 404 means
the platform backend predates the paywall endpoints (needs ibl-dm-pro ≥
PR #2977) — stop and tell the user; nothing app-side can work around it.
GET $CONNECT 404 → the backend predates Connect with Stripe and the
member rail (ibl-dm-pro ≥ 4.377.0); available: false there → its
migration is not applied yet. A pasted key works on either.
Step 1: Admin setup (once per app)
Full curls, error table, and verify list: references/setup-api.md.
Condensed sequence — read iblai.env with the val() reader (do not
source it), resolve IBLAI_USERNAME (env → iblai.env → ask once; both
exactly as in /iblai-vibe-ops-deploy Step 1), then with
PAY="https://api.$DOMAIN/dm/api/ai-mentor/orgs/$PLATFORM/users/$IBLAI_USERNAME/providers/stripe/payments",
CONNECT="https://api.$DOMAIN/dm/api/ai-mentor/orgs/$PLATFORM/users/$IBLAI_USERNAME/providers/stripe/connect/"
and AUTH="Authorization: Api-Token $TOKEN":
Probe the organization's Stripe source: GET $CONNECT →
{source, connected, available, publishable_key, stripe_account, …}.
source: "key" or "connected" → continue (key wins when both exist).
source: null → nothing yet. Offer Connect with Stripe (confirm
with the user first — it opens Stripe): POST $CONNECT
{"return_url":"<the app's URL, or http://localhost:3000/setup>"} →
open authorize_url in the browser; the admin signs in to Stripe (or
creates an account there) and clicks Connect; Stripe returns through the
platform to return_url?stripe_connect=connected (or
…=error&reason=<code>); re-run GET $CONNECT until connected
(plain — not ?refresh=1 in a loop, that read holds a worker on a
Stripe call; use it once afterwards if the snapshot matters).
Reconnect (another Stripe account, or the same one after the admin
revoked the app on Stripe): DELETE $CONNECT (confirm first; a 502
means Stripe could not confirm it and the link is kept — retry), then
the same POST round trip, then re-record the price (item 5) on the
new account. The other way stays: a platform admin adds an integration
credential named stripe in the platform credentials UI, holding a
restricted key: Stripe Dashboard → Developers → API keys → Create
restricted key — write on Products, Prices, Checkout Sessions,
Customers; read on Subscriptions; everything else None. In the platform
UI, not in this chat — never ask for or accept the key here.
502 from any proxy call → Stripe rejected the source (wrong-mode key,
or the account was disconnected on Stripe's side).
404 → old backend (then GET $PAY/products/?limit=1 still tells a
pasted-key setup apart: 200 ready, 400 no credential) or
$IBLAI_USERNAME not a member.
Create the product, tagged for this app:
POST $PAY/products/ {"name":"<App> access","metadata":{"app":"<slug>"}}.
The metadata.app tag is what the DM enforces at checkout — it must
equal PAYWALL_APP_SLUG exactly. Use the deploy project slug
(lowercased package name) as the value.
Create price(s): POST $PAY/prices/
{"product":"prod_…","unit_amount":2900,"currency":"usd"} — add
"recurring":{"interval":"month"} for a subscription. Checkout mode
follows the price type automatically.
Capture display data: GET $PAY/prices/<id>/ → amount/currency/
interval → fill the PRICES constant in app/paywall/page.tsx.
Record the price on the platform (one price per app):
PUT https://api.$DOMAIN/dm/api/core/orgs/$PLATFORM/metadata/
{"metadata":{"apps":{"<slug>":{"stripe":{"product_id":"prod_…","price_id":"price_…","publishable_key":"<from GET $CONNECT>","stripe_account":"<acct_… or null>"}}}}}
— a deep merge, other keys survive; it is a public read, never put a
secret there. The recorded price is the contract: a caller buying on
their own path (a member on the self-service rail, or the platform
key's owner testing their own app) must have one and can buy only it,
and once recorded it binds every checkout. An app selling several
prices through the server rail leaves this out — then test the paywall
with a member account, not as the key's owner.
Write env — append to .env.local:
PAYWALL_PRICE_IDS=price_xxx,price_yyy # server-only allowlist, comma-separated
PAYWALL_APP_SLUG=my-app # must equal the product's metadata.app
Step 2: Install the app files
Ready-made, typecheck- and unit-test-gated copies ship as ops-init assets —
install them with one copy (from wherever the skills are staged; 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 complete drop-in bodies in
references/app-files.md — identical content.
Either way, only PRICES in app/paywall/page.tsx and the two env lines are
per-app; the copy also brings __tests__/paywall*.test.ts, which run under
the app's existing pnpm test.
| File |
Role |
~Lines |
lib/paywall.ts |
Server-only helpers: resolveUser (identity from the forwarded dm_token), userFromRequest, dmPaywallFetch (Api-Token calls to the DM) |
75 |
app/api/paywall/access/route.ts |
GET → resolve user → forward optional session_id → DM's answer verbatim |
22 |
app/api/paywall/checkout/route.ts |
POST → resolve user → allowlisted price_id → DM mints the Checkout session |
32 |
components/paywall-gate.tsx |
Client gate + shared checkPaywallAccess(); denied → /paywall |
55 |
app/paywall/page.tsx + app/paywall/paywall-actions.tsx |
Pricing page (outside (app), login-first via the existing providers) + buy/auto-verify/restore actions |
105 |
app/paywall/return/page.tsx |
Confirms the purchase by session_id, then into the app |
38 |
app/(app)/layout.tsx |
3-line edit: wrap {children} in <PaywallGate> |
— |
Trust rules (non-negotiable):
- User identity comes ONLY from
resolveUser on the server — never accept a
client-sent username.
price_id must pass the PAYWALL_PRICE_IDS allowlist.
IBLAI_API_KEY and PAYWALL_* are server-only — never NEXT_PUBLIC_*,
never imported into client components.
- Surface DM 400 bodies verbatim — they are actionable (missing credential,
wrong app tag, disallowed redirect host).
The member self-service rail (no platform key in the app)
paywall/checkout/ and paywall/access/ also serve the path user
themselves: the browser calls them on the member's own username path with
the member's own DM token (Authorization: Token <dm_token>, the SDK's
dm_token), so the app holds no platform key at all — this is how ibl.ai's
vibe-agent reference app pays in its modal. RBAC:
Ibl.Mentor/StripePaywallSelf/action, a Students-role verb (organizations seeded
before it need seed_rbac_data); RBAC off: any signed-in member on their own
path. Other users' paths and the payments ledger keep the admin verbs.
POST {dm_url}/api/ai-mentor/orgs/<org>/users/<me>/providers/stripe/payments/paywall/checkout/
Authorization: Token <the member's dm_token>
{"app": "<slug>", "price_id": "price_…", "ui_mode": "embedded", "payment_method_types": ["card"]}
→ {"client_secret": "cs_…", "session_id": "cs_…", "publishable_key": "pk_…", "stripe_account": "acct_…" | null}
Render it with Stripe.js: loadStripe(publishable_key, stripe_account ? { stripeAccount: stripe_account } : undefined), then
createEmbeddedCheckoutPage({ fetchClientSecret: async () => client_secret, onComplete }); Stripe never redirects (redirect_on_completion: never). In
onComplete, poll
GET …/users/<me>/providers/stripe/payments/paywall/access/?app=<slug>&session_id=<session_id>
with the same token until has_access is true (a session_id punches
through a cached deny). Rules: the recorded price (Step 1, item 5) is required —
400 no price recorded without it — and only it can be bought; ui_mode
omitted is hosted checkout on this rail too (success_url/cancel_url,
checkout_url). The admin's own DM token works the same way on the admin's
own path for Step 1 (GET/POST $CONNECT, products, prices, the metadata
PUT), so a setup screen inside the app needs no platform key either.
Step 3: Deploy
Server mode is required (no output: 'export'). /iblai-vibe-ops-deploy
regenerates .env.production from .env.local on every deploy and its copy
list includes PAYWALL_*; before uploading, confirm
grep PAYWALL_ .env.production shows both lines. The DM validates
success_url/cancel_url against the organization's own deployed apps
(*.vercel.app hosts), its custom domains, and localhost — a checkout 400
naming the host means the app isn't deployed under this organization yet:
deploy first, or attach the domain.
Step 4: Verify
Deliberately not built
- Webhooks — the DM records at the buyer's return and re-checks live;
there is nothing to receive.
- 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 DM design (Stripe never flips a completed session).
- Guest checkout — the app is login-first;
/paywall sits behind
AuthProvider, so every buyer has an account. Anonymous buying belongs to
the Connect rail.
Common mistakes
- Putting
/paywall inside (app) — the gate would loop. It must live
OUTSIDE the gated group but inside the root providers.
- Adding
^/paywall to PUBLIC_ROUTES — checkout needs a logged-in
platform member; login-first is the design.
- Calling the DM paywall endpoints from the browser with the Api-Token —
it is org-wide authority; only the app's server routes hold it. The browser
rail is the member's own
Token on the member's own path (above).
- Testing the paywall as the platform key's owner with no recorded price —
a caller on their own path must buy the recorded price (400
no price recorded): record it (Step 1, item 5) or test with a member account.
- Asking for a Stripe key when
GET $CONNECT says source: null — offer
Connect with Stripe first; the key path is the admin's, in the platform UI.
- Mixing auth schemes: DM paywall/proxy calls take
Api-Token <platform key>; core/token/verify/ identity resolution
takes the end user's Token <dm_token>.
- Forgetting
PAYWALL_* in .env.production — works locally, then every
deployed user gets 500s from the paywall routes.
- Hardcoding a URL into
success_url instead of using the request origin —
breaks the moment the app moves hosts.
- Treating the
sessionStorage grant cache as security — it only prevents a
loading flash; the DM is the authority.
- Expecting instant lockout after a subscription cancel — the window is the
DM cache (≤ ~75s) plus up to 60s of client grant cache.
Related skills
1---2name: iblai-vibe-monetization-app-paywall3description: Put a Stripe "pay to enter" gate on a whole app on the organization's OWN Stripe account via the DM Stripe proxy paywall endpoints — a pasted restricted key or Connect with Stripe (OAuth); no Stripe Connect Express, no commission, no webhooks. Admin setup (probe the Stripe source — never collect a key in chat — connect it, create the app-tagged product + prices, record the price), two server routes + lib/paywall.ts, the client PaywallGate, the /paywall pages, and the member self-service rail (embedded Checkout on the member's own token, no platform key in the app). Use when the user mentions charging for the whole app, pay to enter, app paywall, subscribe to use the app, gating the app behind payment, Connect with Stripe, or selling access with their own Stripe account. See /iblai-vibe-monetization for the item-level Connect family, /iblai-vibe-monetization-checkout for selling items in-platform, /iblai-vibe-ops-deploy for the server env, /iblai-vibe-auth for token wiring.4---56# /iblai-vibe-monetization-app-paywall78Gate a whole app behind a one-time or subscription payment on the **organization's9own Stripe account** — linked by a pasted restricted key, or by **Connect with10Stripe** (the admin signs in on Stripe; the DM stores only the account id and11drives it with ibl.ai's key plus a `Stripe-Account` header). The ibl.ai12platform (DM) owns entitlement end to end:13it mints the Stripe Checkout session, verifies the buyer's return14read-after-write, records payments durably, caches answers (grants 60s,15denies 15s — a `session_id` punches through a cached deny), checks16subscriptions live so cancellation bites within the cache window, and keeps17recorded payers in during a Stripe outage (`stale: true`) while failing18closed for unknown users. The app stays thin: two server routes, one client19gate, one pricing page — or no server routes at all on the member self-service20rail (below), where the browser pays on the member's own token. **No cookies,21no webhooks, no local payment ledger.**2223How a visit flows: anonymous visitor → `AuthProvider` → hosted Auth SPA →24back logged in → `PaywallGate` (inside the `(app)` layout) asks25`GET /api/paywall/access` → denied → `/paywall` pricing page → buy → Stripe26Checkout → `/paywall/return?session_id=…` → access confirmed → app.2728> **Common setup (brand, conventions, env files, verification):** see [docs/skill-setup.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/docs/skill-setup.md).2930## This rail vs the Connect family3132| | This skill (direct rail) | `/iblai-vibe-monetization-checkout` (Connect rail) |33|---|---|---|34| Sells | Entry to the **whole app** | Individual items (agents, courses…) in-platform |35| Stripe account | Organization's **own** account via the DM Stripe proxy: a pasted restricted key (`rk_…`), or a Standard account linked with **Connect with Stripe** (OAuth) | Stripe Connect Express, ibl.ai-managed |36| Commission | None | ibl.ai commission on each sale |37| Reconciliation | DM records + live checks, no webhooks | Webhook-reconciled subscriptions |38| Platform flag | None required | `enable_monetization` |3940Selling items *inside* the app instead? Use the Connect family — start at41`/iblai-vibe-monetization`.4243## Prerequisites4445- `iblai.env` with `PLATFORM` + `TOKEN` (platform API key). `IBLAI_USERNAME`46 comes from the environment (the ibl.ai desktop app exports it) or from47 `iblai.env`; if neither has it, ask the user once and persist it (same48 Step 1 as `/iblai-vibe-ops-deploy`).49- A scaffolded vibe-starter app with working SSO auth.50- The organization has a **Stripe source**: an integration credential named51 `stripe` holding a restricted key, or an account linked with **Connect with52 Stripe** (Step 1 can drive that: the admin signs in on Stripe in their53 browser; nothing is typed or copied). A pasted key wins when both exist.54 The agent **never asks for or accepts a Stripe key in chat**.55- **Server mode**: `next.config.*` must NOT set `output: 'export'` — the56 paywall needs API routes.57- **Backend version**: probe58 `GET $PAY/paywall/access/?app=probe` (see Step 1 shorthand). A **404 means59 the platform backend predates the paywall endpoints** (needs ibl-dm-pro ≥60 PR #2977) — stop and tell the user; nothing app-side can work around it.61 `GET $CONNECT` 404 → the backend predates Connect with Stripe and the62 member rail (ibl-dm-pro ≥ 4.377.0); `available: false` there → its63 migration is not applied yet. A pasted key works on either.6465## Step 1: Admin setup (once per app)6667Full curls, error table, and verify list: [`references/setup-api.md`](references/setup-api.md).68Condensed sequence — read `iblai.env` with the `val()` reader (do not69`source` it), resolve `IBLAI_USERNAME` (env → `iblai.env` → ask once; both70exactly as in `/iblai-vibe-ops-deploy` Step 1), then with71`PAY="https://api.$DOMAIN/dm/api/ai-mentor/orgs/$PLATFORM/users/$IBLAI_USERNAME/providers/stripe/payments"`,72`CONNECT="https://api.$DOMAIN/dm/api/ai-mentor/orgs/$PLATFORM/users/$IBLAI_USERNAME/providers/stripe/connect/"`73and `AUTH="Authorization: Api-Token $TOKEN"`:74751. **Probe the organization's Stripe source**: `GET $CONNECT` →76 `{source, connected, available, publishable_key, stripe_account, …}`.77 - `source: "key"` or `"connected"` → continue (`key` wins when both exist).78 - `source: null` → nothing yet. Offer **Connect with Stripe** (confirm79 with the user first — it opens Stripe): `POST $CONNECT`80 `{"return_url":"<the app's URL, or http://localhost:3000/setup>"}` →81 open `authorize_url` in the browser; the admin signs in to Stripe (or82 creates an account there) and clicks Connect; Stripe returns through the83 platform to `return_url?stripe_connect=connected` (or84 `…=error&reason=<code>`); re-run `GET $CONNECT` until `connected`85 (plain — not `?refresh=1` in a loop, that read holds a worker on a86 Stripe call; use it once afterwards if the snapshot matters).87 Reconnect (another Stripe account, or the same one after the admin88 revoked the app on Stripe): `DELETE $CONNECT` (confirm first; a `502`89 means Stripe could not confirm it and the link is kept — retry), then90 the same `POST` round trip, then re-record the price (item 5) on the91 new account. The other way stays: a platform admin adds an integration92 credential named `stripe` in the platform credentials UI, holding a93 **restricted** key: Stripe Dashboard → Developers → API keys → Create94 restricted key — write on Products, Prices, Checkout Sessions,95 Customers; read on Subscriptions; everything else None. In the platform96 UI, **not in this chat** — never ask for or accept the key here.97 - `502` from any proxy call → Stripe rejected the source (wrong-mode key,98 or the account was disconnected on Stripe's side).99 - `404` → old backend (then `GET $PAY/products/?limit=1` still tells a100 pasted-key setup apart: 200 ready, 400 no credential) or101 `$IBLAI_USERNAME` not a member.1022. **Create the product**, tagged for this app:103 `POST $PAY/products/` `{"name":"<App> access","metadata":{"app":"<slug>"}}`.104 The `metadata.app` tag is what the DM enforces at checkout — it must105 equal `PAYWALL_APP_SLUG` exactly. Use the deploy project slug106 (lowercased package name) as the value.1073. **Create price(s)**: `POST $PAY/prices/`108 `{"product":"prod_…","unit_amount":2900,"currency":"usd"}` — add109 `"recurring":{"interval":"month"}` for a subscription. Checkout mode110 follows the price type automatically.1114. **Capture display data**: `GET $PAY/prices/<id>/` → amount/currency/112 interval → fill the `PRICES` constant in `app/paywall/page.tsx`.1135. **Record the price on the platform** (one price per app):114 `PUT https://api.$DOMAIN/dm/api/core/orgs/$PLATFORM/metadata/`115 `{"metadata":{"apps":{"<slug>":{"stripe":{"product_id":"prod_…","price_id":"price_…","publishable_key":"<from GET $CONNECT>","stripe_account":"<acct_… or null>"}}}}}`116 — a deep merge, other keys survive; it is a public read, never put a117 secret there. The recorded price is the contract: **a caller buying on118 their own path** (a member on the self-service rail, or the platform119 key's owner testing their own app) **must have one and can buy only it**,120 and once recorded it binds every checkout. An app selling **several**121 prices through the server rail leaves this out — then test the paywall122 with a member account, not as the key's owner.1236. **Write env** — append to `.env.local`:124125 ```bash126 PAYWALL_PRICE_IDS=price_xxx,price_yyy # server-only allowlist, comma-separated127 PAYWALL_APP_SLUG=my-app # must equal the product's metadata.app128 ```129130## Step 2: Install the app files131132Ready-made, typecheck- and unit-test-gated copies ship as ops-init assets —133install them with one copy (from wherever the skills are staged; same134resolution as vibe-starter itself):135136```bash137cp -a <skills-dir>/iblai-vibe-ops-init/assets/stripe-components/. .138```139140If the staged skills carry no `assets/` (some installers strip them), fall141back to the complete drop-in bodies in142[`references/app-files.md`](references/app-files.md) — identical content.143Either way, only `PRICES` in `app/paywall/page.tsx` and the two env lines are144per-app; the copy also brings `__tests__/paywall*.test.ts`, which run under145the app's existing `pnpm test`.146147| File | Role | ~Lines |148|---|---|---|149| `lib/paywall.ts` | Server-only helpers: `resolveUser` (identity from the forwarded `dm_token`), `userFromRequest`, `dmPaywallFetch` (Api-Token calls to the DM) | 75 |150| `app/api/paywall/access/route.ts` | GET → resolve user → forward optional `session_id` → DM's answer verbatim | 22 |151| `app/api/paywall/checkout/route.ts` | POST → resolve user → allowlisted `price_id` → DM mints the Checkout session | 32 |152| `components/paywall-gate.tsx` | Client gate + shared `checkPaywallAccess()`; denied → `/paywall` | 55 |153| `app/paywall/page.tsx` + `app/paywall/paywall-actions.tsx` | Pricing page (outside `(app)`, login-first via the existing providers) + buy/auto-verify/restore actions | 105 |154| `app/paywall/return/page.tsx` | Confirms the purchase by `session_id`, then into the app | 38 |155| `app/(app)/layout.tsx` | 3-line edit: wrap `{children}` in `<PaywallGate>` | — |156157**Trust rules (non-negotiable):**158159- User identity comes ONLY from `resolveUser` on the server — never accept a160 client-sent username.161- `price_id` must pass the `PAYWALL_PRICE_IDS` allowlist.162- `IBLAI_API_KEY` and `PAYWALL_*` are server-only — never `NEXT_PUBLIC_*`,163 never imported into client components.164- Surface DM 400 bodies verbatim — they are actionable (missing credential,165 wrong app tag, disallowed redirect host).166167## The member self-service rail (no platform key in the app)168169`paywall/checkout/` and `paywall/access/` also serve the **path user170themselves**: the browser calls them on the member's own username path with171the member's own DM token (`Authorization: Token <dm_token>`, the SDK's172`dm_token`), so the app holds no platform key at all — this is how ibl.ai's173`vibe-agent` reference app pays in its modal. RBAC:174`Ibl.Mentor/StripePaywallSelf/action`, a Students-role verb (organizations seeded175before it need `seed_rbac_data`); RBAC off: any signed-in member on their own176path. Other users' paths and the payments ledger keep the admin verbs.177178```http179POST {dm_url}/api/ai-mentor/orgs/<org>/users/<me>/providers/stripe/payments/paywall/checkout/180Authorization: Token <the member's dm_token>181{"app": "<slug>", "price_id": "price_…", "ui_mode": "embedded", "payment_method_types": ["card"]}182→ {"client_secret": "cs_…", "session_id": "cs_…", "publishable_key": "pk_…", "stripe_account": "acct_…" | null}183```184185Render it with Stripe.js: `loadStripe(publishable_key, stripe_account ?186{ stripeAccount: stripe_account } : undefined)`, then187`createEmbeddedCheckoutPage({ fetchClientSecret: async () => client_secret,188onComplete })`; Stripe never redirects (`redirect_on_completion: never`). In189`onComplete`, poll190`GET …/users/<me>/providers/stripe/payments/paywall/access/?app=<slug>&session_id=<session_id>`191with the same token until `has_access` is true (a `session_id` punches192through a cached deny). Rules: the recorded price (Step 1, item 5) is required —193400 `no price recorded` without it — and only it can be bought; `ui_mode`194omitted is hosted checkout on this rail too (`success_url`/`cancel_url`,195`checkout_url`). The admin's own DM token works the same way on the admin's196own path for Step 1 (`GET`/`POST $CONNECT`, products, prices, the metadata197PUT), so a setup screen inside the app needs no platform key either.198199## Step 3: Deploy200201Server mode is required (no `output: 'export'`). `/iblai-vibe-ops-deploy`202regenerates `.env.production` from `.env.local` on every deploy and its copy203list includes `PAYWALL_*`; before uploading, confirm204`grep PAYWALL_ .env.production` shows both lines. The DM validates205`success_url`/`cancel_url` against the organization's own deployed apps206(`*.vercel.app` hosts), its custom domains, and localhost — a checkout 400207naming the host means the app isn't deployed under this organization yet:208deploy first, or attach the domain.209210## Step 4: Verify211212- [ ] Anon visit `/` → Auth SPA → back logged in → landed on `/paywall`213 (never a blank app)214- [ ] `/paywall` shows the price card(s) with real name/amount/interval;215 buy → `checkout.stripe.com`216- [ ] Test card `4242 4242 4242 4242` → `/paywall/return?session_id=…` →217 "Confirming…" → app home (the `session_id` punches through any cached218 deny)219- [ ] Clear `sessionStorage` → reload `/` → brief loader → still in the app220 (DM re-verifies)221- [ ] Entitled user opening `/paywall` directly is bounced back to `/`222- [ ] A second (unpaid) platform user is stuck on `/paywall`; "Restore223 access" reports no payment found224- [ ] `GET $PAY/paywall/payments/?app=<slug>` lists the test payment225- [ ] `GET $CONNECT` shows the `source` in use and, when connected,226 `charges_enabled: true` (`?refresh=1` for a live read; the snapshot is227 otherwise refreshed at most once a minute)228- [ ] Subscription price only: cancel in the Stripe dashboard → clear229 `sessionStorage` → access lapses within ~75s (DM cache) + up to 60s of230 client grant cache231- [ ] Deployed: `.env.production` in the zip carries both `PAYWALL_*` lines;232 SSO lands on the deployed app's `/sso-login-complete` and `axd_token`233 appears in localStorage234235## Deliberately not built236237- **Webhooks** — the DM records at the buyer's return and re-checks live;238 there is nothing to receive.239- **Self-serve cancel UI** — the admin cancels in their Stripe dashboard;240 access lapses within the cache window.241- **Refund auto-revoke** — a recorded one-time payment is permanent242 entitlement by DM design (Stripe never flips a completed session).243- **Guest checkout** — the app is login-first; `/paywall` sits behind244 `AuthProvider`, so every buyer has an account. Anonymous buying belongs to245 the Connect rail.246247## Common mistakes248249- Putting `/paywall` inside `(app)` — the gate would loop. It must live250 OUTSIDE the gated group but inside the root providers.251- Adding `^/paywall` to `PUBLIC_ROUTES` — checkout needs a logged-in252 platform member; login-first is the design.253- Calling the DM paywall endpoints from the browser **with the Api-Token** —254 it is org-wide authority; only the app's server routes hold it. The browser255 rail is the member's own `Token` on the member's own path (above).256- Testing the paywall as the platform key's owner with no recorded price —257 a caller on their own path must buy the recorded price (400258 `no price recorded`): record it (Step 1, item 5) or test with a member account.259- Asking for a Stripe key when `GET $CONNECT` says `source: null` — offer260 Connect with Stripe first; the key path is the admin's, in the platform UI.261- Mixing auth schemes: DM paywall/proxy calls take262 `Api-Token <platform key>`; `core/token/verify/` identity resolution263 takes the end user's `Token <dm_token>`.264- Forgetting `PAYWALL_*` in `.env.production` — works locally, then every265 deployed user gets 500s from the paywall routes.266- Hardcoding a URL into `success_url` instead of using the request origin —267 breaks the moment the app moves hosts.268- Treating the `sessionStorage` grant cache as security — it only prevents a269 loading flash; the DM is the authority.270- Expecting instant lockout after a subscription cancel — the window is the271 DM cache (≤ ~75s) plus up to 60s of client grant cache.272273## Related skills274275- [`/iblai-vibe-monetization`](../../billing/iblai-vibe-monetization/SKILL.md) — family index; item-level Connect rail overview276- [`/iblai-vibe-monetization-checkout`](../../billing/iblai-vibe-monetization-checkout/SKILL.md) — sell individual items in-platform (Connect)277- [`/iblai-vibe-monetization-onboard`](../../billing/iblai-vibe-monetization-onboard/SKILL.md) — Stripe Connect **Express** onboarding (item rail only; not the Connect with Stripe link above)278- [`/iblai-vibe-ops-deploy`](../../ship/iblai-vibe-ops-deploy/SKILL.md) — ships the app + `.env.production` via ibl.ai hosting279- [`/iblai-vibe-ops-test`](../../ship/iblai-vibe-ops-test/SKILL.md) — validate before showing work280- [`/iblai-vibe-auth`](../../start/iblai-vibe-auth/SKILL.md) — SSO token wiring the gate depends on281- [BRAND.md](https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/BRAND.md) — visual language for the pricing page