← Back to Article List         
Stop Duplicate Payments! Master Idempotency in Web APIs

Stop Duplicate Payments! Master Idempotency in Web APIs

Published on 23 Sep 2026     6 min read Web API
Idempotency

What is Idempotency?

Idempotency means sending the same request multiple times produces the same final result as sending it once.

For example, a customer clicks Place Order, but the response is delayed. The client retries the request:

POST /api/orders  → Order created: 1001
POST /api/orders  → Returns existing Order: 1001
POST /api/orders  → Returns existing Order: 1001

Only one order is created because every retry contains the same Idempotency-Key.

HTTP Methods

Method Idempotent? Reason
GET Yes Reading repeatedly does not change data
PUT Yes Replaces the resource with the same state
DELETE Yes Resource remains deleted
PATCH Depends Some patch operations may repeatedly change data
POST No, by default Every request may create a new resource

POST can be made idempotent by using an Idempotency-Key.

Complete ASP.NET Core Web API Example

This example uses:

  • ASP.NET Core Web API

  • Controllers

  • Entity Framework Core

  • SQL Server

  • Idempotency-Key request header

  • Database transaction

  • Unique constraint to prevent concurrent duplicates

1. Create the project

dotnet new webapi -n IdempotencyDemo
cd IdempotencyDemo

Install Entity Framework Core packages:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools

2. Project structure

IdempotencyDemo
│
├── Controllers
│   └── OrdersController.cs
├── Data
│   └── AppDbContext.cs
├── DTOs
│   ├── CreateOrderRequest.cs
│   └── OrderResponse.cs
├── Models
│   ├── Order.cs
│   └── IdempotencyRecord.cs
├── Program.cs
└── appsettings.json

3. Order model

Models/Order.cs

namespace IdempotencyDemo.Models;

public class Order
{
    public int Id { get; set; }

    public string CustomerName { get; set; } = string.Empty;

    public string ProductName { get; set; } = string.Empty;

    public int Quantity { get; set; }

    public decimal TotalAmount { get; set; }

    public DateTime CreatedAt { get; set; }
}

4. Idempotency record model

This table remembers which requests have already been processed.

Models/IdempotencyRecord.cs

namespace IdempotencyDemo.Models;

public class IdempotencyRecord
{
    public int Id { get; set; }

    public string IdempotencyKey { get; set; } = string.Empty;

    public string ResponseBody { get; set; } = string.Empty;

    public int StatusCode { get; set; }

    public DateTime CreatedAt { get; set; }
}

5. Request DTO

DTOs/CreateOrderRequest.cs

using System.ComponentModel.DataAnnotations;

namespace IdempotencyDemo.DTOs;

public class CreateOrderRequest
{
    [Required]
    public string CustomerName { get; set; } = string.Empty;

    [Required]
    public string ProductName { get; set; } = string.Empty;

    [Range(1, 100)]
    public int Quantity { get; set; }

    [Range(0.01, double.MaxValue)]
    public decimal TotalAmount { get; set; }
}

6. Response DTO

DTOs/OrderResponse.cs

namespace IdempotencyDemo.DTOs;

public class OrderResponse
{
    public int OrderId { get; set; }

    public string CustomerName { get; set; } = string.Empty;

    public string ProductName { get; set; } = string.Empty;

    public int Quantity { get; set; }

    public decimal TotalAmount { get; set; }

    public DateTime CreatedAt { get; set; }

    public string Message { get; set; } = string.Empty;
}

7. Database context

Data/AppDbContext.cs

using IdempotencyDemo.Models;
using Microsoft.EntityFrameworkCore;

namespace IdempotencyDemo.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();

    public DbSet<IdempotencyRecord> IdempotencyRecords =>
        Set<IdempotencyRecord>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<IdempotencyRecord>()
            .HasIndex(x => x.IdempotencyKey)
            .IsUnique();
    }
}

The unique index ensures that two simultaneous requests cannot save the same key.

8. Orders controller

Controllers/OrdersController.cs

using System.Text.Json;
using IdempotencyDemo.Data;
using IdempotencyDemo.DTOs;
using IdempotencyDemo.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace IdempotencyDemo.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly AppDbContext _context;

    public OrdersController(AppDbContext context)
    {
        _context = context;
    }

    [HttpPost]
    public async Task<ActionResult<OrderResponse>> CreateOrder(
        [FromBody] CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        string? idempotencyKey =
            Request.Headers["Idempotency-Key"].FirstOrDefault();

        if (string.IsNullOrWhiteSpace(idempotencyKey))
        {
            return BadRequest(new
            {
                message = "Idempotency-Key header is required."
            });
        }

        // Check whether this request was already processed.
        var existingRecord = await _context.IdempotencyRecords
            .AsNoTracking()
            .FirstOrDefaultAsync(
                x => x.IdempotencyKey == idempotencyKey,
                cancellationToken);

        if (existingRecord is not null)
        {
            var previousResponse =
                JsonSerializer.Deserialize<OrderResponse>(
                    existingRecord.ResponseBody);

            return StatusCode(
                existingRecord.StatusCode,
                previousResponse);
        }

        await using var transaction =
            await _context.Database.BeginTransactionAsync(
                cancellationToken);

        try
        {
            var order = new Order
            {
                CustomerName = request.CustomerName,
                ProductName = request.ProductName,
                Quantity = request.Quantity,
                TotalAmount = request.TotalAmount,
                CreatedAt = DateTime.UtcNow
            };

            _context.Orders.Add(order);

            await _context.SaveChangesAsync(cancellationToken);

            var response = new OrderResponse
            {
                OrderId = order.Id,
                CustomerName = order.CustomerName,
                ProductName = order.ProductName,
                Quantity = order.Quantity,
                TotalAmount = order.TotalAmount,
                CreatedAt = order.CreatedAt,
                Message = "Order created successfully."
            };

            var idempotencyRecord = new IdempotencyRecord
            {
                IdempotencyKey = idempotencyKey,
                ResponseBody = JsonSerializer.Serialize(response),
                StatusCode = StatusCodes.Status201Created,
                CreatedAt = DateTime.UtcNow
            };

            _context.IdempotencyRecords.Add(idempotencyRecord);

            await _context.SaveChangesAsync(cancellationToken);
            await transaction.CommitAsync(cancellationToken);

            return CreatedAtAction(
                nameof(GetOrder),
                new { id = order.Id },
                response);
        }
        catch (DbUpdateException)
        {
            await transaction.RollbackAsync(cancellationToken);

            // Another concurrent request may have used the same key.
            _context.ChangeTracker.Clear();

            var completedRecord = await _context.IdempotencyRecords
                .AsNoTracking()
                .FirstOrDefaultAsync(
                    x => x.IdempotencyKey == idempotencyKey,
                    cancellationToken);

            if (completedRecord is not null)
            {
                var previousResponse =
                    JsonSerializer.Deserialize<OrderResponse>(
                        completedRecord.ResponseBody);

                return StatusCode(
                    completedRecord.StatusCode,
                    previousResponse);
            }

            throw;
        }
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<Order>> GetOrder(
        int id,
        CancellationToken cancellationToken)
    {
        var order = await _context.Orders
            .AsNoTracking()
            .FirstOrDefaultAsync(
                x => x.Id == id,
                cancellationToken);

        return order is null ? NotFound() : Ok(order);
    }

    [HttpGet]
    public async Task<ActionResult<List<Order>>> GetOrders(
        CancellationToken cancellationToken)
    {
        var orders = await _context.Orders
            .AsNoTracking()
            .OrderByDescending(x => x.CreatedAt)
            .ToListAsync(cancellationToken);

        return Ok(orders);
    }
}

9. Program.cs

using IdempotencyDemo.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString(
            "DefaultConnection")));

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapControllers();

app.Run();

10. Connection string

appsettings.json

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IdempotencyDemoDb;Trusted_Connection=True;TrustServerCertificate=True"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

11. Create the database

dotnet ef migrations add InitialCreate
dotnet ef database update
dotnet run

Testing the API

First request

POST /api/orders
Content-Type: application/json
Idempotency-Key: order-request-1001
{
  "customerName": "Syed",
  "productName": "Laptop",
  "quantity": 1,
  "totalAmount": 65000
}

Response:

{
  "orderId": 1,
  "customerName": "Syed",
  "productName": "Laptop",
  "quantity": 1,
  "totalAmount": 65000,
  "createdAt": "2026-09-14T10:30:00Z",
  "message": "Order created successfully."
}

Status:

201 Created

Retry with the same key

Send the exact request again:

Idempotency-Key: order-request-1001

The API returns the stored response containing:

{
  "orderId": 1
}

It does not create Order 2.

New request

Change the key:

Idempotency-Key: order-request-1002

Now the API creates a new order.

Request Flow

flowchart TD
    A["POST request"] --> B{"Idempotency-Key exists?"}
    B -- No --> C["Return 400"]
    B -- Yes --> D{"Key already stored?"}
    D -- Yes --> E["Return previous response"]
    D -- No --> F["Begin transaction"]
    F --> G["Create order"]
    G --> H["Store key and response"]
    H --> I["Commit transaction"]
    I --> J["Return 201 Created"]

Important Production Points

  • The client must generate a unique key for each logical operation.

  • The same retry must use the same key.

  • Store the key in a persistent database or distributed cache such as Redis.

  • Create a unique database index on the key.

  • Store the status code and response so retries receive the same result.

  • Use a transaction to save the business operation and idempotency record together.

  • Add an expiration time and periodically remove old records.

  • For payment APIs, also pass the idempotency key to the payment provider.

  • Ideally, store a request hash and reject the same key if it is reused with different request data.