Idempotency
How QuotaStack prevents duplicate operations using the Idempotency-Key header, server-side locks, and deterministic key conventions.
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.
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.
| 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 |
| 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 |
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
| 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 |
| 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/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.
grant:{payment_id}, signup-bonus:{user_id})./topup/grant won't collide with /subscriptions/create, but reusing within the same endpoint causes conflict errors.