Integration guide

Idempotency keys for payment endpoints in .NET

How to make a charge endpoint safe to retry: key scope, hashing the raw body, the 409 on a mismatched payload, and the race a unique index has to close.

A client will retry a charge. The network timed out, the app was killed, the load balancer retried a POST. If your endpoint treats that second call as a new payment, you take the money twice.

An idempotency key is how the client names the attempt, so the second call returns the first result instead of creating a second one. Stripe’s API has done this for years, and the behaviour is worth copying because clients already expect it: the same key and the same parameters return the original result; the same key with different parameters is an error, not a new charge.

This is the server side of that contract in ASP.NET Core. It pairs with retry and backoff: a retry policy without this key is how you double-charge.

What you need

  • The raw request body, read before model binding. The same trap as webhook signatures: hashing a deserialised object is not hashing what the client sent.
  • A store with a unique index on (callerId, key). Memory is fine for a test. Production needs a database, because two app instances will race.
  • A documented lifetime. Stripe removes keys automatically after they are at least 24 hours old. Pick a window, publish it, and do not silently inherit someone else’s.

The contract

Client sends Server does
No key, or a blank key 400. Do not guess.
New key Run the charge. Store the status code and the body you returned.
Same key, same raw body, completed Return the stored status and body. Do not call the gateway again.
Same key, different raw body 409. The key names one attempt, not “the latest version of this order”.
Same key, request still in flight 409 with Retry-After. The client retries the same key.

Scope the key to the authenticated caller. A key is not a secret, and it is not global. Two merchants must be able to send order-1042 without colliding.

The implementation

Hash the raw bytes. Re-serialising the model reorders keys and changes the fingerprint, and a legitimate retry then looks like fraud.

public static async Task<byte[]> ReadRawAsync(HttpRequest request, CancellationToken ct)
{
    request.EnableBuffering();
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer, ct);
    request.Body.Position = 0;
    return buffer.ToArray();
}

public static string Fingerprint(byte[] rawBody)
{
    Span<byte> hash = stackalloc byte[32];
    SHA256.HashData(rawBody, hash);
    return Convert.ToHexString(hash);
}

Insert before you call the gateway, not after. The unique index is the lock. If the insert loses, someone else owns this key and you must not proceed.

public async Task<IResult> Charge(
    HttpContext http,
    Caller caller,
    CancellationToken ct)
{
    if (!http.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues))
        return Results.BadRequest(new { error = "idempotency_key_required" });

    var key = keyValues.ToString();
    if (key.Length is < 1 or > 255)
        return Results.BadRequest(new { error = "idempotency_key_invalid" });

    var raw = await ReadRawAsync(http.Request, ct);
    var fingerprint = Fingerprint(raw);

    var inserted = await store.TryInsertAsync(
        new Pending(caller.Id, key, fingerprint, DateTimeOffset.UtcNow), ct);

    if (!inserted)
        return await ReplayOrConflict(caller.Id, key, fingerprint, ct);

    try
    {
        var outcome = await gateway.ChargeAsync(raw, key, ct);
        await store.CompleteAsync(caller.Id, key, outcome.StatusCode, outcome.Body, ct);
        return Results.Text(outcome.Body, "application/json", statusCode: outcome.StatusCode);
    }
    catch
    {
        // Leave the row. A retry must see "in progress" or "failed",
        // never a missing key that invites a second charge.
        await store.MarkFailedAsync(caller.Id, key, ct);
        throw;
    }
}

TryInsertAsync is a single INSERT that treats a unique-violation as false. A check-then-insert is not equivalent: both requests pass the check.

Replay returns the stored HTTP status, including failures. If the first attempt was a 402, the retry is a 402. Caching only the successes is how a client “retries” its way into a different answer.

private async Task<IResult> ReplayOrConflict(
    string callerId, string key, string fingerprint, CancellationToken ct)
{
    var row = await store.GetAsync(callerId, key, ct);
    if (row is null)
        return Results.StatusCode(StatusCodes.Status409Conflict);

    if (!string.Equals(row.Fingerprint, fingerprint, StringComparison.Ordinal))
        return Results.Conflict(new { error = "idempotency_key_reused" });

    if (row.CompletedBody is null)
        return Results.Conflict(new { error = "request_in_progress" });

    return Results.Text(row.CompletedBody, "application/json", statusCode: row.StatusCode);
}

Pass the same key downstream to the gateway. Your store stops you charging twice. It does not stop the gateway charging twice if your own call to them is retried without their key. Stripe’s parameter is Idempotency-Key on the request to them; other gateways name it differently, and some have no equivalent, which means your store is the only line you have.

What the docs leave you to decide

Stripe publishes the client-visible contract and the 24-hour retention. It does not publish your database schema, and copying the header name is not the design.

Three choices that have to be yours:

  • Lifetime. 24 hours matches Stripe and is long enough for a mobile client that retries overnight. Shorter is fine if you say so. Longer means you are storing card-attempt metadata you may not want.
  • In-flight behaviour. Returning 409 is simpler than blocking the second request until the first finishes. Blocking needs a timeout, or a stuck gateway call holds every retry. Publish which one you do.
  • What is part of the fingerprint. The raw body, not the headers. A retry from a different IP is the same attempt. A retry with a different amount is not.

Failure modes

Symptom Cause Fix
Two charges, one key Insert happened after the gateway call, or the unique index is missing Insert first; unique on (caller, key)
Legitimate retry returns 409 Fingerprint hashed a re-serialised model Hash the raw body bytes
Retry returns 201 after the client saw 402 Only successful responses were stored Store the status code you actually returned
Every retry 409s forever Failed attempt deleted the row Keep the row until the lifetime expires
Two merchants collide Key is global Scope to the caller

Production checklist

  • Require the header on every money-moving POST
  • Unique index on caller plus key, insert before the gateway call
  • Fingerprint the raw body
  • Replay the stored status, including errors
  • Forward a key downstream, or accept that you cannot safely retry that call
  • Expire rows; do not keep them for the life of the table
  • Alert on a spike of idempotency_key_reused — it is usually a client bug, occasionally a real mix-up of amounts

Sources

  • Stripe: idempotent requests — same key and same parameters replay; a parameter mismatch errors; keys are eligible for removal after at least 24 hours. Checked 26 September 2026.

The handler above is the contract those rules imply for your own API. It is not a claim about any Gulf gateway’s undocumented retry behaviour. Where a provider publishes no idempotency key, the store on your side is the control, and the gateway call itself must not be repeated blindly. That case is covered in retry and backoff.