quotastack Docs
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/topups/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/topups/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 HTTP cache no longer holds the response, and a duplicate request reaches the service.

For most endpoints that means the operation executes fresh. POST /v1/entitlements/consume is the exception: its dedup is durable and outlives the HTTP cache entirely — see Replay semantics on consume below.

Best practice: mint a unique key per logical operation. Derive it from the business event (message ID, request ID, payment ID, job ID) so the same logical operation always produces the same key and two different operations never collide.

Replay semantics on consume

POST /v1/entitlements/consume debits credits, so its deduplication is backed by permanent server-side receipts, not just the 24-hour HTTP cache. Reusing a key after the cache expires does not re-debit.

Same key, same body → 200 duplicate_replay

The response carries the exact original outcome, not a fresh evaluation against the current balance:

{
  "type": "metered",
  "allowed": true,
  "debited": true,
  "reason": "duplicate_replay",
  "original_reason": "overage_recorded",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "billable_metric_key": "image_generation",
  "units": 3,
  "estimated_cost": 3000,
  "overage": 2000,
  "balance": 0,
  "reserved_balance": 0,
  "pending_balance": 0,
  "effective_balance": 0,
  "subscription_status": "active",
  "overage_policy": "allow"
}
FieldOn a replay
reasonAlways duplicate_replay.
units, estimated_costEcho the original request, not the current one.
debited, overageEcho the original debit’s exact split, not a re-derivation from the current balance.
original_reasonThe original response’s own business reason. Present only when the original recorded overage (overage_recorded); absent when the original was a clean full debit.

The overage guarantee is the subtle one. If the original consume drained partial credit under an allow/notify policy and recorded the remainder as overage, the replay reports debited: true and the original overage amount. It never collapses into a generic success with the overage silently dropped — so a caller recovering a lost response never under-bills.

Same key, different body → 409

{
  "type": "https://api.quotastack.io/errors/idempotency-key-reuse",
  "title": "Conflict",
  "status": 409,
  "detail": "Idempotency-Key was already used with a different request body"
}

The fingerprint that decides replay-vs-conflict covers customer_id + billable_metric_key + units only. metadata is excluded — changing only metadata under an already-used key still replays rather than conflicting.

Recovering a lost response

Because receipts are permanent, a dropped connection is always recoverable: replay the same request with the same key and you get the original outcome back, however much later. There is no expiry window after which a duplicate silently re-debits.

This is what makes consume safe to retry blindly from a queue or a crash-loop — but it depends on the key being stable for the logical operation. A retry that mints a new key is a new debit, not a retry.

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
Entitlement consumeconsume:{metric}:{requestId}consume:img:req_98f2
Reservationreservation:{requestId}reservation:req_x7z
Reservation commitcommit:{reservationId}commit:rsv_m3n4
Subscription renewalrenew:{paymentId}renew:pay_xyz789
Subscription cancelcancel:{subscriptionId}cancel:sub_q1w2
Legacy customer importimport:{externalId}import:user_abc
Import credit seed (per block)migrate-block:{externalId}:{label}migrate-block:user_abc:remaining

Cutover scripts have two independent layers of protection, and they expire differently. The Idempotency-Key header makes the whole import request safe to retry, but only within the 24-hour cache. Each entry in credit_blocks[] carries its own idempotency_key, and those are durable — re-running the same export weeks later will not double a customer’s balance. Derive both from stable identifiers, never from a timestamp or a row number that could shift between runs.

For a manual payment flow, use the provider’s payment ID as the key for each credit grant. When your webhook calls QuotaStack with Idempotency-Key: topup:{paymentId}, a retry cannot grant twice. One payment has one payment ID, so it is a safe key.

For a payment connector, do not call a manual grant or renew route. QuotaStack links the payment ID to its connection. It then applies the mapped change once.

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 (HTTP response cache). consume dedup is permanent — see Replay semantics
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/topups/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

Don't use random UUIDs per request

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

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.