Add Stripe subscriptions to your app
This guide takes an app that already has login to one that sells a monthly “Pro” plan, unlocks an “Export PDF” feature only for subscribers, and notices a cancellation made in Stripe without any webhook code of yours. You need a Stripe account in test mode, Stripe’s sandbox where cards are fake and no money moves, and a Strent account.
Stripe hosts the payment page (Checkout) and the subscription management page (Customer Portal). Your app creates one Stripe customer per user and decides who may use what. Strent receives Stripe’s webhooks, the messages Stripe sends when a subscription changes, keeps track of each customer’s subscriptions, and answers your access checks.
If a coding agent writes your code, start with Let an AI agent follow this guide: the agent writes the code and asks you to do the steps in Stripe and in Strent.
Every step ends with a Checkpoint. If what you see does not match, fix that step before moving on.
Pick your stack
Section titled “Pick your stack”Only three pieces depend on your stack: getting the current user, storing their Stripe customer ID, and the server routes. They appear in tabs; everything else is the same for any Node.js server. The tab you pick applies to the whole guide.
Complete code for a Next.js App Router app that uses Supabase for login and its database, such as one created with npx create-next-app -e with-supabase. The code imports Supabase’s server client from @/lib/supabase/server and sends signed-out users to /auth/login, as that starter does.
Each glue piece describes what your code must do. Any server-side Node.js 22, 24, or 26 app with login and a database works.
Let an AI agent follow this guide
Section titled “Let an AI agent follow this guide”Paste this prompt into your coding agent to implement the guide in your app:
Add Stripe subscriptions with Strent to this app by followinghttps://docs.strent.dev/add-stripe-subscriptions/ in order. Use the"Next.js + Supabase" tabs if this app uses them; otherwise follow the"Other stack" tabs. Steps and the checkpoint each must pass:
1. Create the Pro plan in Stripe -> the product has a monthly price, and STRIPE_PRO_PRODUCT_ID and STRIPE_PRO_PRICE_ID are set.2. Set up Strent -> the active items call for a test customer without a subscription returns 200 with {"data": []}.3. Install the SDKs and create the clients -> the app starts with the new modules.4. Store each user's Stripe customer -> the storage exists and existing users need no migration.5. Check access -> a signed-in user without a subscription sees Export PDF locked.6. Sell the plan with Checkout -> after paying with card 4242 4242 4242 4242, the success page says the plan is active and Export PDF unlocks.7. Cancel from the Customer Portal -> access stays, and Strent Admin shows the scheduled cancellation with its date.
Ask me first to set STRIPE_SECRET_KEY to my Stripe test secret key. With it,create the Pro product and its monthly price through the Stripe API yourself.In step 2, once I tell you Strent is connected to Stripe, create the testcustomer the same way: a customer created earlier reaches Strent without awebhook, so the check would not prove that webhooks arrive. The rest of steps 2and 7 happens in the Stripe Dashboard, Strent Admin, or the browser: ask me todo it and to give you the keys. Do not write webhook code.Stop and tell me which checkpoint failed if one does not pass.-
Create the Pro plan in Stripe
In Stripe, a product is what you sell and a price is how much and how often you charge for it. One product can have several prices, such as monthly and yearly.
In the Stripe Dashboard, in test mode, open Product catalog, create a product named
Pro, and give it a recurring monthly price. Copy the product ID (prod_…) and the price ID (price_…) into your server environment, with your Stripe secret key from Developers → API keys:.env.local STRIPE_SECRET_KEY=sk_test_your_keySTRIPE_PRO_PRODUCT_ID=prod_your_productSTRIPE_PRO_PRICE_ID=price_your_monthly_priceAPP_URL=http://localhost:3000Test and live mode have separate catalogs, so these IDs change when you go live.
Checkpoint
The Stripe Dashboard shows the
Proproduct with one monthly price, and the three Stripe variables hold its IDs and your test key. -
Set up Strent
Strent keeps a copy of your customers’ subscriptions, updated by Stripe’s webhooks, so your app never handles them. For that, Strent needs two things from your Stripe account: a key that can only read, so it can look up subscriptions, and a webhook, so Stripe tells it about every change. In Strent Admin:
- Create a project in Test mode for this app’s development environment. Details
- Connect Stripe: in Stripe, create a restricted key with read access, and add the webhook URL that Strent shows as an event destination, Stripe’s name for where it sends webhooks, with the listed events. Then save the key in Strent, with the destination’s signing secret, which lets Strent check that each message comes from Stripe. Strent then reads every customer that already exists, with nothing for you to run. Details
- Create an API key and copy it; Strent shows it only once. Details
- Verify your setup: in the Stripe Dashboard, add a test customer without a subscription; it appears in Recent activity on the project Overview. Then call Strent with its ID. Details
.env.local STRENT_API_KEY=strent_test_your_opaque_secretKeep this key on the server: never in browser code or a public environment variable.
To verify your setup, set
STRENT_API_KEYin your shell and call Strent with the test customer’s ID:Terminal window curl https://api.strent.dev/v1/active_items/list \--request POST \--header "Authorization: Bearer $STRENT_API_KEY" \--header 'Content-Type: application/json' \--data '{"stripe_customer_id":"cus_your_customer_id"}'Checkpoint
The call returns200with{"data": []}: Strent received the customer from Stripe’s webhook, and your key can read it. If it does not, Verify your setup maps each result to its cause. -
Install the SDKs and create the clients
An SDK is a library for calling a service from your code:
stripecalls Stripe, and@strent-dev/nodecalls Strent.Terminal window npm install stripe @strent-dev/nodelib/stripe.ts import Stripe from "stripe";export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);lib/strent.ts import { Strent } from "@strent-dev/node";export const strent = new Strent({ apiKey: process.env.STRENT_API_KEY! });Create the Strent client once, in this module, and import it everywhere. Its cache, and the cache reset after a refresh, live in this object; a client per call would lose both.
Checkpoint
The app starts without errors. -
Store each user’s Stripe customer
A Stripe customer represents one of your users in Stripe; subscriptions and payments belong to it. Your app creates it the first time a user subscribes and stores its ID. Users who never subscribe never get one, so existing users need no migration.
The ID must only be written by server code: a user who could change it could point their account at someone else’s subscription.
Supabase manages the
auth.userstable, so store the ID in a table of its own. With row level security on and no policies, only server code using the secret key can read or write it. Run this in the Supabase SQL editor:supabase/migrations/stripe_customers.sql create table public.stripe_customers (user_id uuid primary key references auth.users (id) on delete cascade,stripe_customer_id text not null unique);alter table public.stripe_customers enable row level security;Add your Supabase secret key from Project Settings → API Keys (the
service_rolekey on older projects):.env.local SUPABASE_SECRET_KEY=your_supabase_secret_keylib/billing-store.ts import { createClient } from "@supabase/supabase-js";const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!,process.env.SUPABASE_SECRET_KEY!,{ auth: { persistSession: false } },);export async function getStripeCustomerId(userId: string,): Promise<string | null> {const { data, error } = await supabase.from("stripe_customers").select("stripe_customer_id").eq("user_id", userId).maybeSingle();if (error) throw error;return data?.stripe_customer_id ?? null;}export async function saveStripeCustomerId(userId: string,stripeCustomerId: string,): Promise<void> {const { error } = await supabase.from("stripe_customers").upsert({ user_id: userId, stripe_customer_id: stripeCustomerId });if (error) throw error;}lib/current-user.ts import type { AppUser } from "@/lib/billing";import { createClient } from "@/lib/supabase/server";export async function getCurrentUser(): Promise<AppUser | null> {const supabase = await createClient();const {data: { user },} = await supabase.auth.getUser();if (!user?.email) return null;return { id: user.id, email: user.email };}Add a nullable column to your users table:
alter table users add column stripe_customer_id text unique;Then implement, in server-only code:
lib/billing-store.tsexportinggetStripeCustomerId(userId: string): Promise<string | null>, which returns the stored ID ornull, andsaveStripeCustomerId(userId: string, stripeCustomerId: string): Promise<void>, which stores it.lib/current-user.tsexportinggetCurrentUser(): Promise<AppUser | null>, which returns the signed-in user’s{ id, email }from your auth library, ornullwhen signed out.AppUsercomes fromlib/billing.tsbelow.
This module holds all the Stripe and Strent code of the guide. Steps 5 and 6 explain each function.
lib/billing.ts import { getStripeCustomerId, saveStripeCustomerId } from "./billing-store";import { strent } from "./strent";import { stripe } from "./stripe";export type AppUser = { id: string; email: string };export async function getOrCreateStripeCustomer(user: AppUser,): Promise<string> {const saved = await getStripeCustomerId(user.id);if (saved) return saved;const customer = await stripe.customers.create({ email: user.email, metadata: { app_user_id: user.id } },// A double click must not create two customers for one user.{ idempotencyKey: `create-customer-${user.id}` },);await saveStripeCustomerId(user.id, customer.id);return customer.id;}export async function createCheckoutUrl(user: AppUser): Promise<string> {const session = await stripe.checkout.sessions.create({mode: "subscription",customer: await getOrCreateStripeCustomer(user),line_items: [{ price: process.env.STRIPE_PRO_PRICE_ID!, quantity: 1 }],success_url: `${process.env.APP_URL}/billing/success`,cancel_url: `${process.env.APP_URL}/export`,});return session.url!;}export async function createPortalUrl(user: AppUser): Promise<string> {const customer = await getStripeCustomerId(user.id);if (!customer) throw new Error("This user has no Stripe customer.");const session = await stripe.billingPortal.sessions.create({customer,return_url: `${process.env.APP_URL}/export`,});return session.url;}// Throws when Strent cannot give a trustworthy answer; callers deny access.export async function userHasPro(user: AppUser): Promise<boolean> {const customer = await getStripeCustomerId(user.id);if (!customer) return false;return strent.activeItems.hasProduct({customer,product: process.env.STRIPE_PRO_PRODUCT_ID!,});}export async function refreshStripeCustomer(user: AppUser): Promise<void> {const customer = await getStripeCustomerId(user.id);if (customer) await strent.customers.refresh({ customer });}Checkpoint
The storage exists (in Supabase, the
stripe_customerstable appears in the Table Editor) and the app still starts. Existing users have no Stripe customer yet, and that is expected. -
Check access to the paid feature
userHasProreturnsfalsewithout calling Strent when the user has no Stripe customer. Otherwise it asks Strent whether the customer has an active subscription item for the Pro product, with any price, so adding a yearly price later needs no code change.When Strent cannot answer,
userHasProthrows. The page below denies access and shows a message instead of a server error. That policy is yours: choose your own failure policy for each feature.The SDK keeps recent “yes” answers in memory: it reuses them for up to 5 minutes without asking Strent, and during an outage it serves them for up to 1 hour. That covers users this server process checked recently, not a Strent outage in general. On serverless platforms such as Vercel, each instance has its own memory, so coverage is partial.
app/export/page.tsx import { redirect } from "next/navigation";import { Suspense } from "react";import { manageBilling, subscribe } from "@/app/billing/actions";import { userHasPro } from "@/lib/billing";import { getCurrentUser } from "@/lib/current-user";export default function ExportPage() {return (<Suspense fallback={<p>Checking your plan…</p>}><ExportFeature /></Suspense>);}async function ExportFeature() {const user = await getCurrentUser();if (!user) redirect("/auth/login");let hasPro: boolean;try {hasPro = await userHasPro(user);} catch (error) {console.error("Could not verify the Pro plan", error);return <p>We couldn't verify your plan, try again in a moment.</p>;}if (!hasPro) {return (<form action={subscribe}><p>Export PDF is a Pro feature.</p><button type="submit">Subscribe</button></form>);}return (<div><button type="button">Export PDF</button><form action={manageBilling}><button type="submit">Manage billing</button></form></div>);}app/billing/actions.ts "use server";import { redirect } from "next/navigation";import { createCheckoutUrl, createPortalUrl } from "@/lib/billing";import { getCurrentUser } from "@/lib/current-user";export async function subscribe() {const user = await getCurrentUser();if (!user) redirect("/auth/login");redirect(await createCheckoutUrl(user));}export async function manageBilling() {const user = await getCurrentUser();if (!user) redirect("/auth/login");redirect(await createPortalUrl(user));}Add three server routes for a signed-in user, sending signed-out users to your login:
- Export page (
/export): calluserHasPro(user). Ontrue, show the Export PDF button and a Manage billing button. Onfalse, show a Subscribe button. When it throws, log the error, deny access, and show “We couldn’t verify your plan, try again in a moment”. - Subscribe (a
POSTfrom the Subscribe button): redirect toawait createCheckoutUrl(user). - Manage billing (a
POSTfrom the Manage billing button): redirect toawait createPortalUrl(user).
Checkpoint
Signed in as a user who never subscribed,
/exportshows “Export PDF is a Pro feature” and a Subscribe button. - Export page (
-
Sell the plan with Checkout
Stripe Checkout is a payment page Stripe hosts: your app creates a session for the customer and price, and redirects the user to it. When the user pays, Stripe creates the subscription and notifies Strent through the webhook you registered in step 2.
The Subscribe button already calls
createCheckoutUrl, which creates the Stripe customer the first time and stores its ID. The success page asks Strent to refresh the signed-in user’s stored customer, so it shows the plan without waiting for the webhook. It never trusts IDs from the URL. If the refresh fails, Stripe’s webhook still delivers the subscription, so the page asks the user to refresh instead of showing an error.app/billing/success/page.tsx import { redirect } from "next/navigation";import { Suspense } from "react";import { refreshStripeCustomer, userHasPro } from "@/lib/billing";import { getCurrentUser } from "@/lib/current-user";export default function CheckoutSuccessPage() {return (<Suspense fallback={<p>Confirming your payment…</p>}><CheckoutResult /></Suspense>);}async function CheckoutResult() {const user = await getCurrentUser();if (!user) redirect("/auth/login");try {await refreshStripeCustomer(user);} catch (error) {console.error("Strent refresh failed", error);}let hasPro = false;try {hasPro = await userHasPro(user);} catch (error) {console.error("Could not verify the Pro plan", error);}if (!hasPro) {return <p>We're confirming your payment, refresh in a few seconds.</p>;}return (<p>Your Pro plan is active. <a href="/export">Export your first PDF</a></p>);}Add a success page at
/billing/successfor the signed-in user. CallrefreshStripeCustomer(user)and log any error without failing. Then calluserHasPro(user). Ontrue, say the Pro plan is active. Onfalseor an error (log it), show “We’re confirming your payment, refresh in a few seconds”.Checkpoint
On
/export, select Subscribe and pay with the test card4242 4242 4242 4242, any future expiry date, and any CVC. The success page says your Pro plan is active, and/exportshows the Export PDF button. In Strent Admin, Customers lists the new customer with an active subscription, and its page shows your Pro product ID.If you go back from Checkout without paying and open
/exportwithin a few seconds, you may see “We couldn’t verify your plan”: Strent has not yet processed the new customer. It clears on its own. If it persists, check the webhook in step 2. -
Cancel from the Customer Portal
The Stripe Customer Portal is a page Stripe hosts where users update their card, see invoices, and cancel. With its default settings, a cancellation takes effect at the end of the paid period, so the user keeps access until then. In test mode, save the portal settings once in the Stripe Dashboard (Settings → Billing → Customer portal) before opening it.
Checkpoint
On
/export, select Manage billing and cancel the subscription. Back in your app,/exportstill shows the Export PDF button. In Strent Admin, reload the customer’s page: Scheduled changes shows the cancellation and its date. You wrote no webhook code for this. -
Optional: cancel immediately
In the Stripe Dashboard, open the subscription and cancel it immediately.
Checkpoint
Once Strent has processed the webhook, a few seconds later, reloading the customer’s page in Strent Admin shows no active subscription. While Strent answers normally,
/exportlocks Export PDF again within 5 minutes of the last confirmed “yes”: the SDK reuses a recent “yes” from memory without asking Strent. Restarting your local server clears that memory. If a feature needs immediate cutoffs, such as after a refund or fraud, create a client withcache: false.
When you grow
Section titled “When you grow”- More plans. Create each plan as its own Stripe product, and resolve the user’s plan from one list of active items instead of one check per product. See Check products and prices and Stripe’s guide to products and prices.
- Capabilities and limits shared across plans. When several plans unlock the same capability or differ in a limit such as a project count, declare what each product grants and check the entitlement instead of each product. See Check entitlements.
- A billing page. Show each user’s plan, price, and next billing date, and send them to Checkout or the Customer Portal for the right next step. See Build a billing page.
- Going live. Create the Pro plan again in Stripe’s live mode, which gives new product and price IDs. Create a Live Strent project and connect it with live credentials, then set the production environment with the live Stripe secret key, plan IDs, and
strent_live_API key.