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

# 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.

> **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

## Diagram

Side-by-side comparison. Without idempotency: retry on timeout causes a second POST, doubling the credits granted to 10,000 mc (bug). With Idempotency-Key: the retry returns the cached response, keeping the total at 5,000 mc (correct).

| Step | Without Idempotency | With Idempotency-Key |
|---|---|---|
| 1 | POST /grant → 5,000 mc | POST /grant → 5,000 mc |
| 2 | (timeout, retry...) | (timeout, retry...) |
| 3 | POST /grant → 5,000 mc | POST /grant → cached |
| Result | = 10,000 mc ✗ | = 5,000 mc ✓ |

## 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

```python
# 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

**✗ 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.
