# Idempotency

How to safely retry a purchase after a timeout or a dropped connection.

If the response to `POST /purchase` never arrived, you don't know whether the purchase went through. An unprotected retry is a second purchase and a second charge. So send an `idempotencyKey` — your own unique purchase identifier, for example the order ID in your system.

- The first request with a key creates the order.
- A repeat with the same key and the same product returns the outcome of that same order — with the same status code and `"replayed": true`. Nothing is charged twice, even if the product has sold out since.
- The same key with a different product — `422 idempotency_key_reused`.
- If no order was created (e.g. `402`, insufficient balance), the key stays free: after a top-up you can retry with it.

## Format

1–128 printable ASCII characters (`!`…`~`), no spaces or non-Latin letters. Case matters: `ABC` and `abc` are two different purchases. A key is scoped to your client.

## Retrying with the same key

```javascript
async function buy(productId, idempotencyKey) {
  for (let attempt = 1; attempt <= 5; attempt++) {
    try {
      const res = await fetch(`https://merchant.dbdlobby.com/purchase/${productId}`, {
        method: "POST",
        headers: { "X-Entity-Key": KEY, "Content-Type": "application/json" },
        body: JSON.stringify({ idempotencyKey }), // the same key in every attempt
        signal: AbortSignal.timeout(90_000),
      });
      if (res.status >= 500 && res.status !== 502) throw new Error(`HTTP ${res.status}`);
      return { status: res.status, body: await res.json() };
    } catch (e) {
      // Network, timeout or 5xx: the outcome is unknown — retry WITH THE SAME key.
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
    }
  }
  throw new Error("API unavailable; retry later with the same idempotencyKey");
}
```

The key is optional, but without it a retry after a timeout creates a second purchase. It may become mandatory in the future.
