Mitc.Integrations.Stripe 4.0.0
Mitc.Integrations.Stripe
A turnkey Stripe integration library for ASP.NET Core applications. Handles webhook processing, product/price catalog mirroring, missed-event backfill, subscription management, checkout session creation, and metered usage reporting.
Design Philosophy
Where Stripe already provides a feature through its customer portal or dashboard, the integration delegates to Stripe rather than reimplementing it. Subscription cancellation and upgrades, payment-method updates, invoice history, the product/price catalog, promotion codes, and tax configuration all live in Stripe's own surfaces — this library mirrors and reacts to that state instead of rebuilding management UI on top of it. The catalog is seeded from Stripe, webhooks keep the local mirror in sync, and entitlement lookups read the synced state; the source of truth stays in Stripe. This keeps the library's surface small, avoids duplicating workflows Stripe maintains (and secures) for us, and means features added on the Stripe side require no code change here.
When your application needs to direct a user to one of these Stripe-managed features, send them to the customer portal via IStripeOperations.CreateCustomerPortalSessionAsync. It returns a short-lived portal URL to redirect to:
// Pass the customer id you captured at fulfillment (see Entitlement Lookup),
// or a StripeSubscription via the convenience overload.
var portalUrl = await operations.CreateCustomerPortalSessionAsync(user.StripeCustomerId, returnUrl);
// redirect the user to portalUrl; Stripe returns them to returnUrl when they're done.
The portal must be configured and activated in the Stripe dashboard first (Settings → Billing → Customer portal). Until a configuration is saved there, Stripe rejects session creation — which reaches you as a thrown StripeException, per the checkout failure contract. To surface that misconfiguration before a user ever hits it, the library checks for an active default portal configuration at startup and logs an informational line when none is found (see Background Services).
Supported Frameworks
- .NET 9
- .NET 10
Setup
1. Configuration
Add a StripeOptions section to your appsettings.json:
{
"StripeOptions": {
"ApiKey": "sk_...",
"EndpointSecret": "whsec_...",
"RegistrationCompletedPage": "https://example.com/registration/complete",
"RegistrationCancelledPage": "https://example.com/registration/cancelled",
"AllowLiveEvents": true,
"AllowTestEvents": false,
"EnableEventReconciliation": true,
"ReconciliationInterval": "01:00:00",
"ReconciliationLookback": "1.00:00:00",
"EnableUsageReporting": true,
"ReportFrequency": "01:00:00",
"EnableLocalWebhook": true,
"ProcessingDelay": "00:00:05",
"ProcessingInterval": "00:00:01",
"EventCleanupInterval": "1.00:00:00",
"EventRetentionPeriod": "30.00:00:00",
"MaxEventRetries": 3,
"EventRetryDelay": "00:00:20"
}
}
| Option | Description |
|---|---|
ApiKey |
Stripe secret API key. Required. |
EndpointSecret |
Stripe webhook signing secret. Required. |
RegistrationCompletedPage |
URL the customer is redirected to after checkout. Required. |
RegistrationCancelledPage |
URL the customer is redirected to if they cancel checkout. Required. |
AllowLiveEvents |
Accept events from Stripe live mode. At least one of AllowLiveEvents / AllowTestEvents must be true. |
AllowTestEvents |
Accept events from Stripe test mode. |
EnableEventReconciliation |
Enables periodic backfill of webhook events missed by delivery (endpoint misconfiguration, outages beyond Stripe's retry window). |
ReconciliationInterval |
How often the reconciler compares Stripe's event stream against the local table. Required when reconciliation is enabled. |
ReconciliationLookback |
How far back each pass looks. Must be strictly shorter than EventRetentionPeriod and at most 30 days. |
EnableUsageReporting |
Enables periodic reporting of subscriber usage to Stripe. When false, ReportFrequency is not required. |
ReportFrequency |
How often metered usage is reported to Stripe. Required when EnableUsageReporting is true. |
EnableLocalWebhook |
Enables local webhook event processing. When false, ProcessingDelay and ProcessingInterval are not required. |
ProcessingDelay |
Grace period before processing an event, allowing out-of-order events to arrive. Required when EnableLocalWebhook is true. |
ProcessingInterval |
How often the event processor drains and processes queued events. Required when EnableLocalWebhook is true. |
EventCleanupInterval |
How often old processed events are cleaned up from the database. |
EventRetentionPeriod |
How long processed events are retained before deletion. |
AllowPromotionCodes |
Whether or not promotional codes are allowed during checkout. |
EnableAutomaticTax |
Enables Stripe automatic tax calculation on checkout sessions. Requires Stripe Tax to be enabled on the account. |
WebhookConfigurationMode |
Startup check of the dashboard webhook endpoint: Off, Validate (log findings), or Heal (also add missing event types). Skipped when EnableLocalWebhook is true. Default: Validate. |
WebhookEndpointUrl |
Exact URL of this app's webhook endpoint in the Stripe dashboard. When unset, the check matches endpoints whose URL ends with /stripe/webhook. Set it when several apps share one Stripe account. |
2. Register Services
builder.SetupStripeIntegration<AppDbContext>();
The generic type parameter TDbContext must be your application's DbContext type. This allows the default store implementations to resolve your concrete DbContext from DI.
This registers all required services, background workers, and a webhook controller endpoint at POST /stripe/webhook.
3. EF Core Entity Configuration
Implement IStripeContext on your DbContext and call ConfigureStripeEntities in OnModelCreating:
public class AppDbContext : DbContext, IStripeContext
{
public DbSet<StripeEvent> StripeEvents { get; init; }
public DbSet<StripeProduct> StripeProducts { get; init; }
public DbSet<StripePrice> StripePrices { get; init; }
public DbSet<StripeCheckoutSession> StripeCheckoutSessions { get; init; }
public DbSet<StripeSubscription> StripeSubscriptions { get; init; }
public DbSet<StripeSubscriptionItem> StripeSubscriptionItems { get; init; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ConfigureStripeEntities();
}
}
ConfigureStripeEntities configures string primary keys (Stripe-assigned, not auto-generated), StripeMetadataValueConverter on metadata properties, and navigation relationships (StripeProduct → StripePrice, StripeSubscription → StripeSubscriptionItem).
4. Implement Required Interfaces
Checkout session event handler — No default implementation is provided; you must supply one:
public class MyCheckoutHandler : IStripeCheckoutSessionEventHandler
{
public Task<Outcome> HandleCheckoutSessionCompleted(Session session) { /* ... */ }
public Task<Outcome> HandleCheckoutSessionExpired(Session session) { /* ... */ }
// Async (delayed) payment methods fire async_payment_succeeded / async_payment_failed.
// Both default to a no-op success, so only override them if you accept such methods:
// public Task<Outcome> HandleCheckoutSessionAsyncPaymentSucceeded(Session session) { /* ... */ }
// public Task<Outcome> HandleCheckoutSessionAsyncPaymentFailed(Session session) { /* ... */ }
}
Usage report generator — Required by the usage reporting service. Return one SubscriberUsage per active subscription item. Only include active, non-cancelled subscriptions; cancelled or ended subscriptions should be filtered out by your query:
public class MyUsageReportGenerator : IStripeUsageReportGenerator
{
public Task<IEnumerable<SubscriberUsage>> GetUsagesAsync() { /* ... */ }
}
Customizing Services
Default store implementations are provided and registered automatically. Custom stores are only needed if you require behavior beyond the defaults. The default stores expose virtual members, so you can inherit one and override only the methods you need:
public class MyStripeEventStore(AppDbContext db) : StripeEventStore(db);
public class MyStripeProductStore(AppDbContext db) : StripeProductStore(db);
public class MyStripePriceStore(AppDbContext db) : StripePriceStore(db);
public class MyStripeSubscriptionStore(AppDbContext db) : StripeSubscriptionStore(db);
Or implement the store interfaces (IStripeEventStore, IStripeProductStore, etc.) directly for full control.
Use the builder callback to register custom stores or event handlers:
builder.SetupStripeIntegration<AppDbContext>(stripe => stripe
.AddEventStore<MyStripeEventStore>()
.AddProductStore<MyStripeProductStore>()
.AddPriceStore<MyStripePriceStore>()
.AddSubscriptionStore<MyStripeSubscriptionStore>()
.AddCheckoutSessionEventHandler<MyCheckoutHandler>()
);
Builder overrides are registered before the library's defaults, so TryAddScoped will skip any service you've already configured.
Available Builder Methods
Stores (scoped, depend on DbContext):
| Method | Interface |
|---|---|
AddEventStore<T>() |
IStripeEventStore |
AddProductStore<T>() |
IStripeProductStore |
AddPriceStore<T>() |
IStripePriceStore |
AddSubscriptionStore<T>() |
IStripeSubscriptionStore |
Event handlers (scoped):
| Method | Interface |
|---|---|
AddCheckoutSessionEventHandler<T>() |
IStripeCheckoutSessionEventHandler |
AddProductEventHandler<T>() |
IStripeProductEventHandler |
AddPriceEventHandler<T>() |
IStripePriceEventHandler |
AddSubscriptionEventHandler<T>() |
IStripeSubscriptionEventHandler |
AddUsageReportGenerator<T>() |
IStripeUsageReportGenerator |
Sources (singleton):
| Method | Interface |
|---|---|
AddCatalogSource<T>() |
IStripeCatalogSource |
AddEventSource<T>() |
IStripeEventSource |
AddWebhookEndpointSource<T>() |
IStripeWebhookEndpointSource |
AddBillingPortalSource<T>() |
IStripeBillingPortalSource |
AddStartupCheck<T>() |
IStripeStartupCheck |
Architecture
Event Processing Pipeline
Stripe webhook
-> StripeController (POST /stripe/webhook)
-> IStripeEventParser (signature verification)
-> Event.IsForEnabledMode (live/test filter; ignored events are acked and dropped)
-> IStripeEventStore.AddAsync (idempotent durable insert — 200 OK only after the row commits)
StripeEventProcessor (background service, periodic)
-> IStripeEventStore.GetProcessableEventsAsync (settled + due events)
-> IStripe*EventHandler (handle by type, in dependency order)
Events are persisted at intake and become processable only after they have aged past ProcessingDelay (the settle window, keyed off arrival time). This lets a burst of out-of-order events finish arriving before the processor sorts and applies them by (Created, tier, id). Dedup is a primary-key guarantee: a redelivered event id is swallowed on insert.
Because every event is durable before the webhook is acked, ProcessingDelay is purely a query delay rather than an in-memory hold — it can be lengthened to absorb larger or more out-of-order bursts with no added risk of data loss.
Background Services
| Service | Responsibility |
|---|---|
StripeEventProcessor |
Reads settled, due events from the store and dispatches them to handlers in dependency order. |
StripeEventCleanupService |
Deletes processed events older than EventRetentionPeriod. |
StripeCatalogSeedingService |
Converges the local product/price mirror with Stripe once per boot, before the server accepts traffic. Stale local rows are deactivated, never deleted. Call StripeCatalogSeeder.SeedAsync directly to heal drift between boots. |
StripeStartupCheckService |
Runs every registered IStripeStartupCheck once at boot (after catalog seeding). Logs each check's findings — or a "passed" line when a check reports none — and never blocks startup. The library ships a webhook-configuration check and a customer-portal check; add your own via AddStartupCheck<T>(). |
StripeEventReconciliationHostedService |
(Opt-in via EnableEventReconciliation) Periodically backfills webhook events missing from the local table; backfilled events flow through the normal pipeline. |
StripeUsageReportingService |
Reports metered subscription usage to Stripe. |
Recovery semantics
Stale snapshot events (product/price/subscription) are skipped when a newer event for the
same object has already been processed — applying an older point-in-time snapshot would regress
local state. If you replace a snapshot handler with one that performs side effects, be aware
that superseded events are marked processed without invoking your handler. Checkout-session
events are never skipped: each type carries a distinct side effect, so a backfilled
checkout.session.completed still runs even if a later session event was already handled.
A non-empty backfill is logged at Warning level — it means webhook delivery missed something. Check the endpoint configuration in the Stripe dashboard.
Upgrading to 4.0
The two startup checks are now a registry rather than two hosted services.
- Removed types:
StripeWebhookConfigurationCheckService,StripeBillingPortalCheckService,StripeWebhookConfigurationChecker,StripeBillingPortalChecker,WebhookConfigurationReport,WebhookConfigurationOutcome,EndpointFinding. Startup checks now implementIStripeStartupCheckand are run by a singleStripeStartupCheckService. - New namespace: the checks and
StartupCheckFinding/StartupCheckSeveritylive inMitc.Integrations.Stripe.StartupChecks. - Extension point: register your own boot check with
builder.SetupStripeIntegration<AppDbContext>(stripe => stripe.AddStartupCheck<MyCheck>()). A check implementsIStripeStartupCheckand returnsIEnumerable<StartupCheckFinding>; each finding declares aSeverity(Info/Warning/Error) and logs itself at the mapped level. - Log-level changes: a permission failure ("could not verify") is now logged at
Informationfor both checks (previouslyWarningfor the webhook check); the explicit per-check "verified" line is replaced by a generic "Stripe startup check passed" line; the webhook check's "skipped" state is now anInformationline (previouslyDebug).
WebhookConfigurationMode and WebhookEndpointUrl (StripeOptions) are unchanged.
Upgrading to 3.0
Upgrading from 1.x? Apply the 2.0 migration notes below as well.
StripeEventgained anObjectIdcolumn (indexed) — add an EF migration in your application. Rows persisted before the migration have aNULLObjectId, so events backfilled shortly after upgrading may not be recognized as superseded until the next boot re-seeds the catalog.StripeOfferingSyncService,EnableCatalogSync, andSyncIntervalwere removed. Boot-time seeding replaces the periodic sync and is always on; remove the deleted keys from configuration.IStripeEventStoregainedHasNewerProcessedEventAsyncandGetMissingIdsAsync;IStripeProductStore/IStripePriceStoregainedUpsertRangeAsync. Custom store implementations must add these members.
Upgrading to 2.0
- This version adds a
ReceivedAtcolumn to theStripeEvententity. Consuming applications must add and apply an EF Core migration (dotnet ef migrations add AddStripeEventReceivedAt) against their ownDbContext.
Handled Webhook Events
| Category | Events |
|---|---|
| Checkout Session | completed, expired, async_payment_succeeded, async_payment_failed |
| Product | created, updated, deleted |
| Price | created, updated, deleted |
| Subscription | created, updated, deleted, paused, resumed |
Metadata
StripeMetadata provides strongly-typed access to Stripe metadata dictionaries via DataKey<T>:
public static readonly DataKey<Guid> RegistrationId = new("registration_id");
// Writing
var metadata = new StripeMetadata();
metadata.Add(RegistrationId, someGuid);
// Reading
Guid id = metadata.Get(RegistrationId);
Guid? maybeId = metadata.TryGet(RegistrationId);
Includes an EF Core ValueConverter (StripeMetadataValueConverter) for database persistence.
Webhook configuration check
A webhook endpoint that is disabled, points at the wrong URL, or isn't subscribed to an event type fails silently: Stripe never attempts delivery, so nothing shows up as a failed delivery. At startup the library lists the account's webhook endpoints and verifies that an endpoint matches this app (WebhookEndpointUrl, or the /stripe/webhook URL suffix), that it is enabled, and that its enabled_events covers every event type the library routes (["*"] counts as full coverage). Findings are logged — startup is never blocked, and the event reconciler remains the runtime safety net.
With WebhookConfigurationMode: "Heal", the check additionally repairs the one misconfiguration that is both common and silent: missing event types. The repair writes the union of the endpoint's existing enabled_events and the library's routed types — it only ever adds types. Healing never removes types, never touches ["*"] endpoints, never re-enables a disabled endpoint (auto-disable is a symptom of delivery failures; investigate those first), never creates an endpoint, and never touches the endpoint secret. It also refuses to act when several endpoints match — set WebhookEndpointUrl to identify the right one. Teams managing Stripe configuration as code (e.g. Terraform) should leave the mode at Validate.
A restricted API key without webhook-endpoint read permission degrades the check to a "could not verify" line logged at Information; a key that can read but not write logs the findings and a warning that healing was denied.
Checkout
IStripeOperations creates Stripe Checkout sessions for both subscriptions and one-time payments. The mode is selected automatically from the price: a recurring price creates a subscription, a one-time price creates a payment.
public class MyService(IStripeOperations operations)
{
public async Task<string?> StartCheckout(ICheckoutRequest request, StripeMetadata metadata)
{
var result = await operations.CreateCheckoutSessionAsync(request, metadata);
return result.Match(
session => session.Url, // redirect the user here
error => null); // the price isn't in the local mirror — see below
}
}
The failure contract: a price missing from the local store comes back as ResultStatus.NotFound with the reason in Error (the Stripe catalog may be out of sync). Stripe rejections, network failures, and cancellation throw — they are configuration or infrastructure errors, not domain outcomes, so they propagate to your error handling like every other operation on the interface.
When EnableAutomaticTax is set, sessions are created with Stripe automatic tax calculation enabled. This requires Stripe Tax to be enabled on the account — with it off, Stripe rejects session creation (which reaches you as a thrown StripeException, per the contract above).
The provided metadata is attached to the checkout session and, depending on the mode, the resulting subscription or payment intent.
Entitlement Lookup
After checkout completes, the recurring question is does this user have an active subscription, and to what? The pattern has two halves.
1. Capture the customer id at fulfillment. In HandleCheckoutSessionCompleted, read session.CustomerId and store it on your own user record. That column is the join key between your users and their Stripe subscriptions:
public class MyCheckoutHandler(AppDbContext db) : IStripeCheckoutSessionEventHandler
{
public async Task<Outcome> HandleCheckoutSessionCompleted(Session session)
{
var user = await FindUserForSessionAsync(session); // however you correlate the checkout to your user
user.StripeCustomerId = session.CustomerId;
await db.SaveChangesAsync();
return Outcome.Success();
}
}
2. Look up entitlements by customer id. ListForCustomerAsync returns all of the customer's subscriptions with their items loaded — a customer accumulates subscriptions over time (ended, cancelled, trialing, active), and the store does not guess which one matters. Filter with IsBillingActive():
var subscriptions = await store.ListForCustomerAsync(user.StripeCustomerId);
var active = subscriptions.Where(s => s.IsBillingActive()).ToList();
// Each subscription's Items carry the PriceId/ProductId the entitlement is for.
Two boundaries worth respecting:
- Metadata is not the join key. Checkout metadata is for carrying richer payloads to your handlers; it is stored as a serialized column, not a relational key. Look subscriptions up by customer id.
- Past-due is a policy decision.
IsBillingActive()includespast_duebecause Stripe is still billing the subscription. Whether a past-due customer retains feature access is your application's call — decide it explicitly rather than inheriting it from the filter.
Local Development
When builder.Environment.IsDevelopment() is true, the library:
- Bypasses Stripe webhook signature verification
- Starts a
StripeLocalEventListenerthat uses the Stripe CLI to forward events to your local server (requires the Stripe CLI to be installed andEnableLocalWebhookto be set totrue)
No packages depend on Mitc.Integrations.Stripe.
.NET 10.0
- Mitc.Support.Results (>= 1.1.0)
- CliWrap (>= 3.10.0)
- Microsoft.EntityFrameworkCore (>= 10.0.0 && < 11.0.0)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.0 && < 11.0.0)
- Mitc.Support.Enumeration (>= 1.0.5)
- Mitc.Support.Enumeration.EntityFramework (>= 1.0.1)
- Stripe.net (>= 50.3.0)
.NET 9.0
- Mitc.Support.Results (>= 1.1.0)
- CliWrap (>= 3.10.0)
- Microsoft.EntityFrameworkCore (>= 9.0.0 && < 10.0.0)
- Microsoft.EntityFrameworkCore.Relational (>= 9.0.0 && < 10.0.0)
- Mitc.Support.Enumeration (>= 1.0.5)
- Mitc.Support.Enumeration.EntityFramework (>= 1.0.1)
- Stripe.net (>= 50.3.0)
| Version | Downloads | Last updated |
|---|---|---|
| 4.0.0 | 0 | 6/10/2026 |
| 3.3.0 | 0 | 6/8/2026 |
| 3.2.0 | 0 | 6/5/2026 |
| 3.1.0 | 0 | 6/5/2026 |
| 3.0.0 | 0 | 6/5/2026 |
| 2.1.0 | 0 | 6/4/2026 |
| 2.0.0 | 0 | 6/2/2026 |
| 1.6.0 | 0 | 4/29/2026 |
| 1.5.0 | 0 | 4/13/2026 |
| 1.4.0 | 0 | 4/13/2026 |
| 1.3.6 | 16 | 2/21/2026 |
| 1.3.5 | 0 | 2/18/2026 |
| 1.3.1 | 27 | 2/18/2026 |
| 1.3.0 | 5 | 2/17/2026 |
| 1.2.9 | 4 | 2/12/2026 |
| 1.2.8 | 7 | 2/11/2026 |
| 1.2.7 | 1 | 2/11/2026 |
| 1.2.6 | 1 | 2/11/2026 |
| 1.2.5 | 1 | 2/11/2026 |
| 1.2.4 | 1 | 2/11/2026 |
| 1.2.3 | 1 | 2/11/2026 |
| 1.2.2 | 2 | 2/11/2026 |
| 1.2.1 | 1 | 2/11/2026 |
| 1.2.0 | 1 | 2/11/2026 |
| 1.1.9 | 1 | 2/10/2026 |
| 1.1.8 | 1 | 2/10/2026 |
| 1.1.7 | 1 | 2/9/2026 |
| 1.1.6 | 1 | 2/9/2026 |
| 1.1.5 | 1 | 2/9/2026 |
| 1.1.4 | 1 | 2/9/2026 |
| 1.1.3 | 1 | 2/9/2026 |
| 1.1.2 | 1 | 2/9/2026 |
| 1.1.1 | 1 | 2/9/2026 |
| 1.1.0 | 1 | 2/9/2026 |
| 1.0.10 | 1 | 2/9/2026 |
| 1.0.9 | 1 | 2/7/2026 |
| 1.0.8 | 1 | 2/7/2026 |
| 1.0.7 | 1 | 2/7/2026 |
| 1.0.6 | 1 | 2/7/2026 |
| 1.0.5 | 1 | 2/7/2026 |
| 1.0.4 | 1 | 2/7/2026 |
| 1.0.3 | 1 | 2/7/2026 |
| 1.0.2 | 1 | 2/6/2026 |
| 1.0.1 | 0 | 2/6/2026 |
| 1.0.0 | 0 | 2/6/2026 |