BUILDING A SUBSCRIPTION SYSTEM WITH STRIPE
When we introduced a subscription model to one of our partners’ platform, we made one guiding decision early on that shaped everything else: Stripe would be the single source of truth for subscriptions, payments, and entitlements.
This post walks through how that decision influenced the system’s architecture, what worked well, and what we learned along the way.
Stripe First
Instead of treating Stripe as a “payment processor” and rebuilding subscription logic internally, we leaned into what Stripe already provides: subscriptions, products, features, invoices, customer portals, and compliance. Internally, we only store the minimum:
-
A reference that links one of our users to a Stripe customer
-
A cached view of the features (entitlements) the user currently has access to
Everything else, such as active subscriptions, billing state, invoices, feature availability, is derived from Stripe.
This approach has two major benefits. First, it dramatically reduces the amount of sensitive and stateful data we need to manage ourselves. Second, it avoids a whole class of synchronization bugs where internal state slowly drifts away from what the payment provider considers “true”.
Checkout Without Reinventing Payments
The checkout flow uses Stripe-hosted pages. From the application’s point of view, this is just a redirect: the user is sent to Stripe, completes the purchase, and comes back.
Stripe handles:
-
Card validation and storage
-
Recurring payments
-
Payment failures and retries
-
Compliance requirements
Once the checkout is completed, Stripe emits a webhook event. That event is what allows our backend to connect the dots between a Stripe customer and an internal user.
To make that association reliable, we pass a stable user identifier into the checkout session metadata. When the webhook arrives, we can deterministically link the Stripe customer to the correct user without guessing. This keeps payment logic out of the frontend and avoids exposing anything sensitive to the client.
Webhooks
Rather than trying to extract partial information from individual webhook payloads, the backend uses webhooks as signals that something changed. When a relevant event arrives (subscription updates, successful payments, cancellations), we:
-
Verify the webhook signature
-
Identify the affected customer
-
Fetch the authoritative state from Stripe
-
Recalculate the user’s entitlements
-
Update the local cache
This “recalculate instead of patch” approach is intentional. Webhook events can arrive out of order and partial updates are a common source of subtle bugs. Rebuilding state from Stripe ensures consistency, even if it costs a few extra API calls.
Entitlements
One of the more impactful design choices was to model access in terms of entitlements, not plans. Instead of checking “is the user on Plan X?”, the application asks “does the user have Feature Y?”.
Each feature has:
-
A stable identifier shared between Stripe and the backend
-
A clear meaning in the application (e.g. access to a tool, a limit increase, a premium workflow)
Subscription plans are simply collections of features. This makes it much easier to evolve pricing over time. Adding, removing, or reshuffling features across plans rarely requires code changes, only configuration updates. It also keeps authorization logic simple and explicit.
Self-Service Without Custom UI
Managing subscriptions is a surprisingly complex problem once you consider upgrades, downgrades, cancellations, payment method updates, and failed payments. Rather than building and maintaining custom UI for all of this, users are redirected to a Stripe-hosted customer portal. There, they can:
-
Update payment details
-
View invoices
-
Cancel or modify their subscription
This offloads a significant amount of UX and compliance work and ensures users interact with a familiar, trusted interface.
Caching Without Losing Correctness
To avoid hitting Stripe on every request, we keep a cached representation of a user’s entitlements. However, this cache is intentionally treated as disposable. The key rule is simple: The cache is never authoritative. It can be invalidated and rebuilt at any time from Stripe data. This makes recovery trivial if something goes wrong and avoids the fear of “corrupting billing state” during deployments or incidents.
Security
Security concerns were addressed mostly through what we don’t do: no card data is ever stored or processed by our systems. Secret API keys live exclusively on the backend. All webhook events are verified using Stripe’s signatures to ensure authenticity, integrity, and freshness
By relying on Stripe’s compliance guarantees and keeping our own state minimal, the overall attack surface stays small.
Final Thoughts
The most important decision in this system wasn’t which APIs to call or how to structure the database. It was choosing what not to own.
By treating Stripe as the source of truth and keeping internal state minimal, the subscription system remains flexible, secure, and surprisingly easy to evolve. Pricing models change, features come and go, but the core architecture stays stable.
If you’re building subscriptions today, that separation of responsibilities might be the most valuable feature you ship.