When building SaaS payments, we tend to focus on "how do I get paid." But the thing that actually keeps me up at night is a different question — when a subscription expires, how do I make sure the access is always revoked?
Miss granting credits, users come knocking. Miss revoking access, you lose money. Neither side can afford mistakes.
Over 2 months of building payment flows, I tore this problem apart. The result is a 3-layer defense, 1 passive + 2 active fallbacks.
Let me walk through why this was necessary and the pitfalls I hit along the way.
01
The Passive Layer — Webhook Notifications
When it comes to subscription expiry, we're not in the driver's seat.
Users pay through Stripe/PayPal/Creem — whether they renewed, cancelled, or expired — all these state changes are controlled entirely by the payment platform. All we can do is wait for the notification.
That's what webhooks are for. Each payment platform fires an event to our server the instant a state change happens. If we receive it, we handle it. If we don't, we're in the dark.
Sounds straightforward, but subscription states are way more complex than you'd expect.
- User cancelled, but the period hasn't ended yet (cancelling)
- Period ended, subscription terminated (expired)
- Renewal succeeded, period refreshed (renewed)
- Payment failed, entered grace period (past due)
- Upgraded, takes effect immediately
- Downgraded, takes effect next billing cycle
Every single state change fires a different event type. Miss handling one, and the consequences — well, let's just say someone always ends up on the losing side.
I tripped up here early on. Take "cancel at period end" for example — Stripe actually sends two separate notifications. The first one says "the user initiated cancellation," and the second one, only after the billing period ends, says "the subscription is actually terminated." If you revoke access on that first notification, users get angry — they paid for this month.
Figuring out how to handle the gap between those two steps took me several iterations. And on top of that, Stripe, PayPal, and Creem all have different event names and timing sequences — unifying them was another hurdle entirely.
Webhooks are the workhorse for 99% of scenarios. Stripe, PayPal — these platforms have been at it for years, reliability is solid, and they have built-in retry mechanisms. But —
It's not 100%.
Network hiccups, server restarts, deployment windows, the occasional Supabase timeout... any of these can cause a webhook to be lost or fail to process. The probability is low, but in SaaS, "low" doesn't mean "never."
So you need fallbacks.
02
The Active Layer — Two Fallbacks
Fallback One — Scheduled Sweeps
Webhook lost, user not visiting — in this scenario, the status in the database just stays wrong. No one notifies, no one triggers, the subscription stays "active" forever.
And this isn't just a "data inaccuracy" issue. Your dashboard shows inflated active subscriber counts — making decisions based on that is lying to yourself.
What's worse, if other parts of your system read that status — say, marketing emails — everything downstream is wrong too. One stale expired subscription can contaminate an entire chain of data.
So I added a scheduled sweep that periodically scans and expires what needs to be expired.
Sounds simple, but in practice you'll find that just updating the status isn't enough — related data has to be cleaned up together, otherwise the numbers won't match up when the user visits next. And when you're calculating revenue, the books won't balance either, right?
How much a single sweep needs to handle, how to avoid accidentally killing a subscription that just successfully renewed — those are the real pitfalls.
Fallback Two — Lazy-Load Self-Healing
Scheduled tasks have delays. Webhooks can get lost. But there's one moment that's guaranteed — the user shows up.
The instant a user visits, I run a real-time check. If the data is off, it gets corrected on the spot. The user notices zero latency, but the state is already accurate.
This is the last line of defense. No matter how the first two layers fail, when the user arrives, it's always correct.
And in practice, you'll find that what needs checking at this moment goes far beyond just "has the subscription expired." Time-dependent state changes are way more numerous than you'd think.
03
Why Three Layers, Not One?
You might ask — webhooks are so reliable, why make it this complicated?
Because you don't gamble with payments.
Webhooks are passive — if the third party doesn't notify, we don't know. Scheduled tasks have delays — there's a window between sweeps. Lazy-loading has prerequisites — the user has to actually visit.
Each layer has its weakness, but combined, they form a closed loop.
| Layer | Type | Trigger | Coverage |
|---|---|---|---|
| Webhook | Passive | Payment platform push | 99% of normal cases |
| Scheduled Task | Active | Periodic sweep | Fallback when webhook lost & user absent |
| Lazy-Load | Active | On user visit | Last line of defense — always accurate |
The scariest thing in a payment system isn't a bug — bugs can be found and fixed. The scariest thing is silent failure, where something is wrong and you don't even know it, until one day you reconcile the numbers and nothing adds up.
The point of 3-layer defense isn't that every layer gets used constantly — it's that no matter which link breaks, something is always there to catch it.
This is the complete approach I use in pay4saas for handling subscription expiry. Webhooks, scheduled tasks, lazy-loading — three layers stacked together, ensuring that from subscription to cancellation, from renewal to expiry, every single state change is accounted for.
If you're building SaaS monetization and don't want to stumble through payment pitfalls, pay4saas — the holes are already filled. So you can collect payments with peace of mind.