Skip to content
← Back to the blog
integrationsapps

How to add Stripe subscriptions to your app the right way

Build Stripe subscriptions that stay correct: what to store, which webhook events to handle, how to check access on the server and how to test renewals.

By Devonauts Team6 min read
Leer en español →
Hands type on a laptop while one holds a green bank card over a wooden desk

The reliable way to add Stripe subscriptions is to let Stripe own the billing state and have your app copy it through webhooks. You send the customer to a Checkout Session in subscription mode, store the Stripe customer and subscription IDs against your user, and update a small subscriptions table every time Stripe sends a subscription or invoice event. Your app then decides access by reading that table on the server, never by trusting the success page.

This guide covers why the success page is the wrong signal, what to store, which events to handle, how to check access, and how to test a renewal and a failed payment before real customers find the gaps.

Why shouldn't the success page unlock the paid plan?

Most AI-built apps do it this way first: after Checkout redirects back to /success, the page calls the database and marks the user as "pro". It works in a demo and breaks in production for three reasons.

  • The customer can close the tab before the redirect. They paid, but your app never heard about it.
  • Anyone can open /success directly. If that page grants access, it is a free upgrade button.
  • The first payment is only the beginning. Renewals, failed cards, plan changes and cancellations all happen later, with no browser involved. Only webhooks tell you about them.

Stripe's own documentation says a subscription integration needs a webhook destination, because most subscription activity happens asynchronously. Treat the success page as a "thanks, we're setting things up" screen and let the webhook do the work.

What should you store in your database?

Keep a small table that mirrors Stripe. You don't need to copy everything.

ColumnWhere it comes fromWhy you need it
user_idYour auth systemLinks the row to a person
stripe_customer_idCustomer created before CheckoutFinds the user when an event arrives
stripe_subscription_idcustomer.subscription.* eventsOne row per subscription
statusSubscription objectactive, trialing, past_due, canceled, unpaid, etc.
price_id or lookup keyFirst subscription itemTells you which plan they're on
current_period_endSubscription itemWhen the paid period ends
cancel_at_period_end / cancel_atSubscription objectShows "your plan ends on…"

Two details trip people up. On recent API versions the billing period dates live on the subscription items, not on the subscription itself, so code copied from an older tutorial may read an empty field. And for subscriptions using the newer flexible billing mode, Stripe signals a scheduled cancellation with cancel_at, while classic billing mode uses cancel_at_period_end.

Create the Stripe customer yourself before opening Checkout, save its ID on the user, and pass it to the session. Matching users by email later is fragile: people pay with a different address than they signed up with.

Which webhook events do you actually need?

You can subscribe to dozens of events. These cover a typical SaaS plan:

  1. checkout.session.completed: the customer finished Checkout. Read the subscription ID from the session and create your row.
  2. customer.subscription.created, customer.subscription.updated, customer.subscription.deleted: copy status, plan, period end and cancellation fields on every change. deleted means the subscription has ended.
  3. invoice.paid: a renewal was paid. Stripe recommends retrieving the subscription and confirming its status is active before extending access.
  4. invoice.payment_failed: a payment failed. Tell the customer; don't cut access yet if your retry settings will try again.
  5. customer.subscription.trial_will_end: sent 3 days before a trial ends, a good moment to check there is a payment method on file.

If you also want to react to refunds, disputes and fraud warnings, add charge.refunded, charge.dispute.created and radar.early_fraud_warning.created. Our guide on refunds and disputes in Stripe explains what to do with them.

How should the handler be written?

The simplest robust pattern is "on any subscription event, fetch the subscription from Stripe and overwrite my row". That makes order irrelevant: Stripe doesn't guarantee events arrive in order, and it can deliver the same event twice. Fetching the current object means a late updated event can't overwrite newer data with older data.

Three more rules:

  • Verify the signature with the raw request body before doing anything. If that step fails, see Stripe webhook signature verification failed.
  • Store processed event IDs and skip ones you've already handled.
  • Return a 2xx quickly and do slow work afterwards, or Stripe treats the delivery as failed and retries it.

How do you check access on the server?

Every request that touches a paid feature should ask your backend one question: does this user have a row with an allowed status and a period that hasn't ended? A minimal version looks like this:

select exists (
  select 1 from subscriptions
  where user_id = auth.uid()
    and status in ('active', 'trialing')
) as has_access;

Decide on purpose what past_due means for you. Many SaaS products keep access during the retry window and show a banner asking the customer to update their card; others restrict at once. Stripe's docs suggest revoking access when the status becomes canceled or unpaid. We go deeper on this in limiting features by plan on the server, including why hiding a button in React is not a paywall.

How do you test renewals before going live?

Waiting a month for a renewal is not a test plan. In a sandbox you have two tools:

  1. Test clocks. Stripe's docs now call them Simulations. Attach a test customer to a clock, subscribe them, then advance time past the renewal date and watch the invoice, the payment and your webhook handler respond.
  2. The Stripe CLI. Run stripe listen --forward-to localhost:3000/api/stripe/webhook to send events to your machine, and stripe trigger to fire specific events on demand.

Test at least these paths: a successful first payment, a renewal, a renewal with a card that declines, a plan upgrade, and a cancellation at period end. Stripe's test cards include numbers that decline, so you can watch past_due happen without touching a real card.

Quick answers

Should I use Checkout or build my own form for subscriptions? For most small SaaS products, Checkout. It handles trials, taxes, discounts and payment methods for you. Our Checkout vs Payment Element comparison covers when a custom form is worth it.

Is checkout.session.completed enough on its own? No. It only tells you about the first purchase. Renewals, failures and cancellations come through subscription and invoice events.

Can I store the plan in user metadata instead of a table? You can, but a table with status and period end is easier to query, audit and fix when something goes wrong.

What if my webhook was down for a day? Stripe retries failed deliveries for up to three days in live mode. You can also list recent events through the API and reprocess the ones you missed.

Want someone to check your subscription setup?

If customers are being charged but your app's plan status keeps drifting, the cause is almost always in the webhook handler or the access check. Our Stripe payments service covers exactly this. You can start with the free app health check, read the Stripe subscription webhook docs, or tell us what's happening and a person will reply within one business day.

Still stuck with your app?

Run the free health check on your app's URL, or get the $299 App Rescue Audit: a senior engineer reviews the code and sends you a written report in 2 business days.