Docs / Concepts / Idempotency

Idempotency

How QuotaStack prevents duplicate operations using the Idempotency-Key header, server-side locks, and deterministic key conventions.

Mental Model

Every POST/PATCH request includes a receipt number (the Idempotency-Key). Retry with the same key and QuotaStack returns the cached response, not a double-charge. This is how your system survives network failures and webhook re-deliveries.

Quick Take
Every POST/PATCH requires an Idempotency-Key header
Duplicate requests return the cached original response — no double charges
Concurrent requests with the same key are serialized server-side — no race window
Keys expire after 24 hours; 5xx responses are never cached
WITHOUT POST /grant → 5,000 mc (timeout, retry...) POST /grant → 5,000 mc = 10,000 mc ✗ WITH IDEMPOTENCY-KEY POST /grant → 5,000 mc (timeout, retry...) POST /grant → cached = 5,000 mc ✓

Idempotency

Every POST and PATCH request to QuotaStack requires an Idempotency-Key header. This prevents duplicate operations caused by network failures, client retries, and webhook redelivery.

Why it matters

Credit operations are financial. A retry that grants credits twice, debits twice, or creates two subscriptions is a billing error that erodes trust. Network failures are not hypothetical — they happen constantly:

  • Client sends a grant request. Server processes it. The response is lost in transit. Client retries.
  • Your payment provider delivers a “payment succeeded” webhook. Your handler calls QuotaStack to grant credits. Your handler crashes before acknowledging the webhook. The provider redelivers.
  • A load balancer times out a request that the server already completed.

Without idempotency, every one of these results in a double operation.

How it works

First request:
  Client --> POST /v1/topup/grant
             Idempotency-Key: topup:pay_abc123
             Body: {"external_customer_id": "cust_1", "credits": 5000}
  Server --> Executes the grant, stores the response
  Client <-- 201 Created

Retry (same key, same body):
  Client --> POST /v1/topup/grant
             Idempotency-Key: topup:pay_abc123
             Body: {"external_customer_id": "cust_1", "credits": 5000}
  Server --> Finds the stored response, replays it
  Client <-- 201 Created (header: X-Idempotent-Replayed: true)

The first request with a given key executes normally. The response (status code + body) is stored. Any subsequent request with the same key returns the stored response without re-executing the operation.

Replayed responses include the X-Idempotent-Replayed: true header so you can distinguish them from first executions in your logs.

Conflict detection

If you send the same idempotency key with a different request body, QuotaStack returns 409 Conflict. The request body is fingerprinted and compared against the original. This catches bugs where two unrelated operations accidentally share a key.

Request 1:
  Idempotency-Key: grant-123
  Body: {"external_customer_id": "cust_1", "credits": 5000}
  --> 201 Created (stored)

Request 2:
  Idempotency-Key: grant-123
  Body: {"external_customer_id": "cust_2", "credits": 10000}
  --> 409 Conflict

24-hour TTL

Stored idempotency responses expire after 24 hours. After expiry, the same key can be reused (it will execute fresh). In practice, you should never need to reuse a key — use deterministic keys derived from the operation being performed.

5xx responses are never stored

This is critical. If QuotaStack returns a 5xx server error, the response is not stored. Server errors are transient — a momentary overload, a deployment in progress, an upstream timeout. Storing them would “poison” the idempotency key: every retry would replay the error instead of re-executing the operation.

Only 2xx and 4xx responses are stored. 4xx errors (validation failures, not-found, etc.) are deterministic — retrying with the same input will always produce the same result, so replaying them is correct. 5xx errors are not deterministic, so retries get a fresh execution.

Serialization

Two concurrent requests with the same idempotency key do not race. QuotaStack serializes them server-side: the first request executes and stores the response; the second request waits, then sees the stored response and replays it. No double-execution window.

Request A (key: topup:pay_abc) ----[executing]----[store response]-->
Request B (key: topup:pay_abc) ----[waiting]...............[replay stored response]-->

Serialization holds for the entire handler execution — including storing the response — then releases atomically when the operation completes. The wait is bounded by a request timeout (30 seconds by default); if the first request exceeds this window, the second request surfaces the timeout rather than waiting indefinitely.

Durability guarantee

The response is persisted before the HTTP response is sent to the client. If the TCP response is dropped in flight, the next retry with the same key still reads the stored response and replays it. You never lose state due to a broken connection.

Key conventions

Use deterministic keys derived from the operation, not random UUIDs. The key should be the same every time you retry the same logical operation.

OperationKey patternExample
Signup credit grantsignup-grant:{userId}signup-grant:usr_k8x2m
Payment-confirmed topuptopup:{paymentId}topup:pay_abc123
Usage eventusage:{messageId}usage:msg_9f2a1b
Reservationreservation:{requestId}reservation:req_x7z
Reservation commitcommit:{reservationId}commit:rsv_m3n4
Subscription renewalrenew:{paymentId}renew:pay_xyz789
Subscription cancelcancel:{subscriptionId}cancel:sub_q1w2

The most important convention: use your payment provider’s payment ID as the idempotency key for credit grants. When your payment webhook handler calls QuotaStack with Idempotency-Key: topup:{paymentId}, webhook redelivery cannot double-grant. The payment ID is the natural deduplication key because it represents exactly one real-world payment.

The Idempotency-Key header

DetailValue
Header nameIdempotency-Key
Required onAll POST and PATCH requests
Not required onGET and DELETE requests
ScopePer-tenant. Two tenants can use the same key without conflict.
TTL24 hours
Max request body1 MB (for hashing and caching)
Max key length255 characters
Recommended character setASCII [a-zA-Z0-9_\-:.], no whitespace
Missing headerReturns 422 with validation error
Key reuse with different bodyReturns 409 Conflict

Keys aren’t validated for character set today — the server accepts anything — but sticking to ASCII and avoiding whitespace keeps keys portable through logs, proxies, and URL encoders.

Example: safe webhook handler

# Your payment webhook handler
def handle_payment_webhook(event):
    payment_id = event["payment_id"]
    external_customer_id = event["metadata"]["user_id"]
    credits = event["amount_cents"] * 10  # your conversion logic

    # Even if this webhook fires 3 times, only one grant executes.
    response = requests.post(
        "https://api.quotastack.io/v1/topup/grant",
        headers={
            "X-API-Key": "qs_live_...",
            "Idempotency-Key": f"topup:{payment_id}",
            "Content-Type": "application/json",
        },
        json={
            "external_customer_id": external_customer_id,
            "credits": credits,
            "source": "topup",
            "reason": f"Payment {payment_id}",
        },
    )

    if response.status_code >= 500:
        # Transient error. Raise to trigger retry.
        raise RetryableError(f"QuotaStack returned {response.status_code}")

    # 2xx or 4xx -- idempotent, safe to acknowledge the webhook.
    return response.json()

Common Mistakes

The mistakes developers typically make with this concept — and what to do instead.

×
Don't use random UUIDs per request
Why
A random key defeats the purpose — the retry has a different key and creates a duplicate. Use deterministic keys tied to business events (grant:{payment_id}, signup-bonus:{user_id}).
×
Don't reuse an idempotency key across different operations
Why
Keys are scoped by endpoint. A key used for /topup/grant won't collide with /subscriptions/create, but reusing within the same endpoint causes conflict errors.
🤖
Building with an AI agent?
Get this page as markdown: /docs/concepts/idempotency.md · Full index: /llms.txt