Skip to main content

Command Palette

Search for a command to run...

Idempotency in Payment APIs: Why It's Non-Negotiable in FinTech

Updated
4 min readView as Markdown
Idempotency in Payment APIs: Why It's Non-Negotiable in FinTech

A network blip that would be a minor annoyance almost anywhere else becomes a genuine incident when the request in flight is moving someone's money. On remittance and insurance platforms, I've seen exactly how this plays out: a client's request times out, their app retries automatically, and without the right protection in place, a customer gets charged twice for a single transfer. Idempotency is the piece of engineering discipline that closes this gap, and in payments, it's not optional.

What idempotency actually means

An idempotent operation produces the same result no matter how many times it's executed. GET requests are naturally idempotent, you can call them a thousand times and nothing changes. POST requests that create a resource are not, by default, and a payment endpoint is almost always a POST. That mismatch is exactly where duplicate charges come from.

Idempotency keys: the standard pattern

csharp

app.MapPost("/api/transfers", async (
    TransferRequest request,
    [FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
    ITransferService service) =>
{
    var existing = await service.GetByIdempotencyKeyAsync(idempotencyKey);
    if (existing is not null)
        return Results.Ok(existing);

    var result = await service.ProcessAsync(request, idempotencyKey);
    return Results.Ok(result);
});

The client generates a unique key per logical operation, usually a GUID, and sends it with every attempt at that same operation, including retries. The server checks whether it's already seen that key before doing anything else. If it has, it returns the original result instead of processing the request again. The client doesn't need to know whether their first attempt actually succeeded before timing out. They just retry safely with the same key.

Where the naive version breaks

A common mistake is checking for the idempotency key and processing the request as two separate, non-atomic steps. Under concurrent requests, arriving within milliseconds of each other, both can pass the check before either has recorded the key, and both proceed to charge the customer. The check-and-record needs to happen atomically, typically with a unique database constraint on the idempotency key that causes the second concurrent insert to fail cleanly rather than silently succeed twice.

csharp

public async Task<TransferResult> ProcessAsync(TransferRequest request, string idempotencyKey)
{
    try
    {
        await _repository.InsertIdempotencyRecordAsync(idempotencyKey);
    }
    catch (UniqueConstraintViolationException)
    {
        return await _repository.GetResultByIdempotencyKeyAsync(idempotencyKey);
    }

    return await ExecuteTransferAsync(request);
}

The unique constraint at the database level is what actually makes this safe under concurrency, not the application-level check that runs before it.

What happens while the first request is still processing

There's a subtle window where a retry can arrive while the original request is still being processed, not yet finished, not yet failed. A naive implementation might not find a completed result yet and process the request again. Handling this properly means recording the idempotency key as "in progress" before starting the actual work, and having a retry that hits an in-progress record wait for or poll the original result, rather than starting a second execution.

Idempotency keys need to expire, deliberately

Keeping every idempotency key forever isn't practical, and it's also not usually necessary. A reasonable retention window, often 24 hours, covers realistic retry scenarios like a client retrying after a timeout or a brief outage. After that window, a repeated key can be treated as a genuinely new request. Where I draw that line depends on the specific failure modes I'm protecting against, not a default I copy from project to project.

This applies beyond the payment call itself

It's tempting to think idempotency is only about the initial charge, but the same problem shows up anywhere a retry could duplicate an effect: sending a confirmation email twice, reserving inventory twice, triggering a webhook twice. Any operation triggered by an event that might be delivered more than once needs to ask the same question a payment endpoint does: what happens if this runs twice?

The real takeaway

Idempotency isn't a defensive afterthought bolted onto a payment API. It's a basic acknowledgment that networks are unreliable, clients retry, and a system handling real money has to behave correctly under those conditions by design, not by luck. Every payment-adjacent endpoint I build starts with this question before a single line of business logic gets written.

9 views