Skip to content

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:

  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

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

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}`);

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.