Replit Agent can assemble the interface, API and database for a subscription service. The difficult part is deciding when a named member may see a paid resource. A Checkout redirect, an active subscription and an invoice each tell only part of that story. This guide gives an app owner a complete two-plan build specification and a set of checks to run in their own Stripe test mode. It is based on current provider documentation checked on 22 October 2026; BIG CHANGE did not build or test the app, make a payment or verify an account configuration.
The big change
- What changed: An AI coding agent can be given a whole-app contract covering enrollment, hosted recurring billing, signed events, invoice history and live resource access.
- Why it matters: Basic and Plus access can be decided by the current subscription and its matching paid card invoice, rather than by a browser redirect or stored status.
- What to watch: Stripe portal options, API response shapes and hosting permissions must work in the owner's actual test account before any paid release.
The intended reader owns a permitted member service, may configure recurring billing and can decide whom to enroll. “Member” below means a verified Clerk user with an active row in the app database. A Stripe Price defines what is charged; a Subscription records the recurring agreement; an Invoice and its payments record the billed cycle. An entitlement is the server's decision to return the owned Basic or Plus resource. The app uses hosted Stripe pages for card entry and billing changes, so its own pages must never collect card numbers.
The specification deliberately supports a narrow billing policy: two fixed monthly card plans, automatic collection and a positive paid invoice for the current plan and period. Trials, discounts, credits, delayed methods and manual collection require a separate design. This strict rule can interrupt access while billing is unsettled. It does not guarantee that a later refund, dispute or reversal cannot happen. The owner must choose real plan benefits, prices, currency, tax treatment, customer terms, cancellation and refund policy; none are supplied here.
1. Configure a test catalog and development environment
In Stripe test mode, create two active fixed, licensed, per-unit recurring Prices under one Product. Name the app plans Basic and Plus, set each Price to a one-month interval with interval_count of one, and record the real IDs only after reading them back. Configure the test Billing portal for card payments, an immediately invoiced upgrade, a same-Product end-of-period scheduled downgrade and the intended cancellation behavior. Check that those controls actually appear and behave as required in the account. Stripe's portal configuration guide explains the options and same-Product schedule constraint. No price, fee or tax result is assumed by this article.
Prepare Replit Agent, managed Clerk, SQL PostgreSQL and a React/Express project. Keep test and live Clerk identities, databases, Stripe keys, webhook secrets, Price IDs, portal configurations and return origins separate. Choose Node 24 or 22 only after checking the actual Replit development and published runtimes and every locked dependency's engine requirement; Node's release table lists their support state. Keep secrets out of Agent prompts and browser code. Use two harmless member-resource texts and synthetic test identities. Completion of this step is an observed, compatible development environment with the two verified test Prices and portal settings; otherwise mark the build HOLD.
2. Give the agent the complete build contract
In Replit Agent Plan mode, request the React interface, Express API, managed Clerk authentication and PostgreSQL persistence described in the three public sections below. Supply all three sections, including the SQL, to the agent. Ask it to state the routes and data flow it plans before editing. Inspect its generated code and lockfile, especially the actual provider-generated Clerk proxy and sign-in routing, proxy-before-body-parsers order, public landing and safe plans access, private membership checks, SQL transactions, route-specific webhook raw-body/signature handling before general JSON, Stripe request version, endpoint event version, server-only secrets and the live access predicate. Ask the agent to repair each mismatch against the contract and inspect the changed code again. The public sections are the build input; no editorial file is needed.
Seed the first owner with authenticated database administration and the exact Clerk user ID for that environment. Then use the owner enrollment route to add two named synthetic subscribers, after verifying each identity in Clerk. Do not use an email match to join a Stripe customer to a member. The expected result is a persistent app that exposes the listed screens and routes, with no paid access granted merely because the code compiles.
3. Run the billing and denial checks in test mode
Publish the test app on a public HTTPS host reachable by Stripe. Require Clerk sign-in and current SQL membership on every private app route. Keep the document-free landing, safe GET /api/plans and the integration's actual proxy, sign-in, callback and necessary authentication bootstrap/static transport reachable without app membership; retain the provider's own authentication protections. The webhook accepts unauthenticated HTTP only after raw-body Stripe signature verification. Check guest landing, plans, sign-in and bootstrap, then check guest and unlisted private-data denials and subscriber denial on owner routes. A Replit host gate that requires a Replit login can prevent Stripe from reaching the callback; Replit's publishing guide describes those host choices. If the signed webhook cannot be public while member routes remain protected, HOLD.
With Stripe-approved test cards, run the exact acceptance matrix at the end of the API contract. Observe the provider Session, Customer, Subscription, Invoice, Invoice Payment and PaymentIntent records, then compare them to the app's own status, invoice list and Basic/Plus resource responses. Test paid Basic, paid Plus, a pending upgrade, scheduled downgrade, cancellation, failed renewal, active-but-unpaid invoice, duplicate or out-of-order signed events, an uncertain Checkout response and cross-member ID guesses. A redirect or displayed invoice is not a passing payment test. Record the actual results and IDs privately. When a check fails, inspect the specific SQL row and Stripe record, correct the smallest app or account configuration issue, and repeat the allowed and denied cases. The result is either observed test-mode evidence for the complete task or a precise HOLD; this guide reports neither outcome for a reader account.
4. Review the paid-release boundary
The owner reviews actual fees, rights to the service, truthful terms, tax and billing policy, the exact generated revision, production identities, live Stripe restricted-key permissions and public-host exposure. Repeat bounded denial and configuration checks in the separate live environment before inviting a permitted first subscriber. Only an owner decision can authorize real billing. If the first real subscription is approved, verify its hosted payment and server access against actual provider records and confirm another account cannot read its invoices or resources. Missing provider support, unresolved duplicate Checkout, incompatible API shape, uncertain paid-card evidence or inadequate service terms holds the whole paid release. A prior BIG CHANGE guide on a private client portal covers a different named-customer app; this guide's subscription lifecycle stands on its own.
Complete app build brief
This is an original documentation-only reference for an AI-assisted build, not an observed app, payment or compliance review. The reader owns the service, the two recurring price/plan decisions, the permitted member resources, the Stripe and Replit accounts, and the legal/tax/refund/customer-notice decisions. Use harmless synthetic resource text and Stripe test-mode customers/cards first. Do not take real money or promise access until the owner has checked the exact deployed revision and the production account setup. Stripe hosts all card and billing entry; this app never collects, stores or echoes card numbers.
Whole app outcome and Plan-mode brief
Build a small subscription manager for one owned member-resource service, with Basic and Plus monthly plans. An owner creates and approves two active recurring Prices for the same Stripe Product in Stripe Dashboard, records their actual IDs and account settings, and supplies two permitted small text resources to prove plan access. Replit Agent builds the React UI, Node Express API, Replit-managed Clerk authentication and SQL PostgreSQL from the full schema and API contract supplied with this guide. Stripe-hosted Checkout starts one selected recurring subscription; Stripe Billing customer portal is where a subscriber changes payment details, changes between the two configured prices, or cancels/reactivates if the actual portal configuration supports those actions. The app shows server-scoped plan/status, bounded invoice history and protected Basic/Plus resource routes. Owner UI shows member/subscription states, a last-sync time and exact-record reconciliation controls. A webhook verifies Stripe signature on raw bytes, deduplicates event IDs and refreshes current Stripe resources; a success URL alone never grants access. Apply the following plan, schema, route, event, error and release contract.
There are two roles: owner and subscriber. The first owner is seeded by authenticated administration using the exact production/development Clerk user ID in its own environment. The owner may enroll a named existing Clerk user as subscriber after verifying it with Clerk's server users.getUser(userId) and the Auth pane; no anonymous session becomes a subscriber merely by visiting the public deployment. For the restricted first release, allow only owner-enrolled test identities. Later public self-enrollment is a separate reviewed policy change. Users' Stripe customer IDs, price IDs, roles, plan status and invoice IDs come only from the verified server mapping or Stripe; never trust a browser-supplied value as authority. No webhook may attach a Stripe customer/subscription to a different member because an email matches.
The owner must configure the real Stripe test-mode catalog with two active, fixed-price, licensed, quantity-one monthly recurring Prices under the same Product (interval count one), the test-mode Billing portal with card payment, immediate invoice on an upgrade, end-of-period scheduled downgrade and cancellation controls, and the test webhook endpoint/secret. This reference supports automatic card collection only, with no trials, coupons, customer balance, manual/off-Stripe settlement, delayed methods, paused collection or send-invoice billing. Verify the portal and account preserve that policy; any exception holds paid access and release until reviewed. Check account country, currency, tax, billing, proration, cancellation timing and fee settings in the actual Stripe account; these instructions supply none of those policy decisions. Confirm the portal UI actually exposes the chosen actions and schedule behavior; an end-of-period downgrade retains the currently paid Plus access until the billed period ends. If it cannot switch between these Prices or meet the reader's required billing policy, HOLD the whole subscription-manager release. Use only Stripe-approved test card methods in test mode; no real card, subscriber or charge is assumed here.
Development and production use separate Clerk user stores, SQL databases, Stripe test/live secret keys, webhook secrets, Price IDs, portal configurations and return origins. Use an actually supported Active or Maintenance LTS Node.js release (currently 24 or 22; 20 is EOL), verify the Replit development and published runtime versions plus every locked dependency's engine requirements, and HOLD if one compatible supported combination cannot be demonstrated. Pin the actual installed stripe, @clerk/express, Express, PostgreSQL client and React package versions in the reader's lockfile and inspect the generated code. Do not paste secrets into Agent prompts or browser code. The Stripe restricted secret key needs the exact permissions used by Checkout Sessions, Billing Portal Sessions, Prices, Subscriptions and Invoices, Invoice Payments, PaymentIntents and PaymentMethods; inspect its actual grants and use a separately approved broader key only if an operation requires it. Rotate any exposed secret through the owner/provider process; changing sharing does not undo exposure.
Host and authentication boundary: Stripe needs to reach the production HTTPS webhook without a Replit login. Replit Invite only and Workspace only host gates require Replit sign-in, so they cannot be assumed to serve the callback. Use a Public HTTPS published host with app-level restricted access. Leave the document-free landing, safe GET /api/plans, and the actual provider-generated managed-Clerk proxy, sign-in, callback and necessary authentication bootstrap/static transport accessible without app membership; retain the provider's own authentication protections and verify its generated paths instead of guessing them. Clerk sign-in plus current SQL membership protects every private member/admin data route. The sole unauthenticated billing callback, /api/stripe/webhook, accepts only Stripe-signed raw requests and never returns tenant data. Mount the managed Clerk proxy before body parsers; mount the route-specific raw signed Stripe webhook before general JSON; then apply the appropriate private app authentication, membership and role guards. App Origin and session-bound CSRF checks cover app mutation routes, not provider-owned authentication transport. The owner checks host Sharing, guest landing/plans/sign-in/bootstrap, private-route denials and webhook signature handling before a real subscriber. A public URL is not a public billing record. If the host cannot expose the signed webhook while keeping private routes protected, HOLD; do not skip verification or rely on obscurity. A future fully public signup needs its own enrollment, abuse and terms review.
Subscriber and owner experience
The landing page explains the two owner-approved plan names and the real Stripe price amount/currency/interval read from the server after Price validation. An enrolled subscriber sees Choose Basic/Plus (when no current subscription), Billing status (current Stripe status, selected plan, cancel-at-period-end and sync time), Manage billing (new short-lived Stripe portal session), and Invoices (only its own customer ID, last ten with safe status/hosted invoice link). An owned Basic resource is allowed only for a currently eligible Basic or Plus subscription; the owned Plus resource only for an eligible Plus subscription. Eligibility requires the exact current Stripe subscription, approved Price and collection policy, plus a currently paid linked invoice and successful card payment under the API contract. active status alone never proves payment. Pending/delayed or failed payment, an open/void/uncollectible invoice, collection pause, unsupported payment method or provider uncertainty denies the resource. This reference does not grant a trial or grace period. A cancel-at-period-end subscription remains eligible only while the same payment checks pass and Stripe still reports active; after cancellation it loses access. The UI says the provider is checking status instead of showing “paid” from a redirect.
The owner can view member IDs/names, mapped customer/subscription IDs, status, selected configured price and sync errors, but never payment instruments, full invoice PDFs or secret keys. Owner enrollment, deactivation and exact-record reconciliation are admin actions with audit rows. Reconciliation reads Stripe's current subscription and updates one member; it never creates a charge, changes a plan or retries a failed payment. Customer-facing changes happen in Stripe's configured portal. If the account offers no permitted real product/resource, the owner may still test with synthetic files, but the paid production launch remains HOLD until a real permitted service and truthful terms exist.
Reader-facing deliverables and sources
Publish the complete build brief, executable SQL schema and full API/lifecycle contract together, either inline or as genuinely public reference artifacts. A reader must be able to reproduce the two-plan app and its allowed/denied checks without access to an editorial packet. Keep account IDs, secrets, actual customer data and test receipts private.
Sources: Stripe subscription lifecycle, Checkout Session, Billing portal, subscription webhooks, webhook signatures and ordering, API versioning, current subscription, invoice and payment evidence, Node release status, Replit Agent, managed Clerk, SQL environments, host Sharing, and Clerk Express middleware.
PostgreSQL schema
Use this only in a new, empty development database after inspection. The production database is separate and needs the owner's release decision.
-- New empty PostgreSQL environment only. Inspect before running in the
-- reader's Development DB; run separately in Production after owner approval.
-- Server-generated UUIDs are bound as parameters; no browser chooses IDs.
BEGIN;
CREATE TABLE app_member (
id uuid PRIMARY KEY,
clerk_user_id text NOT NULL UNIQUE,
role text NOT NULL CHECK (role IN ('owner','subscriber')),
active boolean NOT NULL DEFAULT true,
stripe_customer_id text UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE plan_catalog (
code text PRIMARY KEY CHECK (code IN ('basic','plus')),
stripe_price_id text NOT NULL UNIQUE,
enabled boolean NOT NULL DEFAULT false,
checked_at timestamptz,
CHECK (char_length(stripe_price_id) BETWEEN 5 AND 120)
);
CREATE TABLE checkout_attempt (
id uuid PRIMARY KEY,
member_id uuid NOT NULL REFERENCES app_member(id) ON DELETE RESTRICT,
plan_code text NOT NULL REFERENCES plan_catalog(code),
stripe_price_id text NOT NULL,
request_key text NOT NULL UNIQUE CHECK (char_length(request_key) BETWEEN 32 AND 80),
stripe_session_id text UNIQUE,
state text NOT NULL CHECK (state IN ('reserved','open','unknown','completed','expired')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX checkout_one_unresolved_per_member
ON checkout_attempt(member_id)
WHERE state IN ('reserved','open','unknown');
CREATE TABLE subscription_snapshot (
stripe_subscription_id text PRIMARY KEY,
member_id uuid NOT NULL REFERENCES app_member(id) ON DELETE RESTRICT,
stripe_customer_id text NOT NULL,
stripe_price_id text NOT NULL,
status text NOT NULL,
cancel_at_period_end boolean NOT NULL,
current_period_end timestamptz,
synced_at timestamptz NOT NULL,
sync_source text NOT NULL CHECK (sync_source IN ('webhook','member-read','owner-reconcile')),
CHECK (char_length(stripe_subscription_id) BETWEEN 5 AND 120)
);
CREATE INDEX subscription_snapshot_member ON subscription_snapshot(member_id,synced_at DESC);
CREATE TABLE stripe_webhook_event (
event_id text PRIMARY KEY,
event_type text NOT NULL,
object_id text,
state text NOT NULL CHECK (state IN ('pending','processed','hold')),
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
error_class text
);
CREATE TABLE app_audit (
id uuid PRIMARY KEY,
actor_member_id uuid REFERENCES app_member(id),
action text NOT NULL CHECK (action IN ('enroll','deactivate','checkout_reserved','checkout_linked','reconcile','webhook_hold')),
subject_member_id uuid REFERENCES app_member(id),
stripe_object_id text,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMIT;
-- First owner: use authenticated database administration and the exact
-- verified Clerk user ID of THIS environment. Do not expose a seed endpoint.
-- INSERT INTO app_member(id,clerk_user_id,role)
-- VALUES ($1,$2,'owner');
-- Owner-approved Basic/Plus Price IDs are likewise bound as parameters after
-- reading Stripe's actual mode, active flag, recurring month interval_count=1,
-- fixed licensed/per-unit scheme, positive unit amount, currency and product.
-- A local Price ID or subscription_snapshot never grants a resource: each
-- protected read applies the current paid-card predicate in the API contract.Complete API and lifecycle contract
These are complete requirements for the reader's generated Node/Express app, not a claim of execution. The reference field and method shape below is Stripe API 2026-09-30.endive, checked in current first-party manuals. No SDK is installed by this document. In the reader app, select a locked stripe SDK that supports that shape, set its request apiVersion explicitly to 2026-09-30.endive, and verify its types/methods and test-mode response fields. Independently pin the Stripe webhook endpoint event API version to 2026-09-30.endive in Workbench and check the signed event api_version; do not inherit or infer either version from the account default. Stripe-node v12+ otherwise uses the version pinned by its SDK release. If the installed SDK, endpoint setting or actual response cannot match the checked shape, HOLD and re-review the implementation against the relevant version before billing. Event payloads supply IDs/routing hints only; re-fetch live objects through the pinned request version. The generated implementation must expose these exact behavior and review points before any test-mode payment. Use parameterized SQL with one checked-out connection for each transaction. Never pass a browser-chosen customer, subscription, invoice, Price or member ID into a Stripe call as authority.
Sessions, membership and plan configuration
Mount the managed Clerk proxy before body parsers and verify its actual generated path and sign-in routing in the Replit integration. Mount the route-specific raw Stripe webhook with signature verification before general JSON parsing. Then apply Clerk clerkMiddleware() to the appropriate private app routes, use getAuth(req) and query the current active app_member row by the verified userId. On private routes, guest 401; signed-in unlisted/inactive 403; owner-only action from subscriber 403. All browser app POST/PATCH mutations require exact allowed Origin plus a session-bound app CSRF token, rejecting missing/foreign values before Stripe or SQL. These app guards do not intercept provider-owned proxy, sign-in, callback or necessary authentication bootstrap/static transport; retain the provider's own security controls. Keep the document-free landing and safe GET /api/plans public without app membership. /api/stripe/webhook is the only unauthenticated billing callback and requires a Stripe raw-body signature. No React route guard substitutes for server membership on private app data/actions. Use HTTPS, server-only secrets, safe redirects to fixed origins and Cache-Control: no-store on billing responses.
At setup, owner verifies actual two Stripe Prices in the correct test/live mode using stripe.prices.retrieve(priceId): each must be active, type='recurring', recurring.interval='month', recurring.interval_count===1, recurring.usage_type='licensed', billing_scheme='per_unit', positive fixed unit_amount, expected currency and the same owner-approved Stripe Product for both Prices, and exactly the owner-approved Basic/Plus mapping. Reject metered, tiered, quantity-dependent, multi-month, zero-cost or incompatible Prices rather than approximating a monthly fixed plan. Save IDs in plan_catalog with checked time only after actual readback; read amount/currency from Stripe for UI, do not hard-code a claimed price. Disable a plan in SQL before changing its Price ID or account config; inspect existing subscriptions and Stripe portal catalog/proration before re-enabling. The owner must configure cancellation, tax, refund, invoice and upgrade terms in Stripe and publish truthful customer terms separately. This reference does not set those terms.
POST /api/admin/members is owner-only: accept a Clerk user ID selected by the owner, verify existence with the current environment's clerkClient.users.getUser(userId) and independently verify the intended person in the Auth pane; transactionally create/reactivate one subscriber row and audit it. Never infer identity from email alone. PATCH /api/admin/members/:id/deactivate disables app access, audits it, and does not cancel a Stripe subscription or refund a charge. Show the owner that separate billing action is needed in Stripe; never represent app deactivation as cancellation. First owner seed remains authenticated database administration, not an HTTP route.
Route contract
Route | Server behavior |
|---|---|
| Public without app membership: safe Basic/Plus names and real validated Stripe Price amount/currency/interval; no secret or inactive plan. 503 if actual Price check missing. |
| Current subscriber or owner-self mapping only. Fetch/reconcile exact own Stripe subscriptions and show status, price, cancel flag, period end when documented, and sync/error state; no cross-user ID parameter. |
| Active subscriber with no current nonterminal subscription; body is |
| Browser success page only; server may retrieve the exact linked Checkout Session for this member to reconcile, but never grants from URL/query alone. Pending until current subscription is verified. |
| Current subscriber with server-bound |
| |
| Auth + current member + exact live subscription, latest invoice, invoice-payment and card-method reads; Basic accepts an eligible Basic or Plus Price, Plus accepts eligible Plus only. Return reader-owned permitted text only after the paid-card predicate below. |
| Owner-only bounded local snapshots, sync times and unresolved events; clearly label snapshots, no card details. |
| Owner-only read of exact stored customer/session/subscription IDs; refresh current Stripe state and audit, never modify charges or payment details. |
| Raw body + |
Checkout reservation, unknown result and duplicate protection
Under a member row lock, check the actual Stripe customer if known with stripe.subscriptions.list({customer:storedCustomerId,status:'all',limit:100}); if has_more, HOLD instead of guessing. If any subscription is nonterminal (active, trialing, past_due, unpaid, paused, incomplete or cancellation pending), route to the portal/support and do not start another Checkout. This guide disables trials, but unknown external/account state is still denied. If no Stripe customer exists yet, an unresolved local checkout_attempt still blocks a second attempt. A repeated request key with the same member/plan/Price returns its existing session if open; changed binding returns 409. A different key while an attempt is reserved, open or unknown returns 409. A new attempt reserves SQL state='reserved' and an immutable key before any provider POST, commits, then only its inserter calls Stripe. Stripe's idempotency key is the same server-validated request key, with member/plan binding enforced locally. For an existing Stripe customer pass the stored ID; otherwise Checkout in subscription mode creates one on completion.
The supported call shape is:
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: storedCustomerId || undefined,
client_reference_id: String(member.id),
line_items: [{ price: verifiedPriceId, quantity: 1 }],
allowed_payment_method_types: ['card'],
payment_method_collection: 'always',
success_url: `${fixedOrigin}/account?checkout={CHECKOUT_SESSION_ID}`,
cancel_url: `${fixedOrigin}/plans`
}, { idempotencyKey: requestKey });Check the actual SDK version/method contract and returned session.id, url, mode, livemode and client_reference_id before committing reserved -> open and storing its ID. The browser may follow only Stripe's returned HTTPS Checkout URL. On a network timeout or lost response, mark unknown without a second provider call. Within Stripe's documented idempotency retention window, inspect the exact request/response and safely repeat only the same parameters/key if the owner/operator has confirmed the intended retry; after that window, reconcile the exact stored session or Stripe request history, not a new key or a search result assumed consistent. Stripe may replay a first 500 response for the same key; that is not proof no session exists. A known expired Session can mark the attempt expired after stripe.checkout.sessions.retrieve(storedSessionId); only then may a new request key start a new Checkout. Concurrent same-key calls are serialized by SQL uniqueness and return one attempt. No checkout redirect, local row or webhook event alone proves a paid entitlement. In particular, an account-enabled delayed method that slips past the card filter is not supported: record/hold the attempt, wait for current provider resolution and deny protected resources; checkout.session.async_payment_failed and a void invoice must not leave access enabled.
Billing portal and invoice boundary
The owner must configure the portal so both approved monthly Prices of the same Product, card payment methods, immediate invoicing for an upgrade, an end-of-period scheduled downgrade, and the intended cancellation timing are the only supported paths. Stripe documents the same-Product constraint for scheduled downgrades; verify the actual portal configuration and shown behavior in the reader account. An upgrade may grant Plus only after its new invoice/card payment qualifies. A scheduled downgrade keeps the current paid Plus Price and entitlement until the period ends; at the change, a new paid Basic cycle invoice must qualify. A portal action may produce an unpaid invoice, delayed/pending update or a current Price change without a matching paid invoice; the browser return and subscription active status never upgrade entitlement. The server calls stripe.billingPortal.sessions.create({customer:storedCustomerId,return_url:fixedOrigin+'/account',configuration:ownerVerifiedConfigId}) after current membership and exact mapping checks. The returned short-lived url is sent only to that subscriber; generate it on demand, never cache/share it. Stripe's configured portal handles payment method updates, plan switches and cancellation/reactivation. The app must observe the resulting subscription webhooks/current reads, not pretend its own button made the change. The owner checks the actual portal catalog includes both Prices, immediate-upgrade/scheduled-downgrade and cancellation settings, and the test/live configuration split. If those controls or automatic-card collection cannot be enforced in the reader account, HOLD the full management outcome. A Dashboard/operator switch to send_invoice, non-card collection, pause_collection, a manual paid mark, trial, discount/customer-balance settlement or unverified zero-charge change is outside this reference and fails paid access until separately reviewed. Invoice listing always derives the customer from SQL; if no mapped customer, show no invoices rather than listing all.
Verified webhook, mapping and current-state refresh
After the managed Clerk proxy has been mounted before body parsers, mount express.raw({type:'application/json',limit:'512kb'}) for /api/stripe/webhook before express.json() for other routes. Keep this raw route outside app membership and app CSRF guards while requiring its Stripe signature. Use stripe.webhooks.constructEvent(req.body,req.headers['stripe-signature'],endpointSecret), catching signature/parse errors as 400. Require the event livemode and api_version to match the deployment's mode and independently pinned endpoint version; mismatches HOLD without updating a member. Allow checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, customer.subscription.created, .updated, .deleted, invoice.paid, invoice.payment_failed, invoice.voided and invoice.marked_uncollectible as reconciliation triggers. For either async Checkout event, retrieve the exact Session ID, mark its existing attempt for operator reconciliation and keep resource access denied until the live paid-card predicate passes. Unexpected signed events receive a harmless 200 without changing entitlement; do not infer payment from any event payload. Stripe explicitly does not guarantee event order and may deliver duplicates. Do not grant access from the event's embedded status or from event creation time. A signed webhook is still a provider assertion, not permission to map a customer to a different member.
Insert event.id in stripe_webhook_event if absent. processed returns 200 on duplicate; pending/hold is retried, not discarded merely because the ID exists. For checkout.session.completed, retrieve the exact Session ID from Stripe and require mode='subscription', livemode match, server-created attempt with that stripe_session_id and same internal client_reference_id, matching configured Price through the current Subscription, and customer/subscription IDs not already bound to another member. Under a member SQL row lock, set the immutable customer mapping, mark attempt completed, append audit and refresh the retrieved current Subscription. A mismatched or ambiguous mapping is hold/5xx for operator investigation, never an email-based reassignment.
For subscription events, take only the object ID/customer as routing hints; require it to match an already mapped customer, then stripe.subscriptions.retrieve(subscriptionId) and validate returned customer, livemode, exactly one subscription item of quantity one, its configured Price ID, and no extra item page before updating subscription_snapshot. If it arrives before the Checkout mapping, keep the event pending/5xx; process it after the matching Session is linked. For invoice events, use the verified invoice customer only to identify the mapped member and refresh current subscriptions; invoice.paid does not directly grant a feature. Serialize refreshes per member under a bounded SQL lock and fetch the current Stripe record within that ordered operation so out-of-order events cannot overwrite a newer state with an older snapshot. Use a short provider timeout and return 5xx on unresolved reads, allowing Stripe retry; do not mark an event processed before the matching SQL update commits. Keep raw payload/PII out of app logs. If retries expire, owner reconciles exact stored Session/subscription/customer IDs and pending event IDs in Stripe Workbench; no invented success.
Current paid-card entitlement predicate
Every protected Basic/Plus resource request independently checks current Clerk identity and active SQL membership, then lists only the stored Stripe customer's subscriptions with status:'all',limit:100 (deny if has_more) and requires exactly one nonterminal subscription. Retrieve that exact Subscription with the pinned request API version; verify ID, customer, livemode, exactly one item of quantity one, its current enabled configured Price, and status==='active'. Also require collection_method==='charge_automatically', pause_collection===null, no pending_update, no trial, and an unambiguous string latest_invoice ID. Any missing/unknown field denies. The approved Basic Price grants Basic only; Plus grants Basic and Plus.
Retrieve that exact latest invoice using stripe.invoices.retrieve(latestInvoiceId). Require matching customer/mode, collection_method==='charge_automatically', status==='paid', positive amount_paid, zero amount_remaining, expected currency, no unsupported discount/customer-balance/manual-credit path, and a single unambiguous line with parent.type==='subscription_item_details', parent.subscription_item_details.subscription===subscription.id, parent.subscription_item_details.subscription_item===currentItem.id, pricing.price_details.price===currentPriceId, quantity one and period.start <= now < period.end; deny if lines.has_more, if the line/parent binding is absent or ambiguous, or if the line reflects only the previous plan. Use the current 2026-09-30.endive invoice-line shape only after the installed SDK and test response confirm those fields; a generated app with older invoice shapes holds rather than guesses. A portal change that has not produced a paid invoice for the new Price remains pending and is denied until the current invoice/line is paid and matches. This strict rule may temporarily deny a no-charge downgrade; the owner must configure and test a billing flow that yields matching paid evidence, or HOLD that portal action. Never grant a free exception by clock or cached state.
For the paid invoice, call current stripe.invoicePayments.list({invoice:latestInvoiceId,limit:100}); deny on has_more or ambiguous entries. Require one or more status==='paid' payments on that invoice with positive amount_paid summing exactly to the invoice's amount_paid, each with payment.type==='payment_intent' and invoice===latestInvoiceId. Retrieve each exact PaymentIntent using stripe.paymentIntents.retrieve and its exact PaymentMethod using stripe.paymentMethods.retrieve; require matching mode/customer, status==='succeeded', paymentMethod.type==='card', and no unsupported payment or off-Stripe settlement. A manual paid invoice, credit-only settlement, zero payment, delayed method, failed/pending PaymentIntent, or a void/uncollectible/open latest invoice denies even if subscription status is still active. This is an app access policy based on current Stripe payment records, not a guarantee against later refunds, disputes or settlement reversal; those require separate monitoring and policy. No local snapshot grants access.
The server returns 403 for a known ineligible entitlement and 503 for provider/shape ambiguity or unavailable reads; it never falls back to an old snapshot. Invoice or subscription webhooks trigger current reconciliation and may update UI/audit, but only this live predicate authorizes resources. Billing status UI distinguishes Stripe active from “access eligible” and shows a safe pending/recovery state with the portal link. Period-end cancellation remains eligible only while every predicate above passes; after Stripe cancellation it is denied. Inspect the current subscription item period for display, never assume a top-level period field. The reader may optimize reads only after a separately reviewed freshness/revocation policy.
Errors and documented checks
401 guest; 403 unlisted/inactive/role violation or known ineligible entitlement (including unpaid/unsupported collection); 404 foreign member/attempt; 409 altered idempotency binding, unresolved Checkout or existing subscription; 422 invalid plan/key; 503 Stripe/SQL/configuration uncertainty. The UI never displays a success/payment claim from a redirect. Operator logs may include a random request ID, safe error class and exact Stripe object ID in restricted logs; never keys, signatures, card fields, email payload or full webhook body. Test error recovery must inspect exact SQL row, Stripe request/event and provider account before any money-affecting retry.
Before live keys or real subscribers, observe in Stripe test mode: Basic card-only Checkout -> signed completed event -> current active subscription plus matching paid invoice and successful card invoice payment -> Basic allowed/Plus denied; portal Basic-to-Plus switch -> current Plus Price and matching paid invoice/payment -> Plus allowed, while a switch pending payment denies Plus; Plus-to-Basic scheduled downgrade -> Plus remains current/eligible only through its already paid period, then the current Basic Price plus paid Basic invoice allows Basic and denies Plus; cancellation scheduled -> eligible while paid/current/active, then both denied at cancellation; failed renewal/past_due and active-with-open-or-void-latest-invoice -> denied with portal recovery; delayed method slip/async failure, pause_collection, send_invoice, manual/off-Stripe paid mark, non-card method, zero-charge/discount or trial exception -> denied/HOLD; duplicate/out-of-order/invalid-signature/wrong-mode webhook; duplicate and unknown Checkout request; subscriber A guessing B customer/session/invoice/member; guest landing, safe public plans and provider-generated sign-in/proxy/callback/bootstrap access; guest/unlisted private-data denials and subscriber owner-role denial; webhook on public HTTPS with invalid signatures rejected while all private routes deny an unlisted user; SQL persistence across restart; separate test/live configuration; and actual invoice list under A only. Use reader-controlled synthetic identities and Stripe test methods. Record observed statuses, account IDs and request/event IDs privately. No such test was run for this reference; unresolved outcome keeps the whole paid release on HOLD.
Sources & further reading
The Stripe subscription overview explains recurring lifecycle states; Checkout creation and idempotent requests support the hosted-start and retry boundaries. Billing portal integration, subscription webhooks and webhook delivery and signatures explain the provider-controlled change and event paths. For the access rule, compare the current subscription, invoice, invoice line, invoice payments and PaymentIntent reference fields. The checked API versioning guide and webhook endpoint API are separate configuration references. Replit Agent, managed Clerk, SQL environment separation and Clerk's Express middleware describe the build tools. These documents specify provider interfaces; they do not validate any generated app, account policy, payment or release.



