Adding Subscription Billing to a SaaS Product with Stripe and .NET
Stripe subscription billing in a .NET backend works well once you treat webhooks — not your own API calls — as the source of truth for subscription state. The most common mistake I see in SaaS billing integrations is updating a user's subscription status directly after the checkout API call succeeds, instead of waiting for and processing Stripe's webhook events, which is what actually keeps your database in sync with reality over the life of a subscription.
Why Webhooks Are the Source of Truth, Not Your API Calls
A successful checkout session response tells you the checkout flow completed — it doesn't tell you what happens next: a payment that fails on retry, a card that gets declined at renewal, a customer who cancels through Stripe's customer portal, or a dispute that gets filed weeks later. All of these change subscription state, and none of them originate from a call your own backend made. Stripe communicates every one of them through webhook events, which is why webhook handling — not the initial checkout call — is where the real subscription-state logic belongs.
The Architecture: Webhook Endpoint, Signature Verification, Idempotent Processing
The .NET side needs a dedicated webhook endpoint that verifies Stripe's signature on every incoming request (Stripe signs each webhook payload, and skipping verification means accepting unauthenticated requests that claim to be Stripe). Once verified, each event type — checkout.session.completed, invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted, and a handful of others — gets its own handler that updates the relevant subscription record.
Idempotency matters more than it initially seems like it should: Stripe can and does deliver the same webhook event more than once (network retries, at-least-once delivery guarantees). Handlers need to be written so that processing the same event twice doesn't double-charge internal state or send a duplicate "your payment succeeded" email — storing the Stripe event ID and checking whether it's already been processed before acting on it is the standard, reliable fix.
Key Events Worth Handling Deliberately
checkout.session.completed — Confirms a new subscription started; this is where you provision access, not before.
invoice.payment_succeeded — Confirms a renewal payment went through; useful for logging and for resetting any "payment failed" warning state from a previous cycle.
invoice.payment_failed — The event most SaaS products under-handle. This is where you'd trigger a dunning email sequence and, after a defined grace period with continued failures, actually restrict access — not immediately on the first failed attempt, since Stripe's own retry schedule often recovers a temporarily failed card.
customer.subscription.updated — Covers plan changes (upgrades/downgrades) and needs to correctly prorate or adjust access based on the new plan tier.
customer.subscription.deleted — The subscription has actually ended (whether by cancellation or exhausted retries) — this is where access actually gets revoked, distinct from a cancellation request, which Stripe handles as a scheduled cancellation at period end by default.
The Edge Case That Trips Up Most Implementations
Handling "cancel" correctly is the single most common mistake. When a customer cancels, Stripe's default behavior is to keep the subscription active until the end of the current billing period, then cancel it — it does not immediately end access. An implementation that revokes access the moment a cancellation request comes in, rather than waiting for the subscription to actually end at period close, gives customers less than what they paid for and generates avoidable support complaints.
FAQ
Do I need to store credit card details myself?
No, and you shouldn't — Stripe handles card storage and PCI compliance entirely on their side. Your backend only ever deals with Stripe customer IDs, subscription IDs, and webhook events, never raw card data.
How do I test webhook handling before going live?
Stripe's CLI includes a webhook forwarding tool that sends real test events to your local development server, which is the standard way to test the full flow — including failure and edge-case events — before deploying.
What happens if my webhook endpoint is down when an event fires?
Stripe retries webhook delivery on a backoff schedule for a period of time, so brief downtime doesn't lose events — but building idempotent handlers (checking the Stripe event ID before acting) is still essential since retries mean the same event may arrive more than once.
If you're adding subscription billing to a .NET SaaS product and want it to handle these edge cases correctly from day one, let's talk.
Found this useful? Share it with your network.
Comments
Leave a comment