Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 070 / 100

Payment gateway integration lessons for .NET developers

What I learned integrating Stripe, Razorpay and local gateways.

Expona Admin13 min read

Payment Gateway Integration Lessons

I've integrated three different payment gateways across e-commerce and SaaS platforms, and every time I thought I knew what I was doing, production threw me a curveball. Payment gateway integration lessons aren't found in API docs—they're earned through late-night debugging sessions and reconciliation nightmares. Here's what I learned the hard way so you don't have to.

Never Trust the Redirect — The Webhook Is the Source of Truth

This is the one that bit us hardest. A user completes payment on Stripe, gets redirected back to our success page, and we mark the order as paid. Then the webhook arrives 2 seconds later confirming the charge. Seems fine. But what if the redirect breaks?

The browser can drop the redirect. A proxy can intercept it. A user can close the tab mid-redirect. Meanwhile, the payment actually went through on the gateway's side. If we only trust redirects, we're flying blind.

Here's the pattern that works:

csharp
public class PaymentService
{
    // Never mark payment as complete on redirect alone
    public async Task<OrderDto> HandleCheckoutRedirect(string sessionId)
    {
        var session = await _stripe.Sessions.GetAsync(sessionId);
        
        // Redirect just tells us what to do next
        if (session.PaymentStatus == "paid")
        {
            // Still check database—webhook should have already updated it
            var order = await _db.Orders.FirstOrDefaultAsync(o => o.SessionId == sessionId);
            if (order?.PaymentStatus == OrderPaymentStatus.Captured)
            {
                return MapToDto(order);
            }
            // If not captured yet, show "processing" instead of assuming success
            return MapToDto(order);
        }
        
        return null;
    }

    // Webhook is the source of truth
    public async Task HandleStripeWebhook(Event @event)
    {
        switch (@event.Type)
        {
            case "checkout.session.completed":
                var session = @event.Data.Object as Session;
                var order = await _db.Orders.FirstOrDefaultAsync(o => o.SessionId == session.Id);
                order.PaymentStatus = OrderPaymentStatus.Captured;
                order.PaidAt = DateTime.UtcNow;
                await _db.SaveChangesAsync();
                
                // Now trigger fulfillment, send email, etc.
                await _fulfillmentService.StartFulfillment(order.Id);
                break;
        }
    }
}

The redirect is a UI hint. The webhook is the contract. We process critical state changes only when we receive webhook events, after verifying the signature.

Idempotency Everywhere — Charge Requests Carry an Idempotency Key. Retries Never Double-Charge

Network timeouts happen. Your charge request to Stripe succeeds, the payment is made, but the response times out before it reaches your code. Do you retry? If you retry without idempotency, you charge the customer twice.

Every payment gateway worth using supports idempotency keys. These are unique identifiers you pass with the request. The gateway guarantees that two requests with the same key will only charge once, even if called 100 times.

Here's how I structure it:

csharp
public interface IPaymentGateway
{
    Task<ChargeResult> ChargeAsync(ChargeRequest request, string idempotencyKey);
    Task<RefundResult> RefundAsync(string chargeId, string idempotencyKey);
}

public class StripePaymentGateway : IPaymentGateway
{
    public async Task<ChargeResult> ChargeAsync(ChargeRequest request, string idempotencyKey)
    {
        var options = new PaymentIntentCreateOptions
        {
            Amount = request.AmountInCents,
            Currency = request.Currency,
            PaymentMethod = request.PaymentMethodId,
            Confirm = true,
            IdempotencyKey = idempotencyKey, // Critical
        };

        try
        {
            var intent = await _stripeClient.PaymentIntents.CreateAsync(options);
            return new ChargeResult
            {
                Success = intent.Status == "succeeded",
                GatewayChargeId = intent.Id,
                TransactionId = intent.Id,
            };
        }
        catch (StripeException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Conflict)
        {
            // This idempotency key was already processed
            // Fetch the original result
            var existingIntent = await _stripeClient.PaymentIntents.GetAsync(idempotencyKey);
            return new ChargeResult
            {
                Success = existingIntent.Status == "succeeded",
                GatewayChargeId = existingIntent.Id,
                TransactionId = existingIntent.Id,
                WasRetry = true,
            };
        }
    }
}

public class OrderCheckoutService
{
    public async Task<ChargeResult> ProcessPaymentWithRetry(Order order)
    {
        // Generate idempotency key once, use it for all retries
        string idempotencyKey = $"order-{order.Id}-{order.PaymentAttempt}";
        
        var request = new ChargeRequest
        {
            AmountInCents = order.TotalInCents,
            Currency = "USD",
            PaymentMethodId = order.PaymentMethodId,
        };

        // Retry with exponential backoff, same key every time
        int maxRetries = 3;
        for (int i = 0; i < maxRetries; i++)
        {
            try
            {
                return await _paymentGateway.ChargeAsync(request, idempotencyKey);
            }
            catch (Exception ex) when (i < maxRetries - 1)
            {
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)));
            }
        }

        return new ChargeResult { Success = false };
    }
}

The idempotency key should be deterministic (same order, same attempt = same key) so retries don't create duplicate charges. Razorpay calls this X-Razorpay-Request-Id, Stripe calls it IdempotencyKey—the principle is identical.

Model Payment States — Pending, Authorized, Captured, Failed, Refunded. A State Machine Beats Boolean Flags

I've seen codebases with IsPaymentProcessed, IsPaid, and IsChargeAttempted booleans scattered across the order table. That's chaos. A payment has discrete states, and you need to model them explicitly.

Here's the state machine I use:

csharp
public enum OrderPaymentStatus
{
    Pending = 0,           // No charge attempt yet
    Authorized = 1,        // Payment method validated, charge approved but not settled
    Captured = 2,          // Money taken from customer, in settlement cycle
    CaptureFailed = 3,     // Charge declined or failed
    Refunded = 4,          // Full refund issued
    PartiallyRefunded = 5, // Partial refund issued
    Disputed = 6,          // Chargeback filed
}

public class Order
{
    public int Id { get; set; }
    public OrderPaymentStatus PaymentStatus { get; set; }
    public string GatewayChargeId { get; set; }
    public decimal AmountCapturedInCents { get; set; }
    public decimal AmountRefundedInCents { get; set; }
    public DateTime? CapturedAt { get; set; }
    public DateTime? RefundedAt { get; set; }
}

public class OrderPaymentStateMachine
{
    public bool CanCapture(Order order) => order.PaymentStatus == OrderPaymentStatus.Authorized;
    public bool CanRefund(Order order) => 
        order.PaymentStatus == OrderPaymentStatus.Captured || 
        order.PaymentStatus == OrderPaymentStatus.PartiallyRefunded;
    public bool CanDispute(Order order) =>
        order.PaymentStatus is OrderPaymentStatus.Captured or OrderPaymentStatus.PartiallyRefunded;

    public void TransitionToCaptured(Order order)
    {
        if (!CanCapture(order))
            throw new InvalidOperationException($"Cannot capture from {order.PaymentStatus}");
        
        order.PaymentStatus = OrderPaymentStatus.Captured;
        order.CapturedAt = DateTime.UtcNow;
    }

    public void TransitionToRefunded(Order order, decimal amountInCents)
    {
        if (!CanRefund(order))
            throw new InvalidOperationException($"Cannot refund from {order.PaymentStatus}");
        
        order.AmountRefundedInCents += amountInCents;
        
        if (order.AmountRefundedInCents >= order.TotalInCents)
            order.PaymentStatus = OrderPaymentStatus.Refunded;
        else
            order.PaymentStatus = OrderPaymentStatus.PartiallyRefunded;
        
        order.RefundedAt = DateTime.UtcNow;
    }
}

This approach prevents invalid transitions (you can't refund a Pending order) and makes the payment lifecycle explicit and auditable.

Abstract the Gateway — An IPaymentGateway Interface Let Us Add a Second Provider in Days, Not Weeks

We started with Stripe. Six months in, we needed Razorpay for international expansion. If payment logic was tightly coupled to Stripe's SDK, this would've taken weeks. Instead, because we'd abstracted behind an interface, we added Razorpay in four days.

csharp
public interface IPaymentGateway
{
    Task<ChargeResult> ChargeAsync(ChargeRequest request, string idempotencyKey);
    Task<RefundResult> RefundAsync(string chargeId, decimal amountInCents, string idempotencyKey);
    Task<PaymentMethodValidationResult> ValidatePaymentMethodAsync(string paymentMethodId);
    Task<SettlementStatus> GetSettlementStatusAsync(string chargeId);
}

public class RazorpayPaymentGateway : IPaymentGateway
{
    private readonly RazorpayClient _client;

    public async Task<ChargeResult> ChargeAsync(ChargeRequest request, string idempotencyKey)
    {
        var paymentOptions = new Dictionary<string, object>
        {
            { "amount", request.AmountInCents },
            { "currency", request.Currency },
            { "customer_id", request.CustomerId },
            { "email", request.CustomerEmail },
            { "contact", request.CustomerPhone },
            { "receipt", idempotencyKey }, // Razorpay uses receipt for idempotency
            { "description", request.Description },
        };

        try
        {
            var payment = _client.Payment.Create(paymentOptions);
            return new ChargeResult
            {
                Success = payment["status"] == "captured",
                GatewayChargeId = payment["id"],
                TransactionId = payment["id"],
                RawResponse = payment,
            };
        }
        catch (Exception ex)
        {
            return new ChargeResult
            {
                Success = false,
                ErrorMessage = ex.Message,
            };
        }
    }

    public async Task<RefundResult> RefundAsync(string chargeId, decimal amountInCents, string idempotencyKey)
    {
        var refund = _client.Payment.Refund(chargeId, new Dictionary<string, object>
        {
            { "amount", amountInCents },
        });

        return new RefundResult
        {
            Success = refund["status"] == "processed",
            GatewayRefundId = refund["id"],
        };
    }

    // ... other methods
}

// In dependency injection:
services.AddScoped<IPaymentGateway>(provider =>
{
    var gatewayType = configuration["Payment:Gateway"]; // "Stripe" or "Razorpay"
    if (gatewayType == "Razorpay")
        return new RazorpayPaymentGateway(razorpayClient);
    else
        return new StripePaymentGateway(stripeClient);
});

// Business logic never references Stripe or Razorpay directly
public class OrderPaymentService
{
    private readonly IPaymentGateway _gateway;

    public async Task<bool> ChargeOrder(Order order)
    {
        var result = await _gateway.ChargeAsync(
            new ChargeRequest { /* ... */ },
            $"order-{order.Id}"
        );
        return result.Success;
    }
}

The abstraction isn't perfect—some gateway features won't map cleanly—but it buys flexibility. We can even support multiple gateways simultaneously (Stripe primary, Razorpay fallback) by injecting a composite gateway.

Reconcile Daily — A Job Compares Gateway Settlements with Your Records. Mismatches Surface Early

Gateways and your database will drift. A charge succeeds on Stripe but your webhook processor crashes. A refund is issued on your side but the customer disputes it. A settlement is delayed. Without reconciliation, you'll never know.

I run a daily job that downloads settlement data from the gateway and compares it with our records:

csharp
public class PaymentReconciliationJob
{
    private readonly IPaymentGateway _gateway;
    private readonly ApplicationDbContext _db;
    private readonly ILogger<PaymentReconciliationJob> _logger;

    public async Task ExecuteAsync(DateTime fromDate)
    {
        // Fetch settlements from gateway for the past 24 hours
        var gatewayCharges = await _gateway.GetSettlementsAsync(fromDate, DateTime.UtcNow);

        foreach (var gatewayCharge in gatewayCharges)
        {
            var localOrder = await _db.Orders
                .FirstOrDefaultAsync(o => o.GatewayChargeId == gatewayCharge.Id);

            if (localOrder == null)
            {
                // Charge exists on gateway but not in our database
                _logger.LogError($"Orphaned charge found: {gatewayCharge.Id}");
                await NotifySlackAsync($"Orphaned charge: {gatewayCharge.Id} for {gatewayCharge.Amount}");
                continue;
            }

            // Compare amounts
            if (localOrder.TotalInCents != gatewayCharge.AmountInCents)
            {
                _logger.LogError($"Amount mismatch for order {localOrder.Id}: " +
                    $"local {localOrder.TotalInCents} vs gateway {gatewayCharge.AmountInCents}");
            }

            // Compare status
            var expectedStatus = MapGatewayStatusToLocal(gatewayCharge.Status);
            if (localOrder.PaymentStatus != expectedStatus)
            {
                _logger.LogWarning($"Status mismatch for order {localOrder.Id}: " +
                    $"local {localOrder.PaymentStatus} vs gateway {expectedStatus}");
                
                // Sync to gateway state (gateway is source of truth)
                if (expectedStatus == OrderPaymentStatus.Refunded)
                    localOrder.PaymentStatus = OrderPaymentStatus.Refunded;
            }

            // Check for disputes/chargebacks
            if (gatewayCharge.Status == "disputed")
            {
                localOrder.PaymentStatus = OrderPaymentStatus.Disputed;
                await NotifySlackAsync($"Chargeback filed for order {localOrder.Id}");
            }
        }

        // Check for local charges that didn't settle
        var unsettledOrders = await _db.Orders
            .Where(o => o.PaymentStatus == OrderPaymentStatus.Captured 
                && o.CapturedAt < DateTime.UtcNow.AddDays(-2)
                && !gatewayCharges.Any(g => g.Id == o.GatewayChargeId))
            .ToListAsync();

        foreach (var order in unsettledOrders)
        {
            _logger.LogError($"Order {order.Id} captured 2+ days ago but not in settlements");
            await NotifySlackAsync($"Stale charge: {order.GatewayChargeId}");
        }

        await _db.SaveChangesAsync();
    }

    private OrderPaymentStatus MapGatewayStatusToLocal(string gatewayStatus)
    {
        return gatewayStatus switch
        {
            "succeeded" or "captured" => OrderPaymentStatus.Captured,
            "refunded" => OrderPaymentStatus.Refunded,
            "failed" => OrderPaymentStatus.CaptureFailed,
            "disputed" => OrderPaymentStatus.Disputed,
            _ => OrderPaymentStatus.Pending,
        };
    }
}

// Schedule daily
services.AddHostedService<ReconciliationHostedService>();

public class ReconciliationHostedService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var timer = new PeriodicTimer(TimeSpan.FromHours(24));
        using (timer)
        {
            do
            {
                try
                {
                    await _reconciliationJob.ExecuteAsync(DateTime.UtcNow.AddDays(-1));
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Reconciliation job failed");
                }
            } while (await timer.WaitForNextTickAsync(stoppingToken));
        }
    }
}

This job catches issues that webhooks miss and gives you confidence that your ledger matches reality. Run it daily, send alerts to Slack, and investigate mismatches within hours.

Common Mistakes

  • Treating the redirect as confirmation. It's a hint, not a guarantee. Wait for the webhook.
  • Retrying without idempotency keys. You will double-charge someone. Use idempotency keys religiously.
  • Coupling business logic to gateway SDKs. When you need a second gateway, you'll regret it. Abstract from day one.
  • Ignoring webhook signature verification. Anyone can POST to your webhook endpoint. Always verify the signature with the gateway's public key.
  • Skipping reconciliation. You'll find discrepancies months later when customers complain. Run it daily.
  • Not handling partial refunds. Your schema should support it from the start, even if you don't use it yet.
  • Assuming webhook delivery. Gateways retry webhooks for days, but they're not guaranteed. Don't rely solely on webhooks for critical data—reconciliation catches gaps.

Key Takeaways

  • The webhook is the source of truth, never the browser redirect. Confirm payment state from the gateway, not the UI.
  • Idempotency keys prevent double-charges on retry. Generate them deterministically and use them for every charge and refund request.
  • Model payment states explicitly as an enum (Pending, Authorized, Captured, Refunded, etc.). State machines beat scattered boolean flags and prevent invalid transitions.
  • Abstract payment logic behind an IPaymentGateway interface. Adding a new gateway should take days, not weeks.
  • Reconcile daily by downloading settlements from the gateway and comparing them to your records. This catches webhook failures, API bugs, and disputes early.
  • Always verify webhook signatures using the gateway's public key. Trust the gateway, verify the signature.
  • Keep gateway-specific logic isolated. Let the interface handle the mapping between different gateway APIs and your domain model.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles