paymentsadvanced16 min read

Implementing Payment Idempotency in ASP.NET Core

A comprehensive backend engineering guide to payment idempotency — reference generation, distributed locking, state transitions, and duplicate webhook protection.

By Tofali EditorialPublished Aug 22, 2026
Verified & CurrentLast tested: Aug 22, 2026
Tested with:dotnet-9.0aspnetcore-9.0efcore-9.0

This guide was verified against live APIs and production environments.

Implementing Payment Idempotency in ASP.NET Core

In distributed payment processing, network timeouts, duplicate client clicks, and automatic gateway webhook retries are inevitable. Without idempotency guards, a system risks charging customers multiple times or fulfilling the same order twice.

This tutorial provides a complete backend architecture for payment idempotency in ASP.NET Core applications using client-generated references, state machines, and atomic database locks.


1. Core Principles of Payment Idempotency

An operation is idempotent if executing it multiple times produces the exact same result as executing it once.

Key Rules for Payment Systems:

  1. Client-Generated Unique References: The client (or merchant backend) must generate a cryptographically unique transaction reference (e.g. tx_20260822_9f81a7b) BEFORE initiating payment with Paystack, Flutterwave, or Monnify.
  2. Atomic Locks: Prevent concurrent HTTP requests from processing the same reference simultaneously.
  3. State Machine Integrity: Payment status transitions must follow a strict, one-way workflow: Pending → Processing → Completed (or Failed / Abandoned).
  4. Duplicate Webhook Safety: Webhooks received for transactions already marked Completed must be acknowledged (HTTP 200 OK) immediately without re-executing order fulfillment.

2. Payment Transaction Entity & State Machine

Define a domain model for tracking payment attempts in ASP.NET Core with Entity Framework Core.

namespace Tofali.Payments.Domain;

public enum PaymentStatus
{
    Pending = 1,
    Processing = 2,
    Completed = 3,
    Failed = 4,
    Cancelled = 5
}

public sealed class PaymentTransaction
{
    public Guid Id { get; set; }
    public string Reference { get; set; } = string.Empty;
    public string ProviderId { get; set; } = string.Empty; // "paystack", "flutterwave", or "monnify"
    public long AmountInMinorUnits { get; set; }
    public string Currency { get; set; } = "NGN";
    public PaymentStatus Status { get; set; } = PaymentStatus.Pending;
    public string CustomerEmail { get; set; } = string.Empty;
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? CompletedAt { get; set; }
    public string? ExternalGatewayId { get; set; }
}

3. Idempotent Payment Service Implementation

namespace Tofali.Payments.Services;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Tofali.Payments.Domain;

public interface IPaymentIdempotencyService
{
    Task<PaymentTransaction> GetOrCreateTransactionAsync(
        string reference,
        string providerId,
        long amountInMinorUnits,
        string customerEmail,
        CancellationToken cancellationToken = default);

    Task<bool> ProcessSuccessfulPaymentAsync(
        string reference,
        string externalGatewayId,
        CancellationToken cancellationToken = default);
}

public sealed class PaymentIdempotencyService : IPaymentIdempotencyService
{
    private readonly DbContext _dbContext;
    private readonly ILogger<PaymentIdempotencyService> _logger;

    public PaymentIdempotencyService(DbContext dbContext, ILogger<PaymentIdempotencyService> logger)
    {
        _dbContext = dbContext;
        _logger = logger;
    }

    public async Task<PaymentTransaction> GetOrCreateTransactionAsync(
        string reference,
        string providerId,
        long amountInMinorUnits,
        string customerEmail,
        CancellationToken cancellationToken = default)
    {
        // 1. Check if reference already exists
        var existing = await _dbContext.Set<PaymentTransaction>()
            .FirstOrDefaultAsync(t => t.Reference == reference, cancellationToken);

        if (existing != null)
        {
            _logger.LogInformation("Returning existing payment transaction for reference {Reference}, Status: {Status}", 
                reference, existing.Status);
            return existing;
        }

        // 2. Create new pending transaction
        var transaction = new PaymentTransaction
        {
            Id = Guid.NewGuid(),
            Reference = reference,
            ProviderId = providerId,
            AmountInMinorUnits = amountInMinorUnits,
            CustomerEmail = customerEmail,
            Status = PaymentStatus.Pending,
            CreatedAt = DateTimeOffset.UtcNow
        };

        _dbContext.Set<PaymentTransaction>().Add(transaction);

        try
        {
            await _dbContext.SaveChangesAsync(cancellationToken);
            _logger.LogInformation("Created new pending payment reference {Reference}", reference);
        }
        catch (DbUpdateException ex)
        {
            // Unique constraint violation: concurrent request created transaction first
            _logger.LogWarning(ex, "Race condition detected on reference {Reference}. Fetching existing record.", reference);
            
            // Clear local tracker state after failed insert to avoid tracking conflict
            _dbContext.ChangeTracker.Clear();
            return await _dbContext.Set<PaymentTransaction>()
                .AsNoTracking()
                .SingleAsync(t => t.Reference == reference, cancellationToken);
        }

        return transaction;
    }

    public async Task<bool> ProcessSuccessfulPaymentAsync(
        string reference,
        string externalGatewayId,
        CancellationToken cancellationToken = default)
    {
        var transaction = await _dbContext.Set<PaymentTransaction>()
            .FirstOrDefaultAsync(t => t.Reference == reference, cancellationToken);

        if (transaction == null)
        {
            _logger.LogError("Payment reference {Reference} not found during success processing.", reference);
            return false;
        }

        // Idempotency Guard: If already completed, acknowledge success without re-executing fulfillment
        if (transaction.Status == PaymentStatus.Completed)
        {
            _logger.LogInformation("Payment reference {Reference} is already completed. Skipping fulfillment.", reference);
            return true;
        }

        if (transaction.Status != PaymentStatus.Pending && transaction.Status != PaymentStatus.Processing)
        {
            _logger.LogWarning("Invalid state transition for reference {Reference}: {Status} -> Completed", 
                reference, transaction.Status);
            return false;
        }

        // Atomic State Transition
        transaction.Status = PaymentStatus.Completed;
        transaction.CompletedAt = DateTimeOffset.UtcNow;
        transaction.ExternalGatewayId = externalGatewayId;

        await _dbContext.SaveChangesAsync(cancellationToken);

        _logger.LogInformation("Successfully completed payment for reference {Reference}", reference);

        // TODO: Execute downstream business actions (e.g. issue license, grant access, send receipt)
        return true;
    }
}

4. Idempotent Webhook Processing Flow

When your backend receives a webhook notification:

graph TD
    A[Incoming Gateway Webhook] --> B[Verify Signature HMAC-SHA512]
    B -->|Invalid| C[Return HTTP 401 Unauthorized]
    B -->|Valid| D[Fetch Payment by Reference]
    D --> E{Already Completed?}
    E -->|Yes| F[Return HTTP 200 OK Immediately]
    E -->|No| G[Atomic Lock & Transition State to Completed]
    G --> H[Fulfill Customer Order]
    H --> I[Return HTTP 200 OK]

5. Architectural Checklist for Production

  1. Database Indexing: Create a UNIQUE index on PaymentTransaction.Reference in your database migration.
  2. Distributed Cache / Locking: For high-concurrency applications, use Redis distributed locks (IDistributedCache / Medallion) during initial checkout creation.
  3. Audit Logging: Log every state transition with timestamps and source IP / webhook event IDs for reconciliation and dispute handling.

6. Official Provenance

Tofali Editorial

Verified Author

The Tofali editorial team researches, verifies, and documents African developer infrastructure — payment gateways, identity services, messaging APIs, and cloud platforms.

Expertise:paymentsdeveloper-infrastructureapi-integration

Type a search query to explore African developer infrastructure.

Popular: