SaaS
Stripe subscription billing for SaaS: webhooks, plan limits and what goes wrong
Taking the first payment is easy. Keeping access correct when cards fail, plans change and events arrive out of order is the real work.
By Ahmad AffanUpdated 3 min read

Key takeaways
- Let Stripe’s webhooks, not the browser redirect, decide who has access.
- Verify the Stripe-Signature header using the raw request body and your endpoint secret.
- Stripe does not guarantee event order and can retry events, so make handlers idempotent and fetch current state when in doubt.
- Enforce plan limits on the server from your own record of the subscription.
- Plan for failed payments, upgrades with proration and cancellations before launch.
The checkout page is the easy part of subscription billing. The harder part starts afterwards: a card expires, a customer upgrades mid-month, a webhook arrives twice, or two events arrive in the wrong order. If your app decides access in the wrong place, you either give away the product or lock out paying customers.
I built this layer for TelegramCopy, where Stripe subscriptions set how many trading accounts a customer can connect, and the server enforces that limit. The principles below come from that kind of work and from Stripe’s own documentation.
Make webhooks the source of truth
After a customer pays, Stripe sends the browser back to your success page. That redirect is for the customer’s comfort. It can be closed, blocked or forged, so never grant access from it. Grant access from a verified webhook, and keep your own copy of the subscription state in your database.
The events that matter
| Event | What it means | Typical action |
|---|---|---|
| invoice.paid | An invoice was paid | Provision or extend access when the subscription is active |
| invoice.payment_failed | A payment attempt failed | Notify the customer and start your recovery flow |
| customer.subscription.updated | Plan, status or discount changed | Update the stored plan and recalculate limits |
| customer.subscription.deleted | The subscription ended | Revoke paid access at the right moment |
| customer.subscription.trial_will_end | Trial ends in about three days | Remind the customer to add a payment method |
Verify every webhook
Stripe signs each event. Check the Stripe-Signature header using the official library, the raw request body and your endpoint’s secret. A common bug is a web framework that parses and re-serialises the JSON before you verify it, which changes the bytes and makes valid signatures fail. Read the raw body first. The libraries also reject events with old timestamps, which helps against replay attacks.
Assume duplicates and disorder
Stripe retries events when your endpoint does not return a success status, and it does not guarantee delivery in the order events were created. Two habits keep you safe:
- Store processed event IDs with a unique constraint and skip any you have already handled.
- Fetch current state from the API when an event depends on an object you have not seen yet, instead of assuming order.
- Respond quickly with a success status, then do slow work, such as emails and provisioning, in a background job.
Enforce plan limits on the server
A plan is only real if the backend checks it. Store the plan and its limits next to the customer record, and check them whenever a user tries to add a seat, an account or a record. Do not rely on hiding a button. When a customer changes plan, Stripe handles proration on the invoice, and your webhook updates the limits. Decide in advance what happens if someone downgrades below their current usage: block the downgrade, or give a grace period.
Failed payments are normal
Cards expire and banks decline. Turn on Stripe’s automatic retry and recovery features, send a clear email when payment fails, and decide how long access continues while you retry. Showing a banner inside the product (“update your card”) usually recovers more revenue than a silent lockout.
Pre-launch checklist
- Webhook endpoint verifies signatures and rejects stale timestamps.
- Processed event IDs are stored, so retries are harmless.
- Access is derived from your stored subscription state.
- Plan limits are enforced in the API, with tests.
- Failed-payment, cancellation and refund flows are tested in Stripe’s test mode.
- A customer portal link lets people update cards and cancel without emailing you.
- An admin screen shows each customer’s plan, status and last events.
If you are adding billing to a product or building one from scratch, this is part of my SaaS development service. I can also review an existing integration.
Sources
Checked on 28 October 2026. Prices and versions change, so confirm with the vendor before you commit.
Frequently asked questions
For subscriptions, provision access when you receive invoice.paid and the subscription status is active, then keep the stored plan in sync with customer.subscription.updated.
Most often because the request body was modified before verification. Stripe needs the raw, unmodified body, the Stripe-Signature header and the correct endpoint secret.
Yes. Events can be retried and delivered out of order, so store processed event IDs and make handlers safe to run more than once.
In your backend, from your own stored subscription record, at the moment a user tries to exceed a limit. Hiding buttons in the UI is not enforcement.

