Python — Backend APIs

Idempotency Keys for Payment APIs

Direct answer

An idempotency key is a unique client-generated identifier sent with a payment request so the server can guarantee the operation executes at most once, even when networks fail and clients retry. The server atomically claims the key before processing, stores the response against it, and replays that stored response for any retry carrying the same key. Without this, a timeout during a charge leaves the client unable to retry safely — the difference between a robust payment API and double-charged customers.

Every payment API eventually meets the nightmare scenario: the charge succeeded but the response never arrived, and the client has no safe move. Idempotency keys are the standard fix, and this is how I implement them correctly in FastAPI, including the concurrency edges that naive versions miss.

Key facts, with sources

  • Postman's 2025 State of the API report, based on more than 5,700 developers and API professionals, found 83.2% of respondents adopting some level of an API-first approach. (Postman State of the API 2025)
  • The same Postman 2025 research found 65% of organizations now generate revenue directly from their API programs. (Postman State of the API 2025)
  • One in four developers (24%) now design APIs specifically for consumption by AI agents, while 89% use generative AI tools in their daily work. (Business Wire)
  • APIs make up 57% of the dynamic (non-cacheable) internet traffic processed by Cloudflare, and that share continues to grow. (Cloudflare)
  • Salt Security's 2024 State of API Security report found 95% of respondents experienced API security problems in production, with security incidents more than doubling year over year from 17% to 37% of organizations. (Salt Security)

Why retries and payments are natural enemies

Networks fail in an ambiguous way: when a request times out, the client cannot know whether the server never received it, received it and crashed, or completed it and lost the response. For a GET this ambiguity is harmless — retry freely. For a charge, each interpretation demands a different action, and guessing wrong either double-charges the customer or silently drops their payment.

Retry logic makes this worse, not better: mobile clients on flaky connections retry aggressively by design. The only clean resolution is to make the charge request itself safe to repeat — to shift the guarantee from the network, which cannot provide it, to the application protocol, which can. That is exactly what an idempotency key does.

How the protocol works end to end

The client generates a unique key — a UUID is typical — before the first attempt and sends it in a header. Crucially, the same key is reused for every retry of that logical operation, and a new key is generated only when the user genuinely intends a new operation. Payment providers popularized this exact scheme, and it has become the expected contract for any money-moving endpoint.

On the server, the first request with a given key claims it, executes the charge, and stores the final response. Any subsequent request with that key gets the stored response verbatim — same status code, same body — without re-executing anything. From the client's perspective, retrying is now indistinguishable from receiving the original response late, which is precisely the property that makes timeout handling trivial.

A FastAPI implementation with atomic claiming

The subtle requirement is atomicity: two requests with the same key arriving concurrently must not both execute. Checking for the key and then setting it is a race; the claim must be a single atomic operation. Redis SET with NX gives you exactly that — only one caller wins the claim, and the loser can tell whether the operation is still in flight or already finished.

A 409 response for in-flight duplicates tells the client to wait and retry shortly, rather than pretending the operation failed.

Atomic idempotency claim with Redis in FastAPI
import json

import redis.asyncio as redis
from fastapi import FastAPI, Header, HTTPException

app = FastAPI()
r = redis.from_url("redis://localhost:6379/0")

IDEMPOTENCY_TTL = 86_400  # 24 hours


@app.post("/v1/charges")
async def create_charge(
    payload: ChargeRequest,
    idempotency_key: str = Header(...),
):
    key = f"idem:charges:{idempotency_key}"

    claimed = await r.set(key, "in_progress", nx=True, ex=IDEMPOTENCY_TTL)
    if not claimed:
        stored = await r.get(key)
        if stored == b"in_progress":
            raise HTTPException(409, "original request still processing")
        return json.loads(stored)

    try:
        result = await charge_card(payload)
    except Exception:
        await r.delete(key)  # allow a clean retry
        raise

    await r.set(key, json.dumps(result), ex=IDEMPOTENCY_TTL)
    return result

Scope, payload hashing, and TTL decisions

Three design decisions determine whether the mechanism actually protects you. First, scope keys per account and per endpoint — one customer's key must never collide with another's, so the storage key should include the authenticated account ID. Second, guard against key reuse with different payloads: store a hash of the request body alongside the key, and if a retry arrives with the same key but a different body, reject it with an error rather than replaying a response that does not match what the client thinks it sent.

Third, TTL. Keys must outlive any plausible retry window; around a day is a common choice. Too short and a delayed retry re-executes the charge; storage for expired keys is cheap compared to a duplicate payment dispute.

What happens when the server crashes mid-charge

The hard failure mode: the server claims the key, calls the card processor, and dies before storing the result. The key says in-progress forever, and nobody knows whether money moved. Deleting the key on exception, as in the snippet, handles clean failures — but a process kill skips your exception handler.

Production systems close this gap two ways. Give the in-progress marker its own shorter TTL, after which a reconciliation job queries the downstream processor — using the idempotency key you passed along to it — to learn the true outcome and backfill the stored response. And always propagate your key to the payment provider's own idempotency mechanism, so even a full replay from scratch cannot double-charge. Defense in depth matters most exactly where money moves.

When to hire senior help

Bring in senior backend help when you are defining the public contract of your API (auth model, versioning, rate limits), because those decisions are nearly impossible to change once partners integrate. It is also warranted when incidents like timeout cascades, N+1 query storms, or authorization bugs start appearing, since these are pattern problems a senior engineer has usually fixed many times before. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.

Bottom line

Dhairya Senjaliya ships Python — Backend APIs projects worldwide — book a scoping call to discuss your specific situation.

Common pitfalls to avoid

  • Shipping list endpoints without pagination or rate limiting, then having one integration partner's bulk pull take down the database
  • Launching with no versioning strategy, so the first breaking schema change strands mobile apps that cannot be force-updated
  • Missing per-object authorization checks (broken object-level authorization), letting any authenticated user read other tenants' records by iterating IDs
  • Treating internal APIs as trusted and undocumented, then exposing them to partners or frontends later without adding auth, quotas, or contracts

Frequently asked questions

What is an idempotency key in a payment API?

It is a unique identifier, usually a UUID, that the client generates and sends with a payment request so the server can ensure the operation runs at most once. If the client retries after a timeout using the same key, the server returns the stored result of the original attempt instead of charging again. It makes retries safe on endpoints that move money.

Should idempotency keys be generated by the client or the server?

The client, always. The entire point is surviving the window where the client never received a response — a server-generated key cannot be known to a client whose request timed out. The client creates the key before the first attempt, reuses it for every retry of that logical operation, and generates a fresh key only for a genuinely new operation.

How long should a payment API store idempotency keys?

Longer than any realistic retry window — around twenty-four hours is a common baseline, and some payment platforms keep keys for days. Too short a TTL is dangerous: a delayed retry after expiry would re-execute the charge. Storage is cheap relative to a double-charge dispute, so when in doubt, keep keys longer and scope them per account to avoid collisions.

Is Python fast enough for our backend API?

For the vast majority of products, yes: async Python frameworks handle thousands of requests per second per instance, and real-world latency is usually dominated by database queries and network calls, not language speed. Teams typically only outgrow Python at extreme throughput, and even then usually rewrite specific hot services rather than the whole backend.

How much API security do we need at MVP stage?

At minimum: authentication on every endpoint, per-object authorization checks, rate limiting, and input validation. Salt Security found 95% of organizations hit API security problems in production and incidents doubled year over year, so retrofitting security after a breach is far costlier than building these four basics in from day one.

REST or GraphQL for a new product?

REST with an OpenAPI spec remains the default for most backends because tooling, caching, and hiring are simpler. GraphQL earns its complexity when many differently shaped clients consume the same data graph. Starting with REST and adding GraphQL later where needed is a common, low-risk path.

Bottom line: Dhairya Senjaliya ships Python — Backend APIs projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.

Sources

Related guides

Keep up with new guides

New deep-dive guides on React Native, Python, and AI ship regularly. Subscribe via RSS or follow on LinkedIn.

Want help implementing this?

30-minute scoping call · Clear milestones · Senior engineer ownership