SIMWAY
Partners

Idempotency

One header on one endpoint, and the part of this API integrators most often get wrong. Read it in order: the rules are simple, but two of them are surprising and both cost real money when they surprise you in production.

What actually happens

The order row is written — claiming your key on a uniqueness constraint — before the money moves and before anything reaches a supplier. A duplicate delivery loses that race at the database rather than at the application, so two identical requests arriving at the same instant on two servers cannot both proceed. There is no window in which a retry can produce a second order.

Which means the fingerprint matters, so here is exactly what it is taken over: plan_id, quantity, reference and metadata, with keys sorted at every level and normalised values.

The four things a reused key can do

Send the same key twice and one of exactly four things happens. Three of them are ordinary; the fourth is the one to design around.

1. Same key, same body — a replay

HTTP 200 (not 202) with Idempotency-Replayed: true, and the body is the order as it stands now — current status, current esims[], current poll_after_ms. Nothing is re-executed.

200 — replayed
{
  "object": "order",
  "id": "po_c7m2k9x4v1b6n3q8w5t0r2j7",
  "mode": "live",
  "status": "completed",
  "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5",
  "quantity": 3,
  "unit_price_cents": 1752,
  "total_cents": 5256,
  "list_unit_cents": 1990,
  "discount_bps": 1195,
  "price_source": "tier:silver",
  "tier": "silver",
  "reference": "BK-99182",
  "metadata": {
    "bookingId": "BK-99182",
    "travellerEmail": "[email protected]"
  },
  "esims": [
    {
      "object": "esim",
      "iccid": "8944500102030405062",
      "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7",
      "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5",
      "status": "provisioned",
      "activation": {
        "smdp_address": "rsp.example.com",
        "matching_id": "K2-1A9QX-88ZLM",
        "activation_code": "LPA:1$rsp.example.com$K2-1A9QX-88ZLM",
        "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
        "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-1A9QX-88ZLM"
      },
      "usage": {
        "data_used_bytes": 0,
        "data_total_bytes": 5368709120,
        "voice_used_min": 0,
        "voice_total_min": null
      },
      "activated_at": null,
      "expires_at": "2026-09-17T06:41:03Z"
    },
    {
      "object": "esim",
      "iccid": "8944500102030405070",
      "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7",
      "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5",
      "status": "provisioned",
      "activation": {
        "smdp_address": "rsp.example.com",
        "matching_id": "K2-7F4RD-31TVP",
        "activation_code": "LPA:1$rsp.example.com$K2-7F4RD-31TVP",
        "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
        "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-7F4RD-31TVP"
      },
      "usage": {
        "data_used_bytes": 0,
        "data_total_bytes": 5368709120,
        "voice_used_min": 0,
        "voice_total_min": null
      },
      "activated_at": null,
      "expires_at": "2026-09-17T06:41:03Z"
    },
    {
      "object": "esim",
      "iccid": "8944500102030405088",
      "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7",
      "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5",
      "status": "provisioned",
      "activation": {
        "smdp_address": "rsp.example.com",
        "matching_id": "K2-5B2WH-96YKS",
        "activation_code": "LPA:1$rsp.example.com$K2-5B2WH-96YKS",
        "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
        "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-5B2WH-96YKS"
      },
      "usage": {
        "data_used_bytes": 0,
        "data_total_bytes": 5368709120,
        "voice_used_min": 0,
        "voice_total_min": null
      },
      "activated_at": null,
      "expires_at": "2026-09-17T06:41:03Z"
    }
  ],
  "failure_code": null,
  "failure_message": null,
  "balance_after_cents": 43053,
  "poll_after_ms": null,
  "created_at": "2026-08-17T09:14:22Z",
  "completed_at": "2026-08-17T09:14:27Z"
}

2. Same key, different body — a conflict

409 idempotency.key_reused, param: "Idempotency-Key". Either use a new key, or resend the original body. Silently replaying the first order would hand you a profile for a plan you did not ask for the second time, which is worse than an error.

3. Same key, the other mode — also a conflict

409 idempotency.key_reused, with a message saying which mode used it. This is checked before the body comparison, so a sandbox key reused in live is a mode conflict rather than a confusing body mismatch. Namespace your keys per mode.

4. Same key, and the order was refused before it existed

The original refusal is raised again — its original status, its original code — with a sentence added saying the key is now bound to that refusal, and with balance_cents and required_cents refreshed to their current values. It will keep doing that forever.

The rule to design around

CodeBinds the key?
balance.insufficientYes, permanently
partner.not_liveYes, permanently
partner.suspendedYes, permanently
partner.closedYes, permanently

Everything else leaves the key usable, and the distinction is not arbitrary — it is whether the order row was written. These are all raised before it exists, so the key was never claimed:

  • 404 plan.not_found, 400 plan.inactive, 422 plan.unavailable — a catalogue problem. Fix the plan reference and retry the same key.
  • 400 request.invalid — a malformed request. Fix it and retry the same key.

And anything that goes wrong after the 202 — a supplier failure, a partial delivery, an order abandoned as unopenable — replays as case 1. It is an ordinary read of an order you already know about, not an error bound to a key.

Choosing keys

  • A key per order intent, not per HTTP attempt. Every retry of the same intent reuses the same key; that is the entire point.
  • Derive it from something you own. Your booking id, or a UUID stored beside the order in your database before you send anything. A key generated at the moment of sending cannot be reused by a process that crashed mid-request.
  • Namespace per mode. live:BK-99182 and test:BK-99182, or a fresh UUID each time. One namespace across both is a 409 waiting for your first live deploy.
  • Mint a new key when the intent changes. Different quantity, different plan, or the same basket after a top-up — all new intents, all new keys.
The shape that works
// Persist the key with the order, before sending anything.
const key = order.idempotencyKey ?? crypto.randomUUID();   // e.g. 8f3c2a1e-7b64-4d59-9e02-1c5a7f8b3d20
await db.orders.update(order.id, { idempotencyKey: key });

for (let attempt = 0; attempt < 3; attempt++) {
  try {
    const res = await post('/orders', body, { 'Idempotency-Key': key });
    return res;                       // 202 first time, 200 on a replay
  } catch (e) {
    if (e.status === 402) {
      // Bound forever. Top up, then start again with a NEW key.
      await db.orders.update(order.id, { idempotencyKey: null });
      throw e;
    }
    if (e.status >= 500) continue;    // safe: same key, no double charge
    throw e;                          // 4xx: fix the request
  }
}

When you do not know whether it arrived

  1. Resend it, same key. Not a fresh one. You will get either the 202 you missed or a 200 replay, and never a second order.
  2. Never retry a 202 you did receive. The order is durable on its own: a detached run, a drain on shutdown, and a reconciler that re-drives anything stranded. It will reach a terminal state without your help.
  3. A 500 is safe to retry with the same key. That is what the mechanism is for.

The lifecycle page covers what a terminal state means and when money comes back.