Core concepts

Idempotent requests

Retry a write safely with an Idempotency-Key; the 409 replay contract.

Network calls can fail after the server has already done the work — a dropped connection, a timeout, a proxy hiccup. Retrying blindly risks creating the same record twice. An idempotency key makes any write safe to retry: the change is applied at most once, no matter how many times you send it.

How it works

Attach an Idempotency-Key header — a unique value you generate per logical operation (a UUID is ideal) — to any mutating request: POST, PATCH, PUT or DELETE.

curl · idempotent create
curl -X POST https://gulfpay-production.up.railway.app/api/v1/customers \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "X-Company-Id: cmp_123" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5f0b2b1e-8a1c-4c2b-9f3a-0d1e2f3a4b5c" \
  -d '{ "name": "Acme Trading LLC", "email": "ap@acme.example" }'
  • First time a key is seen — the request is processed normally and the key is recorded once it succeeds.
  • A retry with the same key after it already succeeded — the server does not apply the change again. It responds 409 Conflict with idempotentReplay: true.
  • A request that failed stays retryable — the key is only committed on success, so you can safely resend with the same key.

The replay response

Treat a 409 carrying idempotentReplay: true as success, not failure — it confirms the change landed exactly once. Stop retrying when you see it.

json · 409 idempotent replay
{
  "statusCode": 409,
  "message": "This change was already applied.",
  "idempotentReplay": true,
  "requestId": "req_...",
  "timestamp": "2026-09-07T09:00:00.000Z",
  "path": "/api/v1/customers"
}

409 is overloaded — check the flag

A plain 409 Conflict without idempotentReplay is a genuine uniqueness conflict (for example a duplicate code) and should not be retried. Always branch on the idempotentReplay flag, not the status code alone.

A safe retry loop

Keep the key stable across every attempt of the same operation, retry only on transient (5xx) errors, and treat an idempotent replay as a win:

javascript · retry with idempotency
// Retry a write safely: same key on every attempt.
async function createWithRetry(body, token, key) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(
      'https://gulfpay-production.up.railway.app/api/v1/customers',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${token}`,
          'X-Company-Id': 'cmp_123',
          'Content-Type': 'application/json',
          'Idempotency-Key': key, // stable across all attempts
        },
        body: JSON.stringify(body),
      },
    )

    if (res.ok) return res.json()                 // applied this attempt

    const err = await res.json().catch(() => ({}))
    if (res.status === 409 && err.idempotentReplay) {
      return { alreadyApplied: true }             // a PRIOR attempt won — success
    }
    if (res.status >= 500) {
      await new Promise((r) => setTimeout(r, 2 ** attempt * 500))
      continue                                    // transient — back off and retry
    }
    throw new Error(`Create failed: ${res.status}`) // 4xx (not a replay) — do not retry
  }
  throw new Error('Exhausted retries')
}

Rules of thumb

  • Generate a fresh key for each distinct operation so unrelated writes never collide.
  • Reuse the same key only when retrying the very same request.
  • Keys are retained for 7 days; after that a repeat is treated as new.
  • Idempotency scoping respects your company context — keep X-Company-Id consistent across retries.

Pairs with rate-limit backoff

Because writes are idempotent, retrying after a 429 or a 5xx with exponential backoff is safe — see rate limits.