Skip to main content
Building Idempotent Payment APIs in C# and .NET — Anselm Fowel
AI & Technology

Building Idempotent Payment APIs in C# and .NET

6 min read
462 views
Share:

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.

Enjoying this article?

Get more like it in your inbox — practical engineering leadership, fintech, and AI. No spam, unsubscribe anytime.

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.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

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.

Enjoyed this article? Share it with others!

Share:

Get new posts in your inbox

Occasional, practical notes on engineering leadership, fintech, and building with AI. No spam, unsubscribe anytime.

Comments (4)

Leave a Comment

Comments are moderated and will appear after review.

Ifeoma Nwosu

June 21, 2026

Quick question on "The minimal viable implementation" — does the pattern hold when the workload is bursty rather than steady state? We keep running into the high-fanout case and the textbook answers do not always survive contact.

Ryan White

June 13, 2026

SRE at a payment processor. We hit this exact thing with cross-AZ replication lag last Jan — tail latency was the presenting symptom, and the "What idempotency actually means here" section is basically how we untangled it. Ended up adding a stateless matcher on Terraform Cloud, cut p99 latency by 59%. For context: 17-service graph.

Yaa Darko

June 13, 2026

Good topic. Tech lead at a Ghanaian remittance startup here. What we do differently: run a shadow processor comparing against production for a week before cut-over on Cassandra for events. It is not universally better; ops needed six weeks to warm up to it, but the testability is dramatically better and that pays for itself the first time you have to answer a forensic investigator question at 4am.

Chiamaka Eze

June 5, 2026

Does the "Concurrency is the part people miss" still hold on a 7-engineer team? We're at the smaller end of that and some of these patterns feel like they need a dedicated platform team to run properly.

About the author

Anselm Fowel

Anselm Fowel

Chief Technology Officer & fintech architect. 16+ years leading engineering across AlliancePay, Mondu, Transalliance, Global Accelerex, and Fidelity Bank — writing here about engineering leadership, fintech architecture, and AI in production.

Read next

Subscribe to the newsletter

Practical notes on engineering leadership, fintech, and building with AI — delivered to your inbox. No spam, unsubscribe anytime.

Anselm Fowel

Chief Technology Officer | Fintech Architect | Engineering Leader

Building the future of financial technology through innovative engineering and strategic leadership.

Expertise

  • CTO Advisory
  • Fintech Architecture
  • Team Leadership
  • Technical Strategy
  • System Design

Get In Touch

[email protected]
Lagos, Nigeria

© 2026 Anselm Fowel. Crafted with passion.