Stripe Subscriptions and Invoicing with .NET: A Backend Guide
Building Stripe subscriptions and invoicing with .NET and Stripe.net: products, prices, Checkout, webhooks with signature checks, idempotency and the customer portal.

Taking a single payment with Stripe is an afternoon’s work. Subscriptions are different. Money moves every month whether or not anyone opens your app, cards expire, people upgrade halfway through a billing period, and your database has to agree with Stripe about who is paying for what.
For Dent Shop Manager, I built the .NET backend and integrated Stripe for payments, subscriptions and automated invoicing. This post is the general shape I’d use for any B2B-style subscription backend in .NET with Stripe.net. The code is trimmed to the essentials; check Stripe’s docs for the exact API version you’re on, because some fields have moved between versions.
The mental model
Four Stripe objects do almost all the work:
- Product: what you sell, like “Pro plan”.
- Price: how much and how often, like $X per month or $Y per year. A product can have several prices.
- Customer: who pays. One per account in your system (per shop, per company, per user, whatever your billing unit is).
- Subscription: a customer attached to one or more prices, renewing on a schedule. Each billing period, Stripe generates an Invoice, tries to collect it, and tells you what happened.
The rule that keeps all of this sane: Stripe is the source of truth for billing; your database keeps a copy of the parts your app needs. Your API checks its own database to decide what a user can do. Webhooks keep that copy current.
Step 1: products and prices live in Stripe
Create products and prices in the Stripe dashboard (or with a setup script), not on the fly in your API. Then give each price a lookup key such as pro_monthly. Your code references the lookup key, so you can change a price later by moving the key to a new price without redeploying.
Never accept a raw price ID from the client and pass it straight to Stripe. The client sends a plan name; the server maps it to a price it trusts. Same principle as in my Stripe Connect post: the app never decides what to charge.
Step 2: one Customer per account
Create the Stripe customer when the account is created, or lazily the first time someone visits billing, and store the ID:
var customer = await new CustomerService().CreateAsync(new CustomerCreateOptions
{
Email = shop.OwnerEmail,
Name = shop.Name,
Metadata = new Dictionary<string, string> { ["shopId"] = shop.Id.ToString() },
});
shop.StripeCustomerId = customer.Id;
await db.SaveChangesAsync();
The metadata is cheap insurance. When you’re looking at a customer in the Stripe dashboard during a support call, you’ll want to know which account it belongs to.
Step 3: start the subscription on the server
You have two good ways to collect a payment method and start the subscription.
Stripe Checkout is a hosted page. Your server creates a session and returns its URL; the client opens it. Stripe handles cards, 3D Secure, wallets, taxes if you enable them, and the receipt.
var session = await new Stripe.Checkout.SessionService().CreateAsync(
new Stripe.Checkout.SessionCreateOptions
{
Mode = "subscription",
Customer = shop.StripeCustomerId,
ClientReferenceId = shop.Id.ToString(),
LineItems = new List<Stripe.Checkout.SessionLineItemOptions>
{
new() { Price = priceId, Quantity = 1 },
},
SuccessUrl = "https://app.example.com/billing/success",
CancelUrl = "https://app.example.com/billing/cancel",
});
return Results.Ok(new { url = session.Url });
PaymentSheet (via flutter_stripe on mobile, or the Payment Element on web) keeps the user inside your UI. The server creates the subscription with PaymentBehavior = "default_incomplete", which leaves the first invoice unpaid, and returns a client secret for the app to confirm. Where exactly that secret lives on the invoice has changed between Stripe API versions, so follow the current subscription guide for your version rather than copying an old snippet.
I default to Checkout for web dashboards because it’s less code and Stripe keeps improving it. PaymentSheet is nicer inside a mobile app, as long as the store rules allow it (more on that below).
Either way, don’t mark anything as paid when the redirect or sheet completes. That only means the user finished the flow. Wait for the webhook.
Step 4: webhooks are where the real work happens
These are the events I handle at minimum:
| Event | What I do |
|---|---|
checkout.session.completed |
Link the session to the account (if using Checkout) |
customer.subscription.created / updated |
Sync status, price and cancel flag into the database |
customer.subscription.deleted |
Mark the subscription ended and downgrade access |
invoice.paid |
Record the invoice, extend access, send any internal notification |
invoice.payment_failed |
Flag the account, show a “fix your card” banner in the app |
Here’s the endpoint in an ASP.NET Core minimal API:
app.MapPost("/webhooks/stripe", async (HttpRequest request, BillingDb db, IOptions<StripeSettings> settings) =>
{
var json = await new StreamReader(request.Body).ReadToEndAsync();
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(
json, request.Headers["Stripe-Signature"], settings.Value.WebhookSecret);
}
catch (StripeException)
{
return Results.BadRequest();
}
if (await db.ProcessedStripeEvents.AnyAsync(e => e.Id == stripeEvent.Id))
return Results.Ok();
switch (stripeEvent.Type)
{
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
var subscription = (Subscription)stripeEvent.Data.Object;
await SyncSubscriptionAsync(db, subscription.Id);
break;
case "invoice.paid":
case "invoice.payment_failed":
var invoice = (Invoice)stripeEvent.Data.Object;
await RecordInvoiceAsync(db, invoice);
break;
}
db.ProcessedStripeEvents.Add(new ProcessedStripeEvent { Id = stripeEvent.Id });
await db.SaveChangesAsync();
return Results.Ok();
});
A few things that matter more than they look:
Signature verification. ConstructEvent checks the Stripe-Signature header against your endpoint’s signing secret. Without it, anyone who finds the URL can tell your server that they paid. Read the raw body as a string; if a JSON formatter parses it first, the signature won’t match. By default ConstructEvent also throws if the event’s API version doesn’t match the version your Stripe.net release expects, which is a common surprise after upgrading the package. Keep the webhook endpoint’s API version and the library in step.
Idempotency. Stripe retries on failure and doesn’t guarantee single delivery, so the same event can arrive twice. I store processed event IDs with a unique constraint, so even two concurrent deliveries can’t both succeed. The handlers themselves should also be safe to run twice. For outgoing calls, Stripe.net accepts a RequestOptions with an IdempotencyKey, which protects things like “create subscription” against a client retrying a timed-out request.
Ordering. Events can arrive out of order. An old updated landing after a newer one would roll your data back. That’s why SyncSubscriptionAsync takes an ID and fetches the current object instead of trusting the event payload:
static async Task SyncSubscriptionAsync(BillingDb db, string subscriptionId)
{
var sub = await new SubscriptionService().GetAsync(subscriptionId);
var shop = await db.Shops.SingleAsync(s => s.StripeCustomerId == sub.CustomerId);
shop.StripeSubscriptionId = sub.Id;
shop.SubscriptionStatus = sub.Status; // "active", "past_due", "canceled", ...
shop.StripePriceId = sub.Items.Data[0].Price.Id;
shop.CancelAtPeriodEnd = sub.CancelAtPeriodEnd;
}
One extra API call per event is a small price for never having stale billing state.
Respond fast. Return 2xx quickly. If a handler needs to send emails or generate reports, queue that work and process it in the background.
Step 5: store the state your app needs
On the account, I keep the Stripe customer ID, subscription ID, status, price (or plan) and whether it cancels at period end. Access checks then read from your own database, not from Stripe on every request:
activeandtrialing: full access.past_due: keep access for a grace period while Stripe retries the card, and show a banner.canceled,unpaid,incomplete_expired: downgrade.
Stripe’s automatic retries and failed-payment emails (configured in the dashboard) do a lot of the dunning work for you.
Invoicing comes mostly for free
Every subscription period produces an invoice. Stripe can email it to the customer, host a payment page for it, and give you a PDF link. In your app, a billing history screen can list invoices from your database (filled by invoice.paid) and link to Stripe’s hosted invoice page or PDF. You rarely need to generate invoice documents yourself for subscriptions.
Step 6: let the customer portal do self-service
Updating cards, switching plans, cancelling, downloading past invoices: Stripe’s customer portal handles all of it. Configure what’s allowed in the dashboard, then create a session on demand:
var portal = await new Stripe.BillingPortal.SessionService().CreateAsync(
new Stripe.BillingPortal.SessionCreateOptions
{
Customer = shop.StripeCustomerId,
ReturnUrl = "https://app.example.com/account",
});
return Results.Ok(new { url = portal.Url });
Changes made in the portal arrive as the same webhooks, so your sync code already handles them.
Upgrades, downgrades and proration
If you build plan changes yourself, update the existing subscription item rather than creating a second subscription:
var service = new SubscriptionService();
var sub = await service.GetAsync(shop.StripeSubscriptionId);
await service.UpdateAsync(sub.Id, new SubscriptionUpdateOptions
{
Items = new List<SubscriptionItemOptions>
{
new() { Id = sub.Items.Data[0].Id, Price = newPriceId },
},
ProrationBehavior = "create_prorations",
});
With create_prorations, Stripe credits unused time on the old price and charges for the new one on the next invoice. always_invoice bills the difference immediately, and none skips proration entirely. Pick one deliberately and tell users in the UI what will happen.
When Stripe is the wrong tool
If you sell digital features or content consumed inside a mobile app, Apple and Google generally require their own in-app purchase systems. The rules vary by region and have been changing, so check the current App Store and Google Play guidelines before you design your billing. For in-app subscriptions, I use RevenueCat on top of the store billing systems.
Stripe fits when you’re selling physical goods, real-world services, or a business tool where users subscribe on the web and simply sign in on mobile. Plenty of products need both: store billing in the app, Stripe for web or services. If you do, make your backend’s access check read one unified “entitlement” field, whichever system set it. For the backend structure around all of this, see building a .NET API for a Flutter app.
Takeaways
- Products and prices live in Stripe; your server maps plan names to trusted price IDs.
- Create Customers and Subscriptions server-side, and never trust the client redirect as proof of payment.
- Verify webhook signatures, deduplicate events, and refetch objects to avoid out-of-order updates.
- Keep a local copy of subscription status for access checks, and let Stripe handle retries and invoices.
- Use the customer portal for self-service, and be deliberate about proration.
- For digital in-app features, follow store rules and use store billing (RevenueCat makes it bearable).
Billing is the part of a product where small mistakes cost real money. If you’re adding subscriptions to a .NET backend and want a second pair of eyes on the design, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).