Skip to main content

Command Palette

Search for a command to run...

Designing a PCI-Aware Payment Architecture in .NET

Published
31 min read
Designing a PCI-Aware Payment Architecture in .NET
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

A practical guide to keeping card data out of your system, reducing payment risk, and building safer payment boundaries with ASP.NET Core, Azure, and provider tokenisation.

Introduction

The safest payment system is not the one that handles card data carefully. It is the one that avoids handling card data in the first place.

That sounds obvious, but many payment integrations drift in the wrong direction. You start with a hosted checkout page, then add a custom checkout form, then log a request for debugging, then store a provider response as raw JSON, then copy payloads into support tooling. Nobody sets out to build a card data environment. You build one accidentally.

A PCI-aware architecture does not begin with a checklist. It begins with a boundary decision.

The core question is simple. Where can cardholder data exist?

If the answer is "not in our .NET application", the architecture becomes much easier to reason about. Your frontend sends card details directly to a PCI-compliant payment provider. The provider returns a token, payment method identifier, setup intent, checkout session, or payment intent reference. Your backend stores only provider references, transaction state, amounts, currencies, audit metadata, and business identifiers.

That design does not remove all compliance responsibilities. It does reduce the blast radius. It gives your engineers a clear rule to enforce in code reviews, logs, database schemas, tests, and incident response.

This article shows how to design that kind of payment architecture in .NET. The examples use ASP.NET Core minimal APIs, clean module boundaries, hosted payment/tokenised provider flows, webhook verification, idempotency, Azure Key Vault, and audit-safe logging.

The code is provider-shaped rather than provider-dependent. Stripe examples are used where useful because the concepts are familiar, but the architecture works just as well with Adyen, Worldpay, Braintree, PayPal, Checkout.com, or a bank-specific provider.

This is not legal advice, and it is not a substitute for a Qualified Security Assessor. Treat it as engineering guidance for reducing payment risk before compliance becomes expensive.

PCI-aware does not mean PCI-free

PCI DSS applies when systems store, process, or transmit payment account data. The practical goal for many .NET teams is to avoid storing, processing, or transmitting raw card data in their own systems. That usually means pushing the sensitive collection step to a payment service provider by using hosted payment pages, embedded provider fields, mobile SDKs, or direct tokenisation.

The distinction matters. If your app receives a card number in an API request, even briefly, you have a very different architecture from an app that receives only a provider token.

The first architecture has to treat the application, logs, network path, monitoring stack, support tools, queues, databases, and backups as potentially in-scope. The second architecture still needs security controls, but the most sensitive data never enters your estate.

Here is the rule I would put at the top of the payment module README.

The Payments API must never accept, log, persist, queue, publish, or forward raw cardholder data. It accepts provider-generated payment references only.

That rule sounds blunt because it needs to be. A softer rule gets bypassed during a production incident.

The target architecture

A PCI-aware .NET payment architecture needs hard separation between business payment state and sensitive card collection.

The customer interacts with a checkout UI. The UI either redirects to a hosted payment page or renders provider-controlled fields. The provider collects card details. The provider returns a payment reference. Your backend stores that reference and drives the business workflow.

The important part is not the drawing. The important part is the absence of a line from Frontend to PaymentApi carrying card data.

The backend can create a checkout session. It can record that a payment was requested. It can receive webhooks. It can mark a payment as authorised, captured, failed, refunded, or disputed. It must not become a card collection service.

Data classification first, code second

Before designing APIs, classify the data the payment system is allowed to see.

Data Example Can the .NET app store it? Notes
Internal order id ord_123 Yes Business identifier
Payment id pay_123 Yes Internal payment aggregate id
Provider payment id pi_abc123 Yes Provider reference
Provider customer id cus_abc123 Yes Provider reference
Card brand Visa Usually yes Avoid treating it as proof of payment
Last four digits 4242 Usually yes Useful for receipts, still handle carefully
Expiry month/year 12/2028 Avoid unless needed Often not required by your business
Card number 4242424242424242 No Must never enter the app
CVV/CVC 123 No Must never enter the app
Track data/PIN data N/A No Must never enter the app

This table becomes a design tool. Every DTO, log event, database column, queue message, and analytics export should fit into it.

If an engineer adds a CardNumber property, the code review should be short.

No.

The payment module boundary

A payment module should expose business operations, not provider primitives. You do not want the rest of your system calling CreateStripePaymentIntent or CaptureAdyenAuthorisation. You want operations like StartPayment, ConfirmPayment, CapturePayment, RefundPayment, and ReadPaymentStatus.

A minimal module layout can look like this.

src/
  Payments/
    Domain/
      Payment.cs
      PaymentStatus.cs
      Money.cs
      PaymentErrors.cs
    Application/
      StartPayment/
        StartPaymentEndpoint.cs
        StartPaymentRequest.cs
        StartPaymentHandler.cs
      Webhooks/
        PaymentWebhookEndpoint.cs
        PaymentWebhookHandler.cs
      RefundPayment/
        RefundPaymentEndpoint.cs
        RefundPaymentHandler.cs
    Providers/
      IPaymentProvider.cs
      PaymentProviderOptions.cs
      StripePaymentProvider.cs
    Infrastructure/
      PaymentsDbContext.cs
      OutboxMessage.cs
      PaymentAuditLog.cs
      Redaction/
        SensitivePaymentDataGuard.cs

This keeps provider details at the edge. Your domain model should not know what Stripe, Adyen, or Worldpay call their objects. It should know that a payment was requested, authorised, captured, failed, refunded, or disputed.

The public API should make unsafe input impossible

Start with the request contract. Notice what is missing.

There is no CardNumber. No Cvv. No ExpiryMonth. No ExpiryYear.

namespace Payments.Application.StartPayment;

public sealed record StartPaymentRequest(
    Guid OrderId,
    long AmountMinor,
    string Currency,
    string CustomerEmail,
    Uri SuccessUrl,
    Uri CancelUrl);

The endpoint creates a provider-hosted session and returns a URL or client secret that the frontend can use. The backend does not collect card details.

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Payments.Domain;

namespace Payments.Application.StartPayment;

internal static class StartPaymentEndpoint
{
    public static IEndpointRouteBuilder MapStartPayment(this IEndpointRouteBuilder app)
    {
        app.MapPost("/payments", StartPayment)
            .WithName("StartPayment")
            .WithTags("Payments")
            .WithSummary("Starts a provider-hosted payment")
            .WithDescription("Creates an internal payment record and a provider-hosted checkout session. Raw card data is never accepted by this endpoint.")
            .Produces<StartPaymentResponse>(StatusCodes.Status201Created)
            .Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
            .Produces<ProblemDetails>(StatusCodes.Status409Conflict);

        return app;
    }

    private static async Task<Results<Created<StartPaymentResponse>, ProblemHttpResult>> StartPayment(
        [FromBody] StartPaymentRequest request,
        StartPaymentHandler handler,
        CancellationToken stopToken)
    {
        var result = await handler.Handle(request, stopToken);

        return result.IsSuccess
            ? TypedResults.Created($"/payments/{result.Value.PaymentId}", result.Value)
            : TypedResults.Problem(
                title: result.Error.Code,
                detail: result.Error.Description,
                statusCode: result.Error.StatusCode);
    }
}

public sealed record StartPaymentResponse(
    Guid PaymentId,
    string Provider,
    string ProviderCheckoutSessionId,
    Uri CheckoutUrl);

This endpoint still needs authentication and authorisation in a real system. Customers should only start payments for their own orders. Internal staff should only access payment details through restricted operations. Those rules are outside the PCI boundary, but they are still part of the payment security model.

Guard against unsafe DTO drift

Contracts are not enough. Someone can add a property later.

You can add a small reflection-based test that fails when unsafe payment terms appear in public request contracts.

using System.Reflection;
using Xunit;

namespace Payments.Tests.Security;

public sealed class PaymentContractsMustNotAcceptCardDataTests
{
    private static readonly string[] ForbiddenTerms =
    [
        "cardnumber",
        "pan",
        "primaryaccountnumber",
        "cvv",
        "cvc",
        "securitycode",
        "trackdata",
        "pinblock"
    ];

    [Fact]
    public void Public_payment_requests_must_not_accept_raw_card_data()
    {
        var requestTypes = typeof(Payments.Application.StartPayment.StartPaymentRequest)
            .Assembly
            .GetTypes()
            .Where(t => t.Name.EndsWith("Request", StringComparison.OrdinalIgnoreCase));

        var violations = requestTypes
            .SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Select(property => $"{type.FullName}.{property.Name}"))
            .Where(name => ForbiddenTerms.Any(term =>
                name.Replace("_", "", StringComparison.Ordinal).Contains(term, StringComparison.OrdinalIgnoreCase)))
            .ToArray();

        Assert.True(
            violations.Length == 0,
            "Payment request contracts must not accept raw card data: " + string.Join(", ", violations));
    }
}

This is not a complete compliance control. It is a cheap tripwire. Cheap tripwires are useful because they catch mistakes before they become architecture.

Model the payment aggregate around state transitions

The domain model should protect business correctness. It should not contain card data. It should know the amount, currency, order, provider reference, status, and state transitions.

namespace Payments.Domain;

public sealed class Payment
{
    private readonly List<PaymentEvent> _events = [];

    private Payment()
    {
    }

    private Payment(
        Guid id,
        Guid orderId,
        Money amount,
        string provider,
        string providerPaymentReference)
    {
        Id = id;
        OrderId = orderId;
        Amount = amount;
        Provider = provider;
        ProviderPaymentReference = providerPaymentReference;
        Status = PaymentStatus.Pending;
        CreatedUtc = DateTimeOffset.UtcNow;

        AddEvent("PaymentStarted");
    }

    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public Money Amount { get; private set; } = Money.Zero("EUR");
    public string Provider { get; private set; } = string.Empty;
    public string ProviderPaymentReference { get; private set; } = string.Empty;
    public PaymentStatus Status { get; private set; }
    public DateTimeOffset CreatedUtc { get; private set; }
    public DateTimeOffset? AuthorisedUtc { get; private set; }
    public DateTimeOffset? CapturedUtc { get; private set; }
    public DateTimeOffset? FailedUtc { get; private set; }

    public IReadOnlyCollection<PaymentEvent> Events => _events.AsReadOnly();

    public static Payment Start(
        Guid orderId,
        Money amount,
        string provider,
        string providerPaymentReference)
    {
        if (orderId == Guid.Empty)
        {
            throw new PaymentDomainException("Order id is required.");
        }

        if (amount.AmountMinor <= 0)
        {
            throw new PaymentDomainException("Payment amount must be greater than zero.");
        }

        if (string.IsNullOrWhiteSpace(providerPaymentReference))
        {
            throw new PaymentDomainException("Provider payment reference is required.");
        }

        return new Payment(Guid.NewGuid(), orderId, amount, provider, providerPaymentReference);
    }

    public void MarkAuthorised(string providerEventId)
    {
        if (Status is PaymentStatus.Captured or PaymentStatus.Refunded)
        {
            return;
        }

        if (Status is PaymentStatus.Failed or PaymentStatus.Cancelled)
        {
            throw new PaymentDomainException($"Cannot authorise payment in state '{Status}'.");
        }

        Status = PaymentStatus.Authorised;
        AuthorisedUtc = DateTimeOffset.UtcNow;

        AddEvent("PaymentAuthorised", providerEventId);
    }

    public void MarkCaptured(string providerEventId)
    {
        if (Status == PaymentStatus.Captured)
        {
            return;
        }

        if (Status != PaymentStatus.Authorised && Status != PaymentStatus.Pending)
        {
            throw new PaymentDomainException($"Cannot capture payment in state '{Status}'.");
        }

        Status = PaymentStatus.Captured;
        CapturedUtc = DateTimeOffset.UtcNow;

        AddEvent("PaymentCaptured", providerEventId);
    }

    public void MarkFailed(string providerEventId, string reason)
    {
        if (Status is PaymentStatus.Captured or PaymentStatus.Refunded)
        {
            throw new PaymentDomainException($"Cannot fail payment in state '{Status}'.");
        }

        Status = PaymentStatus.Failed;
        FailedUtc = DateTimeOffset.UtcNow;

        AddEvent("PaymentFailed", providerEventId, reason);
    }

    private void AddEvent(string type, string? providerEventId = null, string? reason = null)
    {
        _events.Add(new PaymentEvent(
            Guid.NewGuid(),
            Id,
            type,
            providerEventId,
            reason,
            DateTimeOffset.UtcNow));
    }
}

public enum PaymentStatus
{
    Pending = 0,
    Authorised = 1,
    Captured = 2,
    Failed = 3,
    Cancelled = 4,
    Refunded = 5,
    Disputed = 6
}

public sealed record Money(long AmountMinor, string Currency)
{
    public static Money Zero(string currency) => new(0, currency);
}

public sealed record PaymentEvent(
    Guid Id,
    Guid PaymentId,
    string Type,
    string? ProviderEventId,
    string? Reason,
    DateTimeOffset OccurredUtc);

public sealed class PaymentDomainException(string message) : Exception(message);

The aggregate is intentionally boring. That is a good thing. Payment systems become dangerous when the business model becomes a dumping ground for provider payloads.

Store provider references, not provider payload dumps

A common mistake is storing entire provider responses as JSON for convenience. That is risky. Provider payloads can contain more data than you expect, and payload shapes can change over time.

Prefer explicit columns for the data you need.

using Microsoft.EntityFrameworkCore;
using Payments.Domain;

namespace Payments.Infrastructure;

public sealed class PaymentsDbContext(DbContextOptions<PaymentsDbContext> options)
    : DbContext(options)
{
    public DbSet<Payment> Payments => Set<Payment>();

    public DbSet<ProcessedPaymentWebhook> ProcessedWebhooks => Set<ProcessedPaymentWebhook>();

    public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Payment>(payment =>
        {
            payment.ToTable("payments");

            payment.HasKey(x => x.Id);

            payment.Property(x => x.OrderId)
                .IsRequired();

            payment.OwnsOne(x => x.Amount, money =>
            {
                money.Property(x => x.AmountMinor)
                    .HasColumnName("amount_minor")
                    .IsRequired();

                money.Property(x => x.Currency)
                    .HasColumnName("currency")
                    .HasMaxLength(3)
                    .IsRequired();
            });

            payment.Property(x => x.Provider)
                .HasMaxLength(40)
                .IsRequired();

            payment.Property(x => x.ProviderPaymentReference)
                .HasMaxLength(200)
                .IsRequired();

            payment.Property(x => x.Status)
                .HasConversion<string>()
                .HasMaxLength(40)
                .IsRequired();

            payment.HasIndex(x => x.ProviderPaymentReference)
                .IsUnique();
        });

        modelBuilder.Entity<ProcessedPaymentWebhook>(webhook =>
        {
            webhook.ToTable("processed_payment_webhooks");

            webhook.HasKey(x => x.ProviderEventId);

            webhook.Property(x => x.ProviderEventId)
                .HasMaxLength(200)
                .IsRequired();

            webhook.Property(x => x.Provider)
                .HasMaxLength(40)
                .IsRequired();

            webhook.Property(x => x.ProcessedUtc)
                .IsRequired();
        });

        modelBuilder.Entity<OutboxMessage>(outbox =>
        {
            outbox.ToTable("outbox_messages");

            outbox.HasKey(x => x.Id);

            outbox.Property(x => x.Type)
                .HasMaxLength(200)
                .IsRequired();

            outbox.Property(x => x.Payload)
                .IsRequired();

            outbox.Property(x => x.OccurredUtc)
                .IsRequired();

            outbox.Property(x => x.ProcessedUtc);
        });
    }
}

public sealed class ProcessedPaymentWebhook
{
    public string ProviderEventId { get; init; } = string.Empty;
    public string Provider { get; init; } = string.Empty;
    public DateTimeOffset ProcessedUtc { get; init; }
}

public sealed class OutboxMessage
{
    public Guid Id { get; init; }
    public string Type { get; init; } = string.Empty;
    public string Payload { get; init; } = string.Empty;
    public DateTimeOffset OccurredUtc { get; init; }
    public DateTimeOffset? ProcessedUtc { get; set; }
}

The database should tell the same story as the architecture diagram. If the schema contains card_number, cvv, or raw provider request columns, the system is not keeping the boundary clean.

Provider abstraction without hiding payment reality

A provider abstraction should hide SDK details, not payment semantics. Do not turn all providers into a weak Dictionary<string, string> API. You still need strong concepts such as checkout session, provider payment reference, idempotency key, event id, and event type.

namespace Payments.Providers;

public interface IPaymentProvider
{
    string Name { get; }

    Task<CreateCheckoutSessionResult> CreateCheckoutSession(
        CreateCheckoutSessionCommand command,
        CancellationToken stopToken);

    Task<VerifiedPaymentWebhook> VerifyWebhook(
        string rawBody,
        string signatureHeader,
        CancellationToken stopToken);
}

public sealed record CreateCheckoutSessionCommand(
    Guid InternalPaymentId,
    Guid OrderId,
    long AmountMinor,
    string Currency,
    string CustomerEmail,
    Uri SuccessUrl,
    Uri CancelUrl,
    string IdempotencyKey);

public sealed record CreateCheckoutSessionResult(
    string ProviderCheckoutSessionId,
    string ProviderPaymentReference,
    Uri CheckoutUrl);

public sealed record VerifiedPaymentWebhook(
    string ProviderEventId,
    string ProviderPaymentReference,
    PaymentProviderEventType EventType,
    long? AmountMinor,
    string? Currency,
    DateTimeOffset OccurredUtc);

public enum PaymentProviderEventType
{
    Authorised,
    Captured,
    Failed,
    Cancelled,
    Refunded,
    Disputed,
    Unknown
}

This abstraction gives you testability and routing flexibility without pretending all payment providers are identical.

Creating a hosted checkout session

The handler creates an internal payment record first, calls the provider with an idempotency key, then saves the provider reference.

In a high-value payment system, you may choose a slightly different sequence with a reservation record, transactional outbox, or provider-side metadata. The key point is that retries must be safe.

using Microsoft.EntityFrameworkCore;
using Payments.Domain;
using Payments.Infrastructure;
using Payments.Providers;

namespace Payments.Application.StartPayment;

public sealed class StartPaymentHandler(
    PaymentsDbContext dbContext,
    IPaymentProvider paymentProvider)
{
    public async Task<Result<StartPaymentResponse>> Handle(
        StartPaymentRequest request,
        CancellationToken stopToken)
    {
        var money = new Money(request.AmountMinor, request.Currency.ToUpperInvariant());

        var internalPaymentId = Guid.NewGuid();
        var idempotencyKey = $"payment-start:{internalPaymentId:N}";

        var checkoutSession = await paymentProvider.CreateCheckoutSession(
            new CreateCheckoutSessionCommand(
                internalPaymentId,
                request.OrderId,
                money.AmountMinor,
                money.Currency,
                request.CustomerEmail,
                request.SuccessUrl,
                request.CancelUrl,
                idempotencyKey),
            stopToken);

        var payment = Payment.Start(
            request.OrderId,
            money,
            paymentProvider.Name,
            checkoutSession.ProviderPaymentReference);

        dbContext.Payments.Add(payment);

        dbContext.OutboxMessages.Add(new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = "PaymentStarted",
            Payload = PaymentOutboxPayloads.PaymentStarted(payment),
            OccurredUtc = DateTimeOffset.UtcNow
        });

        await dbContext.SaveChangesAsync(stopToken);

        return Result<StartPaymentResponse>.Success(new StartPaymentResponse(
            payment.Id,
            payment.Provider,
            checkoutSession.ProviderCheckoutSessionId,
            checkoutSession.CheckoutUrl));
    }
}

The code above is deliberately simplified. In production, you would usually persist the internal payment id before calling the provider, or use a deterministic idempotency key derived from the order id and payment attempt number. The design depends on whether the business allows multiple payment attempts per order.

The idempotency decision should be explicit. Do not let HTTP retries decide whether a customer gets charged twice.

Stripe-shaped provider example

This example uses a Stripe-shaped checkout flow. It does not send card data to the .NET backend. The backend asks the provider to create a checkout session and returns the hosted checkout URL.

using Microsoft.Extensions.Options;
using Stripe.Checkout;

namespace Payments.Providers.Stripe;

public sealed class StripePaymentProvider(IOptions<StripeProviderOptions> options)
    : IPaymentProvider
{
    private readonly StripeProviderOptions _options = options.Value;

    public string Name => "stripe";

    public async Task<CreateCheckoutSessionResult> CreateCheckoutSession(
        CreateCheckoutSessionCommand command,
        CancellationToken stopToken)
    {
        var service = new SessionService();

        var createOptions = new SessionCreateOptions
        {
            Mode = "payment",
            SuccessUrl = command.SuccessUrl.ToString(),
            CancelUrl = command.CancelUrl.ToString(),
            CustomerEmail = command.CustomerEmail,
            ClientReferenceId = command.OrderId.ToString("N"),
            Metadata = new Dictionary<string, string>
            {
                ["internal_payment_id"] = command.InternalPaymentId.ToString("N"),
                ["order_id"] = command.OrderId.ToString("N")
            },
            LineItems =
            [
                new SessionLineItemOptions
                {
                    Quantity = 1,
                    PriceData = new SessionLineItemPriceDataOptions
                    {
                        Currency = command.Currency.ToLowerInvariant(),
                        UnitAmount = command.AmountMinor,
                        ProductData = new SessionLineItemPriceDataProductDataOptions
                        {
                            Name = $"Order {command.OrderId:N}"
                        }
                    }
                }
            ]
        };

        var requestOptions = new Stripe.RequestOptions
        {
            ApiKey = _options.SecretKey,
            IdempotencyKey = command.IdempotencyKey
        };

        var session = await service.CreateAsync(createOptions, requestOptions, stopToken);

        if (session.PaymentIntentId is null)
        {
            throw new PaymentProviderException("Provider did not return a payment intent reference.");
        }

        return new CreateCheckoutSessionResult(
            session.Id,
            session.PaymentIntentId,
            new Uri(session.Url));
    }

    public Task<VerifiedPaymentWebhook> VerifyWebhook(
        string rawBody,
        string signatureHeader,
        CancellationToken stopToken)
    {
        throw new NotImplementedException("Webhook verification shown later in the article.");
    }
}

public sealed class StripeProviderOptions
{
    public const string SectionName = "Payments:Stripe";
    public string SecretKey { get; init; } = string.Empty;
    public string WebhookSecret { get; init; } = string.Empty;
}

public sealed class PaymentProviderException(string message) : Exception(message);

This is where teams often make a subtle mistake. They assume that because the payment provider is PCI-compliant, their integration is automatically safe. That is not enough. Your application must still avoid unsafe logging, unsafe request capture, overly broad secret access, insecure webhook handling, and careless support tooling.

Secret management with Azure Key Vault and managed identity

Provider secrets should not live in source control, appsettings files, container images, build variables, or logs.

On Azure, a common pattern is to let the app authenticate to Azure Key Vault using managed identity. The app reads the payment provider secret at runtime. The application has access only to the vault and secrets it needs.

using Azure.Identity;
using Payments.Providers.Stripe;

var builder = WebApplication.CreateBuilder(args);

if (!builder.Environment.IsDevelopment())
{
    var keyVaultUri = builder.Configuration["KeyVault:Uri"];

    if (string.IsNullOrWhiteSpace(keyVaultUri))
    {
        throw new InvalidOperationException("KeyVault:Uri is required outside development.");
    }

    builder.Configuration.AddAzureKeyVault(
        new Uri(keyVaultUri),
        new DefaultAzureCredential());
}

builder.Services
    .AddOptions<StripeProviderOptions>()
    .Bind(builder.Configuration.GetSection(StripeProviderOptions.SectionName))
    .Validate(options => !string.IsNullOrWhiteSpace(options.SecretKey), "Stripe secret key is required.")
    .Validate(options => !string.IsNullOrWhiteSpace(options.WebhookSecret), "Stripe webhook secret is required.")
    .ValidateOnStart();

builder.Services.AddScoped<IPaymentProvider, StripePaymentProvider>();

var app = builder.Build();

app.MapStartPayment();
app.MapPaymentWebhook();

app.Run();

In Azure App Service, Container Apps, or Functions, you can use environment-specific settings like these.

KeyVault__Uri=https://kv-payments-prod.vault.azure.net/
Payments__Stripe__SecretKey=<Key Vault reference or secret value resolved by configuration provider>
Payments__Stripe__WebhookSecret=<Key Vault reference or secret value resolved by configuration provider>

Use separate vaults or at least separate access boundaries for unrelated applications. A marketing website does not need payment provider secrets. A reporting job usually does not need webhook signing secrets. A support tool should not have write access to payment credentials.

Secrets are architecture, not configuration trivia.

Webhooks are the source of payment truth

A payment redirect tells you what the customer browser did. A webhook tells you what the provider says happened.

That difference matters. A customer can close the browser after payment. A success URL can be blocked. A malicious user can call your success URL manually. The provider webhook must drive final state changes.

The sequence should look like this.

Do not update the order to paid because the customer returned to /payment-success. Update it because a verified provider event says the payment was captured.

Verify webhook signatures before parsing business meaning

Webhook endpoints are public by design. They need signature verification, replay protection, idempotency, and careful parsing.

For Stripe, the official library verifies the payload using the raw request body, the Stripe-Signature header, and the endpoint secret. The raw body matters. If middleware changes the body before verification, signature checks can fail.

using Microsoft.AspNetCore.Mvc;
using Payments.Providers;

namespace Payments.Application.Webhooks;

internal static class PaymentWebhookEndpoint
{
    public static IEndpointRouteBuilder MapPaymentWebhook(this IEndpointRouteBuilder app)
    {
        app.MapPost("/payments/webhooks/{provider}", HandleWebhook)
            .WithName("HandlePaymentWebhook")
            .WithTags("Payments")
            .WithSummary("Receives verified payment provider webhooks")
            .WithDescription("Verifies provider signatures and processes payment state changes idempotently.")
            .AllowAnonymous()
            .Produces(StatusCodes.Status202Accepted)
            .Produces<ProblemDetails>(StatusCodes.Status400BadRequest);

        return app;
    }

    private static async Task<IResult> HandleWebhook(
        string provider,
        HttpRequest request,
        PaymentWebhookHandler handler,
        CancellationToken stopToken)
    {
        request.EnableBuffering();

        using var reader = new StreamReader(request.Body, leaveOpen: true);
        var rawBody = await reader.ReadToEndAsync(stopToken);
        request.Body.Position = 0;

        var signatureHeader = request.Headers["Stripe-Signature"].ToString();

        if (string.IsNullOrWhiteSpace(signatureHeader))
        {
            return Results.BadRequest(new ProblemDetails
            {
                Title = "Missing webhook signature",
                Detail = "The payment webhook signature header is required."
            });
        }

        await handler.Handle(provider, rawBody, signatureHeader, stopToken);

        return Results.Accepted();
    }
}

The provider implementation can verify and map provider events into your internal event model.

using Microsoft.Extensions.Options;
using Stripe;

namespace Payments.Providers.Stripe;

public sealed partial class StripePaymentProvider
{
    public Task<VerifiedPaymentWebhook> VerifyWebhook(
        string rawBody,
        string signatureHeader,
        CancellationToken stopToken)
    {
        var stripeEvent = EventUtility.ConstructEvent(
            rawBody,
            signatureHeader,
            _options.WebhookSecret);

        var mapped = stripeEvent.Type switch
        {
            "payment_intent.succeeded" => MapPaymentIntent(stripeEvent, PaymentProviderEventType.Captured),
            "payment_intent.payment_failed" => MapPaymentIntent(stripeEvent, PaymentProviderEventType.Failed),
            "charge.refunded" => MapCharge(stripeEvent, PaymentProviderEventType.Refunded),
            "charge.dispute.created" => MapCharge(stripeEvent, PaymentProviderEventType.Disputed),
            _ => new VerifiedPaymentWebhook(
                stripeEvent.Id,
                string.Empty,
                PaymentProviderEventType.Unknown,
                null,
                null,
                DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created))
        };

        return Task.FromResult(mapped);
    }

    private static VerifiedPaymentWebhook MapPaymentIntent(
        Event stripeEvent,
        PaymentProviderEventType eventType)
    {
        var paymentIntent = stripeEvent.Data.Object as PaymentIntent
            ?? throw new PaymentProviderException("Stripe event did not contain a payment intent.");

        return new VerifiedPaymentWebhook(
            stripeEvent.Id,
            paymentIntent.Id,
            eventType,
            paymentIntent.Amount,
            paymentIntent.Currency,
            DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created));
    }

    private static VerifiedPaymentWebhook MapCharge(
        Event stripeEvent,
        PaymentProviderEventType eventType)
    {
        var charge = stripeEvent.Data.Object as Charge
            ?? throw new PaymentProviderException("Stripe event did not contain a charge.");

        return new VerifiedPaymentWebhook(
            stripeEvent.Id,
            charge.PaymentIntentId,
            eventType,
            charge.Amount,
            charge.Currency,
            DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created));
    }
}

Treat unknown events as safely accepted but not applied, or log them as low-risk operational events. Do not fail every unknown event. Providers add event types, and you do not want harmless events to become webhook retry storms.

Process webhooks idempotently

Payment providers retry webhooks. Networks fail. Your endpoint might process an event, commit the database transaction, then fail before returning 202 Accepted.

That means webhook processing must be idempotent.

The cleanest pattern is to store processed provider event ids. If the same event arrives again, return success without applying the transition twice.

using Microsoft.EntityFrameworkCore;
using Payments.Domain;
using Payments.Infrastructure;
using Payments.Providers;

namespace Payments.Application.Webhooks;

public sealed class PaymentWebhookHandler(
    PaymentsDbContext dbContext,
    IEnumerable<IPaymentProvider> providers)
{
    public async Task Handle(
        string providerName,
        string rawBody,
        string signatureHeader,
        CancellationToken stopToken)
    {
        var provider = providers.Single(x =>
            string.Equals(x.Name, providerName, StringComparison.OrdinalIgnoreCase));

        var verifiedEvent = await provider.VerifyWebhook(rawBody, signatureHeader, stopToken);

        if (verifiedEvent.EventType == PaymentProviderEventType.Unknown)
        {
            return;
        }

        var alreadyProcessed = await dbContext.ProcessedWebhooks
            .AnyAsync(x => x.ProviderEventId == verifiedEvent.ProviderEventId, stopToken);

        if (alreadyProcessed)
        {
            return;
        }

        var payment = await dbContext.Payments
            .SingleOrDefaultAsync(
                x => x.ProviderPaymentReference == verifiedEvent.ProviderPaymentReference,
                stopToken);

        if (payment is null)
        {
            throw new PaymentWebhookException(
                $"Payment with provider reference '{verifiedEvent.ProviderPaymentReference}' was not found.");
        }

        ApplyEvent(payment, verifiedEvent);

        dbContext.ProcessedWebhooks.Add(new ProcessedPaymentWebhook
        {
            Provider = provider.Name,
            ProviderEventId = verifiedEvent.ProviderEventId,
            ProcessedUtc = DateTimeOffset.UtcNow
        });

        dbContext.OutboxMessages.Add(new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = $"Payment{verifiedEvent.EventType}",
            Payload = PaymentOutboxPayloads.FromPayment(payment, verifiedEvent),
            OccurredUtc = DateTimeOffset.UtcNow
        });

        await dbContext.SaveChangesAsync(stopToken);
    }

    private static void ApplyEvent(Payment payment, VerifiedPaymentWebhook verifiedEvent)
    {
        switch (verifiedEvent.EventType)
        {
            case PaymentProviderEventType.Authorised:
                payment.MarkAuthorised(verifiedEvent.ProviderEventId);
                break;

            case PaymentProviderEventType.Captured:
                payment.MarkCaptured(verifiedEvent.ProviderEventId);
                break;

            case PaymentProviderEventType.Failed:
                payment.MarkFailed(verifiedEvent.ProviderEventId, "Provider reported payment failure.");
                break;

            case PaymentProviderEventType.Refunded:
            case PaymentProviderEventType.Disputed:
            case PaymentProviderEventType.Cancelled:
            case PaymentProviderEventType.Unknown:
            default:
                break;
        }
    }
}

public sealed class PaymentWebhookException(string message) : Exception(message);

In a high-throughput system, put a unique constraint on ProviderEventId. Then handle unique constraint violations as successful duplicate processing. Do not rely only on an AnyAsync check, because two identical webhook deliveries can race each other.

Use an outbox for payment events

Once the database says a payment was captured, other parts of the system need to know. The orders module might mark the order as paid. The fulfilment module might start shipping. The invoicing module might issue a receipt.

Do not publish those events directly from the request thread after saving the database. That creates a gap. The database commit can succeed and the publish can fail.

Use an outbox table written in the same transaction as the payment state change.

The outbox publisher can be a background service.

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Payments.Infrastructure;

namespace Payments.Workers;

public sealed class OutboxPublisher(
    IServiceScopeFactory scopeFactory,
    IMessageBus messageBus,
    ILogger<OutboxPublisher> logger)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        while (!stopToken.IsCancellationRequested)
        {
            await PublishBatch(stopToken);
            await Task.Delay(TimeSpan.FromSeconds(5), stopToken);
        }
    }

    private async Task PublishBatch(CancellationToken stopToken)
    {
        using var scope = scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<PaymentsDbContext>();

        var messages = await dbContext.OutboxMessages
            .Where(x => x.ProcessedUtc == null)
            .OrderBy(x => x.OccurredUtc)
            .Take(50)
            .ToListAsync(stopToken);

        foreach (var message in messages)
        {
            try
            {
                await messageBus.Publish(message.Type, message.Payload, stopToken);

                message.ProcessedUtc = DateTimeOffset.UtcNow;
            }
            catch (Exception ex)
            {
                logger.LogError(
                    ex,
                    "Failed to publish payment outbox message {OutboxMessageId} of type {OutboxMessageType}",
                    message.Id,
                    message.Type);
            }
        }

        await dbContext.SaveChangesAsync(stopToken);
    }
}

public interface IMessageBus
{
    Task Publish(string messageType, string payload, CancellationToken stopToken);
}

The outbox does not make the world exactly-once. It makes failure visible and recoverable. Consumers still need idempotency because message brokers can redeliver.

Redact aggressively in logs

Payment systems need strong observability, but observability must not become a data leak.

Do not log raw request bodies on payment endpoints. Do not log provider payloads. Do not log headers wholesale, because headers can contain secrets. Do not log query strings blindly. Do not send sensitive payloads to exception monitoring.

Use structured logs with safe fields.

logger.LogInformation(
    "Payment {PaymentId} for order {OrderId} moved to {PaymentStatus} using provider {PaymentProvider}",
    payment.Id,
    payment.OrderId,
    payment.Status,
    payment.Provider);

Avoid this.

logger.LogInformation("Provider webhook payload: {Payload}", rawBody);

You can add a payment redaction guard for accidental strings. This is not a replacement for careful logging, but it helps.

using System.Text.RegularExpressions;

namespace Payments.Infrastructure.Redaction;

public static partial class SensitivePaymentDataGuard
{
    public static string Redact(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return value;
        }

        var withoutPotentialCards = CardNumberPattern().Replace(value, "[REDACTED_CARD_NUMBER]");
        var withoutPotentialCvv = CvvPattern().Replace(withoutPotentialCards, "$1[REDACTED_CVV]");

        return withoutPotentialCvv;
    }

    public static bool ContainsLikelyCardData(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return false;
        }

        return CardNumberPattern().IsMatch(value) || CvvPattern().IsMatch(value);
    }

    [GeneratedRegex(@"\b(?:\d[ -]*?){13,19}\b", RegexOptions.Compiled)]
    private static partial Regex CardNumberPattern();

    [GeneratedRegex(@"(?i)\b(cvv|cvc|securityCode)\b\s*[:=]\s*(\d{3,4})", RegexOptions.Compiled)]
    private static partial Regex CvvPattern();
}

Regex redaction is imperfect. It can create false positives, and it can miss creative payload shapes. That is fine. Use it as a safety net, not a licence to log unsafe data.

You can also add middleware that blocks suspicious payment requests before they reach handlers.

namespace Payments.Infrastructure.Redaction;

public sealed class RejectRawCardDataMiddleware(RequestDelegate next)
{
    public async Task Invoke(HttpContext context)
    {
        if (!context.Request.Path.StartsWithSegments("/payments"))
        {
            await next(context);
            return;
        }

        context.Request.EnableBuffering();

        using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
        var body = await reader.ReadToEndAsync(context.RequestAborted);
        context.Request.Body.Position = 0;

        if (SensitivePaymentDataGuard.ContainsLikelyCardData(body))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;

            await context.Response.WriteAsJsonAsync(new
            {
                error = "Raw card data must not be sent to this API."
            });

            return;
        }

        await next(context);
    }
}

Be careful with this middleware on large request bodies. Payment endpoints should have small contracts anyway, so set request size limits as well.

Keep support tooling out of the card data path

Support teams need to answer payment questions. They do not need raw card data.

A support view should show safe payment facts.

{
  "paymentId": "7d7e7f2673a04bcb85f7ff3ac0d3f7f1",
  "orderId": "2bb7f0204f374473a40f86fcf445cc31",
  "status": "Captured",
  "provider": "stripe",
  "providerPaymentReference": "pi_abc123",
  "amountMinor": 12999,
  "currency": "EUR",
  "createdUtc": "2026-05-14T10:42:00Z",
  "capturedUtc": "2026-05-14T10:43:12Z"
}

Do not give support staff provider dashboards with broader permissions than they need. Do not copy raw provider event payloads into tickets. Do not ask customers to send card details through chat, email, or screenshots.

PCI-aware architecture includes humans. Humans are often the easiest way for sensitive data to escape the system.

Use CSP and script control on checkout pages

Even when your backend avoids card data, the checkout page still matters. If your site hosts a page that embeds provider payment fields, malicious JavaScript on that page can become a serious risk.

Use a tight Content Security Policy. Avoid arbitrary third-party scripts on checkout pages. Keep analytics, A/B testing, heatmaps, and chat widgets away from payment entry screens unless your compliance team has explicitly approved them.

A strict checkout page CSP might look like this.

default-src 'self';
script-src 'self' https://js.stripe.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
connect-src 'self' https://api.stripe.com;
img-src 'self' data:;
style-src 'self' 'unsafe-inline';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';

Do not copy this blindly. Each provider has specific script, frame, and connection requirements. The point is to make the checkout page boring and predictable.

Its worth checking your application on Securityheaders.com to see how strong the CSP & other headers are.

Do not let analytics rebuild the card data environment

Analytics pipelines are easy to forget.

A customer enters card details into a provider-controlled iframe. Good.

A frontend error tracker records DOM snapshots. Bad.

A session replay tool captures keystrokes. Very bad.

A reverse proxy logs full request bodies. Bad.

An API gateway stores payload samples. Bad.

A message bus dead-letter queue keeps failed provider payloads forever. Bad.

A PCI-aware design reviews the whole data path.

The safe claim should be testable.

Payment data in logs: safe.

Payment data in traces: safe.

Payment data in support views: safe.

Payment data in exports: safe.

Payment data in backups: safe.

If you cannot say that confidently, you do not understand your payment boundary yet.

Multi-provider routing without leaking provider details

Advanced payment systems often need multiple providers. You might route by region, currency, tenant, payment method, provider health, cost, or risk.

Keep routing separate from provider execution.

namespace Payments.Providers;

public interface IPaymentProviderRouter
{
    IPaymentProvider SelectProvider(PaymentRoutingContext context);
}

public sealed record PaymentRoutingContext(
    Guid TenantId,
    string Country,
    string Currency,
    long AmountMinor);

public sealed class PaymentProviderRouter(IEnumerable<IPaymentProvider> providers)
    : IPaymentProviderRouter
{
    public IPaymentProvider SelectProvider(PaymentRoutingContext context)
    {
        if (context.Currency.Equals("EUR", StringComparison.OrdinalIgnoreCase))
        {
            return providers.Single(x => x.Name == "stripe");
        }

        return providers.Single(x => x.Name == "adyen");
    }
}

Do not expose provider selection to the frontend unless you have a strong reason. The server should decide the provider, persist the decision, and process all later webhooks against that provider.

Provider routing adds operational complexity. You need provider-specific webhook endpoints, provider-specific idempotency, provider-specific reconciliation, and provider-specific incident handling. Do it when you need it, not because it looks elegant in a diagram.

Reconciliation closes the gap

Even with webhooks, you need reconciliation.

Webhooks can be delayed. Your endpoint can be down. A provider can send events in an unexpected order. A manual refund can happen in the provider dashboard. Chargebacks can arrive later.

A reconciliation job compares your internal payment records with provider-side truth.

A minimal discrepancy model can look like this.

public sealed class PaymentDiscrepancy
{
    public Guid Id { get; init; }

    public Guid PaymentId { get; init; }

    public string Provider { get; init; } = string.Empty;

    public string ProviderPaymentReference { get; init; } = string.Empty;

    public string InternalStatus { get; init; } = string.Empty;

    public string ProviderStatus { get; init; } = string.Empty;

    public string Severity { get; init; } = string.Empty;

    public DateTimeOffset DetectedUtc { get; init; }

    public DateTimeOffset? ResolvedUtc { get; set; }
}

Reconciliation is not an afterthought. It is part of making payment state trustworthy.

Deployment boundaries

A PCI-aware payment service should have narrower access than the rest of the system.

That means separate deployment identity, separate Key Vault access, separate database permissions, separate logs, separate alerting, and separate incident runbooks.

In Azure, a reasonable production layout might look like this.

The orders API does not need the payment provider secret. The payment API does not need write access to order internals. The outbox worker does not need access to webhook secrets. Keep the permissions boring.

Threat model the payment boundary

A lightweight threat model is better than a compliance spreadsheet that nobody reads.

For each payment boundary, ask what can go wrong.

Boundary Threat Control
Checkout page Malicious script captures card input Hosted page, strict CSP, controlled scripts
Start payment endpoint Customer starts payment for another order Authorise order ownership
Provider API call Retry creates duplicate charge/session Idempotency key
Webhook endpoint Fake provider event marks order paid Signature verification
Webhook processing Duplicate event applies transition twice Processed event table and unique constraint
Logs Raw provider payload leaks sensitive data Structured safe logs and redaction
Database Provider payload dump stores sensitive fields Explicit schema, no raw payload persistence
Secrets Provider key exposed to unrelated app Managed identity and narrow Key Vault access
Support Staff sees more than needed Safe support DTOs and role-based access
Reconciliation Provider and internal state drift Scheduled comparison and discrepancy workflow

This does not need to be heavy.

A practical readiness check for .NET developers

Before shipping a payment integration, the main question is whether the architecture keeps sensitive card data outside your system. The backend should never accept card numbers, CVV values, track data, PIN data, or anything else that would pull the application into direct card handling. The checkout flow should use either a hosted provider page or provider-controlled embedded fields, so the customer enters payment details into the provider’s environment rather than yours.

The payment API should store only the references it needs to run the business process. That means internal payment ids, order ids, provider payment references, provider names, payment statuses, amounts, currencies, and safe audit metadata. It should not store raw provider payloads just because they are convenient. The database schema should make that boundary obvious. If you see columns such as CardNumber, Cvv, or raw unfiltered provider request bodies, the design has already drifted.

Secrets should also have a clear boundary. Provider credentials should come from a proper secret store such as Azure Key Vault, and deployed applications should use managed identity rather than shared credentials or long-lived secrets in configuration files. Access should be narrow. The payment service may need the provider secret, but the orders API, reporting jobs, and support tools usually do not.

Webhook handling needs the same level of discipline. The webhook endpoint should verify provider signatures using the raw request body before trusting the event. Processing should be idempotent, because providers retry events and duplicate delivery is normal. Payment state changes should be saved alongside outbox messages so that downstream systems are notified reliably without creating a gap between the database update and the published event.

Observability should help you operate the system without leaking payment data. Logs should contain payment ids, order ids, provider names, statuses, and safe error details. They should not contain raw provider payloads, request bodies, card data, or sensitive headers. The same rule applies to support tooling, session replay, analytics, error monitoring, API gateways, and dead-letter queues. If those systems can capture payment entry data, they have quietly become part of the risk surface.

A safe payment system also needs recovery paths. Reconciliation should exist so you can compare internal payment state with provider-side truth. A failure in the order system should not cause a second charge. A retry from the provider should not apply the same state transition twice. A developer should not be able to add CardNumber to a public payment request without a test failing. The goal is not just to pass a review. The goal is to make unsafe changes difficult to introduce by accident.

Common mistakes

The most common mistake is building a custom card form too early. It can feel like a better user experience, but it changes the compliance and security shape of the system. Unless there is a strong business reason and the team has the maturity to operate that boundary safely, hosted checkout or provider-controlled fields are usually the better choice.

Another mistake is trusting redirect URLs. A browser redirect is useful for the customer journey, but it is not proof that money moved. Customers can close the browser, refresh pages, block redirects, or manually call success URLs. Final payment state should come from verified provider events, not from the fact that the user landed on a success page.

Teams also get into trouble by logging too much. Raw provider events are tempting during development because they make debugging easier, but logs spread into monitoring tools, exports, backups, tickets, and incident channels. Once sensitive data reaches those places, cleanup becomes painful. Store and log explicit safe fields instead.

Storing raw provider payloads creates the same problem in the database. The argument is usually that the team might need the data later. That is understandable, but it is still risky. If the system needs a field, model it directly. If the system does not need it, do not store it. A payment database should be boring and intentional.

Webhook handling is another place where systems are often too casual. A webhook is not just a callback. It is a public integration boundary that can change payment state. It needs signature verification, replay protection, idempotent processing, safe error handling, and a clear approach to event ordering.

A broader operational mistake is using one application identity for too much. The payment service should not share the same secret access as the rest of the platform. Keep permissions narrow so that a compromise or bug in one area does not expose payment provider credentials unnecessarily.

The final mistake is skipping reconciliation. Even if the webhook flow is well designed, provider and internal state can drift. Webhooks can be delayed, dashboards can be used manually, refunds can happen outside your application, and chargebacks can arrive later. At some point you will need to explain why your database says one thing and the provider says another. Reconciliation gives you a controlled way to find and fix those gaps.

A good .NET payment architecture is not just an ASP.NET Core endpoint wrapped around a provider SDK. It is a set of boundaries. The provider collects sensitive card data. Your system stores business payment state. Webhooks move state forward. The outbox publishes facts. Reconciliation catches drift. Logs and support tools stay safe.

That is the architecture worth aiming for.

Further reading

PCI Security Standards Council, PCI DSS v4.0.1 Document Library:

PCI Security Standards Council, SAQ A eligibility clarification for e-commerce merchants:

Stripe, Integration security guide:

Stripe, Webhook signature verification:

Stripe, Idempotent requests:

Microsoft, Azure Key Vault configuration provider in ASP.NET Core:

Microsoft, Secure your Azure Key Vault:

OWASP, Secrets Management Cheat Sheet: