Idempotency & retries

Every write takes an idempotency key. It is what makes a retry safe: the second call returns the first call's result instead of charging you again.

Why it is mandatory

A request that times out tells you nothing about whether it arrived. Without a key, retrying risks issuing a second subscription and paying for it; not retrying risks leaving a paid customer with nothing. The key removes the guess — retry freely, and you get the original outcome back.

Choosing a key

Any string of 8 to 128 characters. A UUID per logical operation is the simplest choice that works. Keys are scoped to your account, so you never need to worry about colliding with another partner.

One key per logical operation, not per attempt. Generating a fresh key on each retry defeats the entire mechanism — each attempt looks like a brand new request and charges you again. Mint the key before the first attempt and persist it.

What a replay returns

A repeated call with the same key and the same body returns the original response verbatim — the same order id, the same remaining balance, and for a promo batch the same codes. No additional quota is spent.

Two responses mean the retry needs handling rather than acceptance:

Reseller.RequestInProgress 409

The first call is still running. Wait and retry with the same key — do not start a new one.

Reseller.IdempotencyKeyConflict 409

The key was used before with a different body. This is a bug on your side — usually a key reused across two different sales. Nothing was charged.

Replays do not last forever. The stored result is kept for about 72 hours. After that the same key is treated as a fresh request and will spend quota again, so reconcile within the window — this matters most for promo codes, which are unrecoverable once the record expires.

A retry loop that behaves

TypeScript
// Mint the key once, before the first attempt, and persist it
// alongside your own record of the sale.
const key = order.idempotencyKey ?? crypto.randomUUID();
await db.orders.update(order.id, { idempotencyKey: key });

for (let attempt = 0; attempt < 3; attempt++) {
  const res = await fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify({ planSlug, login, idempotencyKey: key }),
  });

  if (res.ok) return res.json();

  // 409 RequestInProgress means an identical call is still running.
  const problem = await res.json().catch(() => null);
  const code = problem?.type?.replace("/errors/", "");

  if (code === "Reseller.RequestInProgress" || res.status >= 500) {
    await sleep(1000 * 2 ** attempt);
    continue;
  }

  // Anything else is final — retrying will not change the outcome.
  throw new Error(code ?? `HTTP ${res.status}`);
}

The rule of thumb: retry on network failures, on RequestInProgress, and on 5xx. Treat every other 4xx as final, because the same request will keep failing — a misspelled login or an unknown plan slug needs fixing, not repeating.