Blog
5 min read

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, trialing and (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