Building Idempotent Payment APIs in C# and .NET

If you build payment systems long enough, you eventually learn the same lesson the hard way: the network will retry your request, and your API had better be...

Originally published onanselmfowel.com

If you build payment systems long enough, you eventually learn the same lesson the hard way: the network will retry your request, and your API had better be ready for it. A client times out waiting for a response, retries the same transfer, and now you have moved the money twice. The customer is furious, the finance team is reconciling by hand, and you are reading logs at midnight trying to work out which of the two identical-looking requests was the "real" one.

Idempotency is a design discipline before it is a line of code.
Idempotency is a design discipline before it is a line of code.

Idempotency is the discipline that prevents this entire category of incident. In .NET it is less about any single library and far more about design, and once you internalize the pattern you stop thinking about it as a feature and start thinking about it as table stakes. This is how I build it, why each piece matters, and the mistakes I have watched good teams make.

What idempotency actually means here

An idempotent operation produces the same result whether it is executed once or many times. For a payment, that means a retried "transfer 50,000 NGN from A to B" request must never move the money twice, no matter how many times it arrives. The standard mechanism is an idempotency key: a unique token the client generates and sends with the request, usually as an Idempotency-Key header.

The contract is simple to state. The first time the server sees a key, it does the work and remembers the result. Every subsequent time it sees that same key, it returns the remembered result without doing the work again. The entire art is in making that contract hold under concurrency, partial failure, and time.

The minimal viable implementation

The core flow on each write request looks like this. You read the idempotency key, you attempt to claim it inside the same transaction that performs the work, and you store the response against the key so retries can replay it.

public async Task<TransferResult> TransferAsync(TransferRequest req, string idempotencyKey)
{
    await using var tx = await _db.BeginTransactionAsync();

    // Claim the key. The unique constraint on idempotency_key
    // turns a concurrent duplicate into a constraint violation we can catch.
    try
    {
        await _db.InsertIdempotencyKeyAsync(idempotencyKey, Hash(req), tx);
    }
    catch (UniqueViolationException)
    {
        // Someone already used this key. Replay the stored result.
        return await _db.GetStoredResultAsync(idempotencyKey);
    }

    var result = await _ledger.MoveFundsAsync(req, tx);
    await _db.StoreResultAsync(idempotencyKey, result, tx);

    await tx.CommitAsync();
    return result;
}

The crucial detail is that the key record and the side effect commit in the same database transaction. If you store the key first and the work fails, you have locked out a legitimate retry forever. If you do the work first and storing the key fails, you are back to double execution. Atomicity is the whole game, and everything else is detail.

Concurrency is the part people miss

Two identical requests can arrive within milliseconds of each other, which happens constantly when a mobile client on a flaky network fires a retry while the original is still in flight. A naive "check if the key exists, then insert it" has a race between the check and the insert, and under load that race will fire and you will double-charge someone.

Push correctness down to the layer that can enforce it atomically. In .NET that almost always means a unique index in the database, not a C# lock that only protects one process.

Lean on the database. A unique constraint on the idempotency key column turns the race into a clean constraint violation you catch and convert into "this request is already in progress or done." The database is the only component that sees all concurrent requests and can serialize them correctly, so that is where the guarantee belongs.

Scope and validate keys correctly

An idempotency key should be scoped to a single logical operation. Reusing a key across two genuinely different requests should be treated as a client error, not silently accepted, because if you ignore it a client bug quietly becomes a data-corruption bug. This is why the example above hashes the request payload and stores the hash alongside the key.

  • Same key, same payload hash: it is a retry, replay the stored result.
  • Same key, different payload hash: it is a client mistake, reject it with a clear error.
  • New key: it is new work, do it and remember it.

That three-way branch closes the loophole where a buggy client reuses a key for a different transfer and the server happily returns the wrong cached answer.

Expire keys, but not too soon

Keys do not need to live forever, but they must comfortably outlive any reasonable retry window. I keep them for 24 hours. Anything shorter risks expiring a key while a slow client on a bad connection is still retrying, which reopens the exact door you built this to close. Anything dramatically longer is just storage you do not need and a table that grows without bound.

Run a scheduled cleanup that deletes expired keys in batches. Keep the cleanup boring and well-monitored, because a cleanup job that silently dies will eventually let the table grow until it becomes its own incident.

Make it middleware, then forget about it

The final move is to lift all of this out of individual handlers and into shared infrastructure: ASP.NET Core middleware or a filter that reads the header, manages the key lifecycle, and either proceeds or replays. Individual endpoints declare that they require idempotency and stop thinking about the mechanics. This matters because correctness that depends on every developer remembering to do the right thing in every handler is correctness you do not actually have.

Conclusion: the unglamorous plumbing of trust

None of this is exotic. It is the unglamorous plumbing that separates a payment API you can trust from one that quietly bills people twice and erodes the trust you spend years building. Get the transaction boundary right, push the concurrency guarantee into a unique constraint, validate payload hashes, expire keys sensibly, and wrap the whole thing in middleware. Then stop thinking about it, which is exactly the point: the best idempotency implementation is the one nobody on your team has to think about anymore.

Chat with us