Idempotency
How QuotaStack prevents duplicate operations using the Idempotency-Key header, server-side locks, and deterministic key conventions.
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
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"
}
| Field | On a replay |
|---|---|
reason | Always duplicate_replay. |
units, estimated_cost | Echo the original request, not the current one. |
debited, overage | Echo the original debit’s exact split, not a re-derivation from the current balance. |
original_reason | The 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.
| Operation | Key pattern | Example |
|---|---|---|
| Signup credit grant | signup-grant:{userId} | signup-grant:usr_k8x2m |
| Payment-confirmed topup | topup:{paymentId} | topup:pay_abc123 |
| Usage event | usage:{messageId} | usage:msg_9f2a1b |
| Entitlement consume | consume:{metric}:{requestId} | consume:img:req_98f2 |
| Reservation | reservation:{requestId} | reservation:req_x7z |
| Reservation commit | commit:{reservationId} | commit:rsv_m3n4 |
| Subscription renewal | renew:{paymentId} | renew:pay_xyz789 |
| Subscription cancel | cancel:{subscriptionId} | cancel:sub_q1w2 |
| Legacy customer import | import:{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
| Detail | Value |
|---|---|
| Header name | Idempotency-Key |
| Required on | All POST and PATCH requests |
| Not required on | GET and DELETE requests |
| Scope | Per-tenant. Two tenants can use the same key without conflict. |
| TTL | 24 hours (HTTP response cache). consume dedup is permanent — see Replay semantics |
| Max request body | 1 MB (for hashing and caching) |
| Max key length | 255 characters |
| Recommended character set | ASCII [a-zA-Z0-9_\-:.], no whitespace |
| Missing header | Returns 422 with validation error |
| Key reuse with different body | Returns 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.
Loading…