# Idempotency > Retry requests safely with Idempotency-Key, and make order forwarding duplicate-proof with external_id. Source: https://swaglists.com/docs/idempotency Networks fail. A request can time out after we processed it, and retrying blindly could create a second order. Two mechanisms make retries safe: 1. **`Idempotency-Key` header** — on every `POST`, `PATCH` and `DELETE`, so a retried request returns the original result instead of running twice. 2. **`external_id`** on orders — your own order id, unique per mode, so the same store order can never become two orders. Use both. ## Idempotency-Key ```bash title="cURL" curl -X POST https://swaglists.com/api/v1/orders \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 5d2c8b7e-9a4f-4f0e-8b1d-6c3a2e7f9b10" \ -d @order.json ``` How it works: | Situation | Result | | --- | --- | | First request with a key | Runs normally; the response is stored for **24 hours**. | | Same key, same method, path and body, after it finished | The **stored response** is returned (same status and body) with the header `Idempotent-Replayed: true`. Nothing runs again. | | Same key while the first request is still running | `409 idempotency_in_progress` with `Retry-After: 1` — wait and retry with the same key. | | Same key with a different method, path or body | `409 idempotency_key_reused`. Use a new key for a different request. | | The first attempt was rejected before it ran — invalid or malformed parameters (`400 parameter_invalid`, `invalid_json`, `415`), missing scope — or failed with a 5xx | Not stored — retry (or fix and retry) with the **same** key. | | The first attempt ran and returned a 4xx (for example an unknown SKU, or `402 insufficient_funds`) | Stored like a success: retrying the same request returns the same error, even after you fixed the cause. After changing the request — or fixing the cause outside it, such as adding credits — use a **new** key. | - Keys are up to 255 printable ASCII characters (no spaces). A UUID v4 is ideal. - Keys are scoped to your account and mode. Body comparison ignores JSON key order and whitespace. - `GET` requests are naturally idempotent and ignore the header. ### Choosing keys Derive the key from the thing you are doing, so every retry — even after a crash or a redeploy — uses the same key: | Operation | Good key | | --- | --- | | Forward store order 1042 | `order-1042` or `shopify:my-shop:1042` | | Confirm order `ord_…` | `confirm-ord_…` | | Create product for store product 9921 | `product-9921` | | One-off action from a script | A fresh UUID, reused on retries of that action | > **Warning** Don't generate a new random key on each retry of a network error, `429` or `5xx` — that defeats the purpose. Generate it once, store it with the job, reuse it. > **Tip** If a request failed with a 4xx because of something you then fixed outside the request (you added credits after a `402 insufficient_funds`, or created the missing SKU), retrying within 24 hours with the same key replays the stored error. Add a suffix for the new attempt (`order-1042:2`); `external_id` still prevents duplicates. ## external_id for orders Set `external_id` to your store's order id on every order. It is unique per mode: a second `POST /orders` with the same `external_id` fails with: ```json { "error": { "type": "conflict_error", "code": "duplicate_external_id", "message": "An order with external_id '1042' already exists.", "param": "external_id", "existing_id": "ord_01k8x4b2q9w7e3r5t1y8u6i0pa", "doc_url": "https://swaglists.com/docs/errors#duplicate_external_id", "request_id": "req_01k8x4b2q7w9e1r3t5y7u9i1o3" } } ``` Treat this as success: the order already exists — use `existing_id`, or fetch it with `GET /orders/ext:1042`. This protects you even when your store sends the same "order paid" webhook twice days apart (after the 24-hour idempotency window). ## Retry recipe ```js title="Node.js" import { setTimeout as sleep } from "node:timers/promises"; /** POST with retries on network errors, 409 idempotency_in_progress, 429 and 5xx — always with the same key. */ async function postWithRetry(path, body, idempotencyKey, attempts = 5) { for (let attempt = 1; ; attempt++) { let res; try { res = await fetch(`https://swaglists.com/api/v1${path}`, { method: "POST", headers: { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(body), }); } catch (err) { if (attempt >= attempts) throw err; // network error: retry await sleep(2 ** attempt * 250 + Math.random() * 250); continue; } const json = await res.json(); if (res.ok) return json; if (json.error?.code === "duplicate_external_id") return { id: json.error.existing_id, duplicate: true }; const retryable = res.status === 429 || res.status >= 500 || json.error?.code === "idempotency_in_progress"; if (!retryable || attempt >= attempts) throw Object.assign(new Error(json.error.message), { error: json.error }); const retryAfter = Number(res.headers.get("retry-after")); await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250 + Math.random() * 250); } } const order = await postWithRetry("/orders", orderBody, `order-${storeOrder.id}`); ``` ```python title="Python" import os, random, time, requests def post_with_retry(path, body, idempotency_key, attempts=5): for attempt in range(1, attempts + 1): try: res = requests.post( "https://swaglists.com/api/v1" + path, json=body, headers={"Authorization": f"Bearer {os.environ['API_KEY']}", "Idempotency-Key": idempotency_key}, timeout=30, ) except requests.RequestException: if attempt == attempts: raise time.sleep(2 ** attempt * 0.25 + random.random() * 0.25) continue data = res.json() if res.ok: return data error = data.get("error", {}) if error.get("code") == "duplicate_external_id": return {"id": error["existing_id"], "duplicate": True} retryable = res.status_code == 429 or res.status_code >= 500 or error.get("code") == "idempotency_in_progress" if not retryable or attempt == attempts: raise RuntimeError(f"{error.get('code')}: {error.get('message')}") retry_after = float(res.headers.get("Retry-After") or 0) time.sleep(retry_after or 2 ** attempt * 0.25 + random.random() * 0.25) order = post_with_retry("/orders", order_body, f"order-{store_order_id}") ``` ## Webhooks are idempotent too Our webhook deliveries can arrive more than once. Each event has a stable `id` (also the `webhook-id` header) — see [Webhooks best practices](https://swaglists.com/docs/webhooks.md#best-practices).