A practical Stripe subscription design that uses Checkout, webhooks, the customer portal, and a small local cache.
If I add subscriptions to a software as a service (SaaS) app, I start with Stripe's hosted payment flow. I do not start with a custom payment form.
The first version needs four parts:
- Stripe Checkout starts the subscription.
- Webhooks report lifecycle changes.
- The customer portal manages plans, invoices, and payment methods.
- The app stores enough subscription data to enforce access quickly.
This design keeps payment collection in Stripe. The app remains responsible for authentication, product access, and recovery when local data differs from Stripe.
Define the integration boundaries
Create one server route for each operation.
The Checkout route creates a Checkout Session. It receives the authenticated workspace, selected plan, success URL, and cancel URL. The server maps the plan to a Stripe Price ID. Do not accept an arbitrary Price ID from the browser.
The portal route creates a customer portal session. It needs the authenticated workspace and the workspace's Stripe Customer ID.
The webhook route processes subscription events. At minimum, handle the events that your access model needs, such as:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
Verify the webhook signature before you parse or apply an event. Make each handler idempotent because Stripe can deliver the same event more than once.
Store a small local cache
I usually store these fields:
- Workspace ID
- Stripe Customer ID
- Stripe Subscription ID
- Plan tier
- Subscription status
- Current billing period end
- Cancel-at-period-end value
The local record supports access checks and billing screens. It does not replace Stripe as the source of truth for the payment lifecycle.
Reconcile before you display billing state
Most subscription bugs come from state drift. A customer changes a plan in the portal, a webhook arrives late, or the app reads stale data after a redirect. The billing page can then say "renews" while Stripe says "cancels."
Use this read path when the page needs current billing state:
- If the app has a Subscription ID, retrieve that subscription from Stripe.
- If the app has only a Customer ID, list the customer's subscriptions and select the applicable record.
- Update the local record with the Stripe response.
- Render the reconciled state.
If the state is still unclear, show the billing period end date. Do not claim that a subscription will renew or cancel until the provider state supports that claim.
Verify the complete lifecycle
Before release, test that:
- Checkout creates the expected subscription and associates it with the correct workspace.
- The webhook route rejects requests with an invalid signature.
- Duplicate webhook events do not apply the same change twice.
- The customer portal can cancel and resume a subscription when the plan permits it.
- Subscription events update the local entitlement.
- The billing page remains accurate when webhook delivery is late.
Billing text affects trust. A wrong renewal message can be worse than a visible error because the user may make a payment decision from it.
Design principle
Let Stripe manage payment collection and the subscription lifecycle. Let the app manage product access and a local cache of the provider state.
The integration is complete when each boundary has one clear job. Checkout collects payment. Webhooks synchronize lifecycle events. The portal handles customer changes. The database stores the minimum state that the product needs.