Building a Secure Stripe Checkout Integration with ASP.NET Core and Webhook Handling



This content originally appeared on DEV Community and was authored by Dor Danai

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 Pending
  • GET /success – Client redirect handler, updates order to Confirmed
  • POST /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:

  1. Pending → Fulfilled (if webhook arrives first)
  2. 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.

Checkout Button

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:

Console Log

The Stripe CLI also shows webhook delivery:

Stripe Webhook
You’ll be redirected to /success with a JSON response showing the final order state.

Next Steps / Extensions

  • Replace OrderRepository in-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.


This content originally appeared on DEV Community and was authored by Dor Danai