Stripe Subscriptions Explained: Products, Prices, Webhooks and Access
How Stripe Billing models subscriptions — customers, products, prices, subscriptions and invoices — which webhooks to handle, what each subscription status means for access, trials, upgrades and proration, failed payments and dunning, and the Customer Portal.
Taking a one-off payment is one event. A subscription is a relationship that changes over months: trials end, cards expire, customers upgrade, payments fail and recover. Your app needs to know, at any moment, whether this user should have access — and Stripe tells you through webhooks.
(Starting out? Stripe Payment Links vs Checkout and adding Stripe to an AI-built app cover the basics.)
The objects
| Object | What it is | Example |
|---|---|---|
| Customer | The payer | cus_… linked to your user |
| Product | What you sell | "Pro plan" |
| Price | How much and how often | $20/month, $200/year (two prices, one product) |
| Subscription | A customer on one or more prices | sub_…, status active |
| Invoice | A bill for one period | Created each cycle; paid or not |
| PaymentIntent | One attempt to collect money | Behind each invoice payment |
Store the Stripe customer ID on your user and the subscription ID, status, price and current period end in your database. Your app checks your database; webhooks keep it in sync.
Creating subscriptions: use Checkout
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: user.stripeCustomerId, // create the customer once, reuse it
line_items: [{ price: 'price_pro_monthly', quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${APP_URL}/billing?success=1`,
cancel_url: `${APP_URL}/pricing`,
})
Checkout handles card entry, 3-D Secure, taxes (with Stripe Tax), coupons and wallets.
The webhooks that matter
Don't grant access on the success page — grant it from verified webhooks. (Handle webhooks reliably)
| Event | Do |
|---|---|
checkout.session.completed |
Link the subscription to your user; store IDs |
customer.subscription.created / .updated |
Sync status, price, current_period_end, cancel_at_period_end |
customer.subscription.deleted |
Subscription ended — remove access |
invoice.paid |
Period paid — extend access, record payment |
invoice.payment_failed |
Payment failed — notify the user; status will change |
customer.subscription.trial_will_end |
Remind them ~3 days before the trial ends |
A robust pattern: on any subscription-related event, fetch the current subscription from Stripe and overwrite your copy. Events can arrive out of order or twice; fetching the latest state makes your handler idempotent and order-independent. (Idempotency keys)
Statuses and what they mean for access
| Status | Meaning | Access? |
|---|---|---|
trialing |
In free trial | Yes |
active |
Paid and current | Yes |
past_due |
Latest payment failed; Stripe is retrying | Usually yes, with a warning (grace period) |
unpaid |
Retries exhausted; subscription kept open | No (your choice) |
canceled |
Ended | No |
incomplete |
First payment needs action (e.g. 3-D Secure) | No |
incomplete_expired |
First payment never completed | No |
paused |
Paused (e.g. trial ended without payment method) | No |
A simple access rule:
const hasAccess =
['active', 'trialing', 'past_due'].includes(sub.status) &&
sub.currentPeriodEnd > new Date()
Cancellations
Most apps cancel at period end: the customer keeps access until the end of what they paid for. Stripe sets cancel_at_period_end: true, the status stays active until then, and customer.subscription.deleted fires when it ends. Show "Your plan ends on 14 November" in the UI.
Upgrades, downgrades and proration
Changing the price on a subscription mid-cycle triggers proration by default: credit for unused time on the old price, charge for the remaining time on the new one. Options:
- Prorate and invoice immediately (common for upgrades).
- Prorate onto the next invoice.
- No proration, change at renewal (common for downgrades — schedule it with a subscription schedule).
Pick a policy and state it on your pricing page. (How to price your SaaS)
Failed payments and dunning
Cards expire and get declined; this is normal, not exceptional. Turn on Stripe's Smart Retries and failed-payment emails in Billing settings, and let customers update their card via the portal. Decide how long past_due keeps access (a week is common), then restrict.
The Customer Portal
Stripe's hosted Customer Portal lets customers update payment methods, switch plans, view invoices and cancel — configured in the dashboard, opened with one API call:
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${APP_URL}/billing`,
})
// redirect to portal.url
It saves you building most of a billing UI. Changes made there arrive as the same webhooks.
Testing
Use test mode, test cards (including ones that decline or require authentication), stripe trigger for events, and test clocks to simulate a trial ending or a renewal months ahead without waiting. (Test webhooks locally)
Usage-based billing
For metered pricing (per seat, per API call, per AI token), Stripe supports usage-based prices with meters: you report usage events and Stripe bills them each period. Useful for AI features where costs scale with use. (Claude API pricing)
The summary
- Customer → Subscription → Prices (of Products) → Invoices each period.
- Create with Checkout; sync state from verified webhooks, fetching the latest subscription each time.
- Grant access for
active,trialingand (briefly)past_due. - Cancel at period end; pick a proration policy; enable Smart Retries.
- Use the Customer Portal instead of building billing screens.
EasySpawn runs your backend as an always-on server, so Stripe webhooks always have a reliable endpoint — with your Postgres database on the same machine to record them. See how it works or join the waitlist.
Related: Stripe Payment Links vs Checkout · Handling Webhooks Reliably · Stripe vs Paddle vs Lemon Squeezy · How to Price Your SaaS
Keep reading
Handling Webhooks Reliably: Signatures, Idempotency, and Retries
Webhooks arrive late, twice, out of order, or from someone pretending to be Stripe. How to verify signatures against the raw body, acknowledge fast and process in the background, make handlers idempotent, cope with ordering, and test the whole thing locally.
TanStack Query vs useEffect for Data Fetching in React
Fetching in useEffect looks simple until you need loading states, errors, caching, race conditions, refetching and mutations. What TanStack Query handles for you, side-by-side code, mutations with invalidation, and when server components or plain useEffect are still the right choice.