Early SaaS apps love a local IsActive flag. It feels fast. It is also how you spend a Friday night reconciling a customer who paid, a row that still says free, and a webhook you already processed twice.
Status is a state machine, not a checkbox
Stripe subscriptions move through real states: incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid, and sometimes paused. Each one means something different for access.
incomplete is not "almost active." It means the first invoice did not clear. past_due is not canceled. Retries may still succeed. paused after a trial without a payment method is not the same as pausing collection while invoices keep generating.
If your app caches a boolean and updates it "when it feels right," you invent a second source of truth. That second source drifts.
Mirror the provider, do not invent your own
The pragmatic pattern is boring and reliable:
- Persist the provider subscription id and the raw status string.
- Derive product access from that status (and from your own entitlement rules), not from a hand-maintained flag.
- Treat the webhook as the write path that refreshes status. Treat your admin UI as a reader, not a rival ledger.
In .NET that usually means mapping Stripe's status enum into your domain once, then refusing to invent parallel enums that almost match. Almost is where the bugs live.
Webhooks without idempotency will bill you twice
Retries happen. Networks flake. Your handler will see the same event.id more than once. Without a unique constraint on that id (or an equivalent claim table), you double-apply side effects: unlock twice, email twice, or worse, mutate billing state twice.
Verify signatures against the raw body. Use idempotency keys on non-GET API calls you initiate. Keep the handler thin: update local projection, enqueue work if needed, return 200.
Domestic Stripe, global Paddle, one access model
Whether the charge landed through Stripe at home or through a Merchant of Record abroad, your product still needs one answer to "can this account use the feature today?" Keep provider-specific details at the edge. Keep entitlement logic in one place that reads a normalized status.
A code-first foundation that already wires billing webhooks, status projection, and a single entitlement check saves the afternoon you would otherwise spend debugging why production says paid and the database says free. That afternoon is always more expensive than the template looked on the pricing page.