Idempotency

How to retry safely — natural idempotency on the catalogue, and the Idempotency-Key header on payment writes.

Networks fail. A request can succeed on Localoy's side while your server never sees the answer. The Open Network API is built so that you can retry without doing the work twice.

Catalogue writes#

Catalogue writes are idempotent by their natural key, the externalId:

CallRepeated
POST /catalog/itemsThe first call creates the item (201); a repeat updates it (200).
PATCH /catalog/items/{externalId}Setting the same values again leaves the item the same.
DELETE /catalog/items/{externalId}The second delete answers 404 — treat that as "already gone".

The catalogue routes do not use an Idempotency-Key header.

Payment writes#

POST /payments/sessions/{id}/result and POST /payments/sessions/{id}/refund accept an optional Idempotency-Key header of up to 255 characters.

Header
Idempotency-Key: 7d1f0b3e-2c4a-4b9e-8f51-0a6c2d9e4b17

A repeat with a key already used on the same session is not applied again. It answers 200 with "replayed": true and the session as it is now.

Even without a key, reporting the status a session already has is a no-op that answers 200 with "replayed": true.

Retrying after a lost response
Retrying after a lost responseYour serverLocaloy1. POST …/result Idempotency-Key: K1Recorded — the order is paidThe response is lost: network timeout2. Same request, same key K13. 200 replayed: true — not applied twiceAnother session: a refused request, corrected4. POST …/result amountMinor: 9000, key K25. 409 payment_amount_mismatch6. Corrected body with a NEW key K37. 200 replayed: false — recorded
  1. Your server → Localoy: POST …/result Idempotency-Key: K1
  2. Note: Recorded — the order is paid
  3. Note: The response is lost: network timeout
  4. Your server → Localoy: Same request, same key K1
  5. Localoy → Your server: 200 replayed: true — not applied twice
  6. — Another session: a refused request, corrected —
  7. Your server → Localoy: POST …/result amountMinor: 9000, key K2
  8. Localoy → Your server: 409 payment_amount_mismatch
  9. Your server → Localoy: Corrected body with a NEW key K3
  10. Localoy → Your server: 200 replayed: false — recorded

Use a new key for a corrected request

An Idempotency-Key is remembered with every attempt, including refused ones, and it is not compared with the request body. If a result is refused — say 409 payment_amount_mismatch — and you fix the body but resend it with the same key, the API answers 200 with "replayed": true and applies nothing. The order stays unpaid.

Reuse a key only to retry the identical request after a timeout or network error. Generate a new key for every new or corrected request, and always check data.status in the response.

A safe retry loop#

Node.js
import { randomUUID } from "node:crypto";

async function reportResult(sessionId, body) {
  const idempotencyKey = randomUUID(); // one key per logical request
  for (let attempt = 1; attempt <= 5; attempt++) {
    try {
      const res = await fetch(
        `${process.env.LOCALOY_BASE_URL}/payments/sessions/${sessionId}/result`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.LOCALOY_API_KEY}`,
            "Content-Type": "application/json",
            "Idempotency-Key": idempotencyKey,
          },
          body: JSON.stringify(body),
        },
      );
      if (res.status < 500 && res.status !== 429) return await res.json();
    } catch {
      // network error: fall through and retry with the same key
    }
    await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
  }
  throw new Error("Could not report the payment result");
}