Case Studies Advanced 5 min read

Design a Payment Checkout System (Idempotent, Saga-Based)

How to build a payment system in .NET: idempotency keys, saga orchestration, double-charge prevention, refund flow, and integration with Stripe/Adyen.

Table of contents
  1. When does payment become its own service?
  2. What numbers should I budget for?
  3. What does the architecture look like?
  4. What is the .NET 10 wiring for the saga?
  5. How do webhooks integrate with the saga?
  6. What scale-out path does this support?
  7. What failure modes does this introduce?
  8. When is a custom payment service overkill?
  9. Where should you go from here?

The payment system is where idempotency, sagas, outboxes, and resilience handlers all show up at once. Get any of them wrong and customers get double-charged or stuck with paid-but-unshipped orders. This chapter designs a checkout flow in .NET that survives network glitches, webhook storms, and orchestrator crashes - and explains why every block in the series earns its keep here.

When does payment become its own service?

Three signals.

Multiple products feeding one checkout. A marketplace, multi-tenant SaaS, or e-commerce site with subscriptions and one-offs. Centralising payment logic stops three implementations from drifting in subtle, expensive ways.

Compliance requirements. PCI DSS scope shrinks dramatically if card data never touches your servers (use Stripe Elements / Adyen Web Drop-In). The payment service is the well-defined boundary.

Refund and reconciliation are first-class operations. Manual ops people need to refund, retry, and trace charges. A dedicated service with audit trails and admin UI makes that scalable.

What numbers should I budget for?

Checkouts / day             100K
Avg amount                  $50
Payment provider latency    300-1000 ms p99
Webhook duplication rate    1-5% (Stripe retries)
Idempotency window          24-72 hours
Refund window               90 days typical
Audit log retention         7 years (financial records)

The two numbers that drive everything: provider latency forces async UX (polling or webhook for confirmation), and webhook duplication makes idempotency mandatory. Audit retention shapes storage decisions.

What does the architecture look like?

flowchart LR
    Client --> Web[ASP.NET Core checkout]
    Web --> SagaOrch[Saga Orchestrator]
    SagaOrch -->|reserve| Inv[Inventory svc]
    SagaOrch -->|authorise| Pay[Payment svc]
    Pay --> Stripe[(Stripe API)]
    Stripe -. webhook .-> Web
    SagaOrch --> OB[(Outbox)]
    OB --> Notif[Notification svc]
    SagaOrch --> Audit[(Audit log)]
    Stripe -. async webhook .-> Web

Five services. Web accepts the checkout, the saga orchestrator runs the steps, payment talks to Stripe, inventory reserves stock, notification fans out emails. Audit log captures every state transition. Webhooks come back asynchronously and update the saga.

What is the .NET 10 wiring for the saga?

public class PaymentSaga : MassTransitStateMachine<PaymentSagaState>
{
    public State Reserving { get; private set; } = null!;
    public State Authorising { get; private set; } = null!;
    public State Capturing { get; private set; } = null!;
    public State Completed { get; private set; } = null!;
    public State Failed { get; private set; } = null!;

    public Event<CheckoutStarted> Started { get; private set; } = null!;
    public Event<InventoryReserved> Reserved { get; private set; } = null!;
    public Event<PaymentAuthorised> Authorised { get; private set; } = null!;
    public Event<PaymentCaptured> Captured { get; private set; } = null!;
    public Event<PaymentFailed> Failed { get; private set; } = null!;

    public PaymentSaga()
    {
        InstanceState(x => x.CurrentState);

        Initially(
            When(Started)
                .Then(ctx => { ctx.Saga.IdempotencyKey = ctx.Message.IdempotencyKey;
                                ctx.Saga.Amount = ctx.Message.Amount; })
                .Publish(ctx => new ReserveInventory(ctx.Saga.OrderId, ctx.Message.Items))
                .TransitionTo(Reserving));

        During(Reserving,
            When(Reserved).Publish(ctx => new AuthorisePayment(
                ctx.Saga.IdempotencyKey, ctx.Saga.Amount))
                .TransitionTo(Authorising));

        During(Authorising,
            When(Authorised).Publish(ctx => new CapturePayment(ctx.Saga.IdempotencyKey))
                .TransitionTo(Capturing),
            When(Failed).Publish(ctx => new ReleaseInventory(ctx.Saga.OrderId))
                .TransitionTo(this.Failed));

        During(Capturing,
            When(Captured).TransitionTo(Completed));
    }
}

The Stripe call inside the AuthorisePayment consumer:

public class AuthorisePaymentConsumer(IStripeClient stripe, AppDbContext db)
    : IConsumer<AuthorisePayment>
{
    public async Task Consume(ConsumeContext<AuthorisePayment> ctx)
    {
        var options = new PaymentIntentCreateOptions
        {
            Amount = (long)(ctx.Message.Amount * 100),
            Currency = "usd",
            ConfirmationMethod = "automatic",
        };
        var requestOptions = new RequestOptions
        {
            IdempotencyKey = ctx.Message.IdempotencyKey.ToString()  // Stripe-side dedup
        };
        var intent = await stripe.PaymentIntents.CreateAsync(options, requestOptions);
        if (intent.Status == "requires_action") { /* 3DS challenge */ }
        await ctx.Publish(new PaymentAuthorised(ctx.Message.IdempotencyKey, intent.Id));
    }
}

Three details. The same idempotency key flows from client to your service to Stripe, deduping at every layer. The saga orchestrator persists state on every transition. Webhook updates from Stripe publish into the same saga via correlation on the payment intent ID.

How do webhooks integrate with the saga?

app.MapPost("/webhooks/stripe", async (HttpRequest req, IPublishEndpoint bus,
                                        AppDbContext db, IConfiguration config) =>
{
    var json = await new StreamReader(req.Body).ReadToEndAsync();
    var sig = req.Headers["Stripe-Signature"].ToString();
    var stripeEvent = EventUtility.ConstructEvent(json, sig,
        config["Stripe:WebhookSecret"]);

    // Idempotency on Stripe event ID
    var seen = await db.WebhookEvents.AnyAsync(e => e.StripeEventId == stripeEvent.Id);
    if (seen) return Results.Ok();

    db.WebhookEvents.Add(new() { StripeEventId = stripeEvent.Id, ReceivedAt = DateTimeOffset.UtcNow });
    await db.SaveChangesAsync();

    if (stripeEvent.Type == "payment_intent.succeeded")
    {
        var intent = stripeEvent.Data.Object as PaymentIntent;
        await bus.Publish(new PaymentCaptured(Guid.Parse(intent!.Metadata["idempotencyKey"])));
    }
    return Results.Ok();
});

The signature check authenticates the webhook. The idempotency table on event ID dedupes Stripe's retries. The published message correlates back to the saga via the idempotency key.

What scale-out path does this support?

The shape stays mostly the same at scale. What changes:

The bottleneck at scale is rarely your code; it is the payment provider's API.

What failure modes does this introduce?

The observability chapter exposes saga-state metrics so all four are visible in dashboards.

When is a custom payment service overkill?

When you sell one product with one provider and have no reconciliation needs. A direct Stripe Checkout integration with a single webhook handler is sufficient. Build the dedicated service when complexity (multi-product, refunds, ops UI, multi-provider) justifies it.

Where should you go from here?

Last case study: analytics events pipeline

Frequently asked questions

Why is payment the hardest case study?
Because every other failure mode in the series compounds here. A retried HTTP call may charge twice. A duplicate webhook may credit twice. A crashed orchestrator may leave a charged-but-unshipped order. Money makes every bug visible to customers and to regulators. The patterns from chapters 10-12 (idempotency, circuit breakers, sagas) all earn their keep simultaneously.
How does idempotency work end-to-end?
Client generates a UUID per checkout intent and sends it as Idempotency-Key. Your service checks the idempotency table from chapter 10; if present, return the stored response. The same UUID is forwarded to Stripe as their idempotency key. Triple coverage: client retries, your service retries, Stripe retries - none charge twice.
What is the saga shape for payment?
Reserve inventory → authorise card → capture funds → ship. Compensations: release inventory, void authorisation, refund. The orchestrator runs in a saga state machine from chapter 12. Every state change is persisted; an orchestrator crash resumes from the last persisted state.
How do I handle webhook duplication from Stripe?
Webhook handler checks Stripe-Signature and an event-ID idempotency record before processing. The first delivery saves the event-ID; subsequent deliveries are no-ops. Stripe retries webhook delivery aggressively; without idempotency, your inventory or your accounting will go wrong within a week of going live.