dotnet-9.0aspnetcore-9.0efcore-9.0Implementing 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:
- 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. - Atomic Locks: Prevent concurrent HTTP requests from processing the same reference simultaneously.
- State Machine Integrity: Payment status transitions must follow a strict, one-way workflow:
Pending→Processing→Completed(orFailed/Abandoned). - Duplicate Webhook Safety: Webhooks received for transactions already marked
Completedmust 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
- Database Indexing: Create a UNIQUE index on
PaymentTransaction.Referencein your database migration. - Distributed Cache / Locking: For high-concurrency applications, use Redis distributed locks (
IDistributedCache/ Medallion) during initial checkout creation. - Audit Logging: Log every state transition with timestamps and source IP / webhook event IDs for reconciliation and dispute handling.
6. Official Provenance
- Paystack Webhook Protocol: https://paystack.com/docs/payments/webhooks/
- Flutterwave Verification Docs: https://developer.flutterwave.com/docs/integration-guides/webhooks/
- Monnify Developer Portal: https://docs.monnify.com
- Last Tested: August 22, 2026 UTC