Idempotency
Retry requests safely with Idempotency-Key, and make order forwarding duplicate-proof with external_id.
Networks fail. A request can time out after we processed it, and retrying blindly could create a second order. Two mechanisms make retries safe:
Idempotency-Keyheader — on everyPOST,PATCHandDELETE, so a retried request returns the original result instead of running twice.external_idon orders — your own order id, unique per mode, so the same store order can never become two orders.
Use both.
Idempotency-Key
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.jsonHow 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.
GETrequests 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:
{
"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
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}`);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.