Idempotency keys for payment APIs in ASP.NET Core

Short answer: an idempotency key is a unique value the client sends with a request so the server can recognise a retry. The first request with a key does the work; every later request with the same key gets the original result back instead of charging, crediting or transferring again. In ASP.NET Core you need two things: a lookup by key, and a unique index in the database that makes the lookup race-proof.

A real example: the double callback

A user pays 500,000 Rials into their wallet through a payment gateway. The gateway calls your callback URL to confirm the payment. Your server credits the wallet, but the response is slow, so the gateway times out and calls again. Meanwhile, the user sees a spinner and taps "Pay" a second time.

Without idempotency, that is up to three credits for one payment. With an idempotency key (for a callback, the gateway's own transaction reference works well), the second and third calls find the first one and return its result. The balance moves exactly once.

Runnable code

A minimal API with EF Core. Create it with dotnet new web, add Microsoft.EntityFrameworkCore.Sqlite, and replace Program.cs:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<PaymentsDb>(options => options.UseSqlite("Data Source=payments.db"));

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    scope.ServiceProvider.GetRequiredService<PaymentsDb>().Database.EnsureCreated();
}

app.MapPost("/payments", async (
    [Microsoft.AspNetCore.Mvc.FromHeader(Name = "Idempotency-Key")] string? key,
    PaymentRequest body,
    PaymentsDb db,
    CancellationToken ct) =>
{
    if (string.IsNullOrWhiteSpace(key) || key.Length > 64)
    {
        return Results.BadRequest("Send an Idempotency-Key header (max 64 characters).");
    }

    // 1. Seen this key before? Return the original result instead of paying twice.
    var existing = await db.Payments.AsNoTracking().FirstOrDefaultAsync(p => p.IdempotencyKey == key, ct);
    if (existing is not null)
    {
        return Replay(existing, body);
    }

    // 2. First time: record the payment. The unique index on IdempotencyKey is the real guard.
    var payment = new Payment
    {
        IdempotencyKey = key,
        WalletId = body.WalletId,
        AmountRials = body.AmountRials,
        CreatedAt = DateTime.UtcNow,
    };
    db.Payments.Add(payment);

    try
    {
        await db.SaveChangesAsync(ct);
    }
    catch (DbUpdateException)
    {
        // 3. Two identical requests raced past step 1; the database let exactly one in.
        db.ChangeTracker.Clear();
        var winner = await db.Payments.AsNoTracking().FirstAsync(p => p.IdempotencyKey == key, ct);
        return Replay(winner, body);
    }

    return Results.Ok(payment);
});

app.Run();

// Same key + same request → same answer. Same key + different request → a client bug worth surfacing.
static IResult Replay(Payment original, PaymentRequest body) =>
    original.WalletId == body.WalletId && original.AmountRials == body.AmountRials
        ? Results.Ok(original)
        : Results.Conflict("This Idempotency-Key was already used for a different request.");

public record PaymentRequest(Guid WalletId, long AmountRials);

public class Payment
{
    public int Id { get; set; }
    public required string IdempotencyKey { get; set; }
    public Guid WalletId { get; set; }
    public long AmountRials { get; set; }
    public DateTime CreatedAt { get; set; }
}

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

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Payment>().HasIndex(p => p.IdempotencyKey).IsUnique();
        modelBuilder.Entity<Payment>().Property(p => p.IdempotencyKey).HasMaxLength(64);
    }
}

Try it:

curl -X POST localhost:5000/payments -H "Idempotency-Key: abc-1" -H "Content-Type: application/json" \
     -d '{"walletId":"3fa85f64-5717-4562-b3fc-2c963f66afa6","amountRials":500000}'

Run it twice and you get the same payment back both times. Change the amount but keep the key, and you get 409 Conflict.

Three details carry the weight:

  • The unique index, not the if. The lookup in step 1 is a fast path; the index in step 3 is the guarantee.
  • Replay compares the request. Reusing a key for a different amount is a client bug, and silently returning the old payment would hide it.
  • Amounts are integers in Rials. No floating point anywhere near money.

Pros and cons

Pros

  • Retries become safe, so clients and gateways can retry aggressively on timeouts.
  • The rule lives in one place (the database constraint), not in every caller.
  • Replays are cheap: one indexed lookup.

Cons

  • Keys must be stored and eventually cleaned up; decide a retention period.
  • This sample replays the stored entity, not the exact original HTTP response. A production API often stores the response status and body with the key.
  • The client has to generate and keep the key per operation, which is a contract you need to document.

Gateway callbacks: the key already exists

For payment-gateway callbacks you don't invent a key; the gateway gives you one. Iranian gateways return a transaction reference (often called a RefId or a tracking code) with every callback and verification. Use it as the idempotency key of the "credit the wallet" operation, and add the unique index on it. A gateway that calls back twice, or a verification you retry after a timeout, then can't credit the wallet twice.

Two more rules for callbacks:

  • Verify before you credit. A callback only tells you the user came back; the gateway's verify call tells you the payment really succeeded and for how much. Credit the amount the gateway verified, not the amount in the callback's query string.
  • Make "already processed" a success. When the same reference arrives again, answer the user with the original result ("payment received") instead of an error page that makes them pay again.

When you don't need it

Pure reads (GET) and naturally idempotent writes (PUT of a full resource) don't need a key. Anything that moves money, sends a message or creates a record does.


Need a payment flow, a wallet or a callback handler that has to hold up under retries? See how I can help.

Frequently asked questions

Is an idempotency check in code enough without a unique index?

No. Two identical requests can both pass a "have I seen this key?" check before either one saves. Only a unique constraint in the database guarantees that exactly one of them wins.

How long should idempotency keys be kept?

At least as long as a client or gateway might retry. Many payment APIs keep them for 24 hours or more; for gateway callbacks, keep them as long as the gateway can re-send.

Who generates the idempotency key?

The client, once per logical operation, before the first attempt. Every retry of that operation must send the same key; a new operation gets a new key.