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 -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 ConflictwithidempotentReplay: 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.
{
"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
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:
// 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-Idconsistent across retries.
Pairs with rate-limit backoff
429 or a 5xx with exponential backoff is safe — see rate limits.