Intro
This demo shows how to integrate Stripe Checkout with ASP.NET Core. We handle the complete payment flow including session creation, webhook processing, and race condition management between client callbacks and server notifications.
Why this matters: Stripe webhooks and client callbacks can arrive in any order. Your system needs to handle this race condition safely. This demo shows atomic state transitions and proper webhook verification for production-grade payment processing.
(This demo simplifies some production implementations, explained in detail at the end.)
Project / Setup Overview
The project contains a single ASP.NET Core application running on https://localhost:5001.
Folder Structure:
stripe-checkout-aspnet-demo/
βββ Controllers/
β ββ...
Intro
This demo shows how to integrate Stripe Checkout with ASP.NET Core. We handle the complete payment flow including session creation, webhook processing, and race condition management between client callbacks and server notifications.
Why this matters: Stripe webhooks and client callbacks can arrive in any order. Your system needs to handle this race condition safely. This demo shows atomic state transitions and proper webhook verification for production-grade payment processing.
(This demo simplifies some production implementations, explained in detail at the end.)
Project / Setup Overview
The project contains a single ASP.NET Core application running on https://localhost:5001.
Folder Structure:
stripe-checkout-aspnet-demo/
βββ Controllers/
β βββ CheckoutController.cs # Creates Stripe sessions
β βββ CallbackController.cs # Handles success redirect
β βββ WebhookController.cs # Processes Stripe webhooks
βββ Services/
β βββ ProductService.cs # Maps products to Stripe prices
β βββ OrderService.cs # Business logic for orders
βββ Repositories/
β βββ OrderRepository.cs # Order state management
βββ Contracts/
β βββ Enums.cs # OrderStatus enum
β βββ CreateCheckoutSessionRequest.cs
βββ wwwroot/
βββ checkout.html # Test checkout form
Endpoints:
POST /api/checkout/create-sessionβ Creates Stripe Checkout session, registers order as PendingGET /successβ Client redirect handler, updates order to ConfirmedPOST /stripe-webhookβ Stripe server notification, finalizes order as Fulfilled
Order States:
- Pending β Initial state after session creation
- Confirmed β Set when user returns from Stripe
- Fulfilled β Set by webhook, may skip Confirmed if webhook arrives first
- Failed β Reserved for unexpected database errors (should not occur in this demo)
How It Works
Session Creation
The frontend calls /api/checkout/create-session with ItemId and Quantity. The backend maps the item to a Stripe Price ID on the server side. This prevents price tampering since clients never see the actual price.
public class CreateCheckoutSessionRequest
{
[Required(ErrorMessage = "Item ID must be provided.")]
public required string ItemId { get; set; }
[Range(1, 100, ErrorMessage = "Quantity must be between 1 and 100.")]
public long Quantity { get; set; }
}
The server registers the order as Pending and creates a Stripe session. The session URL redirects users to Stripeβs hosted checkout page.
Payment Process
After payment, Stripe performs two actions:
- Redirects the browser to
/success - Sends a webhook to
/stripe-webhook
These can arrive in any order. The webhook might arrive before the redirect, or vice versa.
Race Condition Handling
Both endpoints attempt to update order state. The OrderRepository uses ConcurrentDictionary.TryUpdate for atomic transitions:
public bool TryUpdateStatus(string orderId, OrderStatus currentStatus, OrderStatus newStatus)
{
return _orderStatuses.TryUpdate(orderId, newStatus, currentStatus);
}
The /success endpoint tries: Pending β Confirmed
The webhook tries two transitions:
- Pending β Fulfilled (if webhook arrives first)
- Confirmed β Fulfilled (if redirect arrived first)
public bool TryFulfillOrder(string orderId)
{
// Try to transition from PENDING -> FULFILLED
if (_orderRepository.TryUpdateStatus(orderId, OrderStatus.Pending, OrderStatus.Fulfilled))
{
return true;
}
// If that failed, try to transition from CONFIRMED -> FULFILLED
if (_orderRepository.TryUpdateStatus(orderId, OrderStatus.Confirmed, OrderStatus.Fulfilled))
{
return true;
}
return false;
}
Whichever arrives first locks in the state. The second arrival safely fails the transition and logs appropriately.
Webhook Verification
The webhook controller verifies Stripeβs signature before processing events. This prevents unauthorized webhook calls.
[HttpPost]
public async Task<IActionResult> Index()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var stripeEvent = EventUtility.ConstructEvent(
json,
Request.Headers["Stripe-Signature"],
_webhookSecret
);
if (stripeEvent.Type == "checkout.session.completed")
{
var session = stripeEvent.Data.Object as Session;
string orderId = session.Metadata.GetValueOrDefault("internal_order_id", "Unknown");
bool fulfilled = _orderService.TryFulfillOrder(orderId);
if (fulfilled)
{
_logger.LogInformation("Order {OrderId} fulfilled successfully.", orderId);
}
else
{
_logger.LogWarning("Order {OrderId} fulfillment skipped (already fulfilled).", orderId);
}
}
return Ok();
}
Dependency Injection
Services and repositories are registered in Program.cs:
// Register services and repositories
builder.Services.AddSingleton<OrderRepository>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddSingleton<ProductService>();
Note: OrderRepository uses Singleton lifetime because the in-memory dictionary must be shared across requests. In production with a real database, use Scoped lifetime to match DbContext lifecycle.
Demo / Usage
Step 1: Configure Stripe Keys
Add your Stripe keys to appsettings.json or use .NET Secret Manager:
"Stripe": {
"SecretKey": "sk_test_...",
"WebhookSecret": "whsec_..."
}
Get your webhook signing secret using Stripe CLI:
stripe listen --forward-to https://localhost:5001/stripe-webhook
The output shows your webhook secret:
> Ready! You are using Stripe API Version [2025-10-29.clover].
Your webhook signing secret is whsec_... (^C to quit)
Copy the whsec_ value to your configuration.
Learn more: Listening to webhooks with Stripe CLI
Step 2: Define Product Mapping
In ProductService.cs, map your internal product IDs to Stripe Price IDs:
private readonly Dictionary<string, string> SecurePriceMap = new()
{
{ "premium_product_demo", "price_1ABC..." }
};
Important: Stripe Price IDs are account-specific. Create your own products and prices in the Stripe Dashboard, then use those Price IDs here.
Step 3: Run the Project
Start the application and navigate to:
https://localhost:5001/checkout.html
Click Proceed to Checkout. Youβll be redirected to Stripeβs hosted checkout page.
Step 4: Complete Payment
Use Stripeβs test card number: 4242 4242 4242 4242
- Any future expiry date
- Any 3-digit CVC
- Any ZIP code
After payment, observe the console output showing state transitions:
The Stripe CLI also shows webhook delivery:
Youβll be redirected to /success with a JSON response showing the final order state.
Next Steps / Extensions
- Replace
OrderRepositoryin-memory storage with Entity Framework Core or Dapper backed by SQL Server or PostgreSQL. - Implement retry logic for webhook processing using Polly or similar libraries.
- Track processed Stripe Event IDs in the database to ensure idempotency.
- Replace console logging with Serilog or another production logger.
- Add customer email notifications using SendGrid or similar services.
- Implement comprehensive error handling and monitoring with Application Insights or Sentry.
- Add unit tests for order state transitions and webhook handling.
Production Notes / Limitations
In-Memory Storage: OrderRepository uses ConcurrentDictionary to simulate database persistence. This data disappears when the application restarts. Production systems need Entity Framework Core, Dapper, or another data access layer backed by a real database.
Singleton Lifetime: OrderRepository is registered as Singleton because the in-memory dictionary must be shared across all requests. With a real database, use Scoped lifetime to match DbContext lifecycle and ensure proper transaction boundaries.
Idempotency: Production systems must track processed Stripe Event IDs to prevent duplicate fulfillment. Stripe may send the same webhook multiple times. Store event IDs in your database and skip already-processed events.
Security: Always verify webhook signatures using EventUtility.ConstructEvent. Never process webhooks without signature verification. Use HTTPS for webhook endpoints in production.
Error Handling: Implement comprehensive logging for all state transitions. Monitor webhook processing failures and set up alerts. Implement retry logic for transient failures.
Testing: Stripe provides test mode for development. Use different API keys for test and production environments. Never expose secret keys in client-side code or public repositories.
This demo serves as an educational foundation rather than production-ready code. It demonstrates proper architectural patterns and race condition handling, but requires additional hardening for production use.
TL;DR
Stripe Checkout integration with ASP.NET Core showing proper webhook handling and race condition management. The demo uses atomic state transitions to handle asynchronous payment flows safely. Webhooks and client callbacks can arrive in any order, and the system handles both scenarios correctly.
GitHub Repository: https://github.com/karnafun/stripe-checkout-aspnet-demo
Looking for help integrating payment systems or building secure APIs? Hire me
Backend engineer & API integrator. Building secure, scalable APIs with .NET.