Idempotency
Networks fail. Servers crash. Your code throws right after a successful POST. Without idempotency, every retry risks creating a duplicate escrow, double-charging a buyer, or sending a webhook twice.
RekberPay supports an Idempotency-Key header on every mutation. With it, retries are free.
How it works
- Generate a UUID v4 for each unique operation.
- Send it in the
Idempotency-Keyheader. - RekberPay caches the full response (status code + body) for 24 hours.
- Retry the same key → get the cached response with
Idempotent-Replay: true. - The side effects (creating an escrow, charging a card) only happen on the first successful request.
Example
Make a request with an idempotency key:
curl -X POST https://api.rekberpay.com/api/escrows/from-link \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d '{ ... }'If the network drops before you read the response, retry with the same key:
curl -X POST https://api.rekberpay.com/api/escrows/from-link \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d '{ ... }'The second response is identical to the first — same escrow.id, same status code, same body — and includes Idempotent-Replay: true in the headers.
Generating keys
Use UUID v4. Most languages have it built in:
import { randomUUID } from 'node:crypto';
const key = randomUUID();<?php
function uuidv4(): string {
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
$key = uuidv4();import uuid
key = str(uuid.uuid4())The API rejects malformed keys with 400 IDEMPOTENCY_KEY_INVALID.
Scoping
Idempotency keys are scoped to endpoint + authenticated user. The same key on different endpoints, or from different users, is treated as separate operations.
This means:
- Good: reusing a key across retries of the same operation
- Avoid: reusing a key for two different escrows. You will get the cached response from the first one, which is probably not what you want.
A good pattern: derive the key deterministically from your business identifier:
import crypto from 'node:crypto';
function idempotencyKey(operation, businessId) {
return crypto
.createHash('sha256')
.update(`${operation}:${businessId}`)
.digest('hex')
.slice(0, 32)
.replace(
/^(.{8})(.{4})(.{4})(.{4})(.{12}).*/,
'$1-$2-$3-$4-$5',
);
}
const key = idempotencyKey('create-escrow', orderId);Or, simpler: generate once per attempt, store with the order:
ALTER TABLE orders ADD COLUMN escrow_idempotency_key UUID;When retrying, look up the existing key. If none, generate and store one.
TTL
Cached responses expire after 24 hours. After that, retrying with the same key creates a fresh operation.
This is long enough to handle:
- Browser/mobile retries on network failure
- Job queue retries with backoff (typical max ~6 hours)
- Cron-driven retries
If you need longer guarantees, store the result yourself.
What's cached vs not
| Outcome | Cached? | Why |
|---|---|---|
| 2xx success | Yes | The operation succeeded — replay returns the same result |
| 4xx (validation, signature, expired link) | No | The operation didn't happen — retrying with fixed input should work |
| 429 (rate-limited) | No | Same — retry after backoff |
| 5xx (server error) | No | The operation may or may not have happened — safe to retry |
This means 4xx and 5xx responses can be retried with the same key. A non-2xx response releases the key, so the retry re-executes the handler instead of replaying the cached failure for the rest of the 24h TTL. Only successful (2xx) responses lock the key.
Detecting replays
Cached responses include Idempotent-Replay: true:
const res = await fetch(url, { ... });
const isReplay = res.headers.get('Idempotent-Replay') === 'true';
if (isReplay) {
// The first request succeeded; we just got the cached response.
// Side effects already happened. No further action needed.
}Useful for logging and dashboards — you can see how often retries are hitting the cache.
Where it's supported
Idempotency keys work on every mutation endpoint:
POST /api/escrows/from-link— creating escrowsPOST /api/escrows/:id/pay— initiating paymentPOST /api/escrows/:id/cancel— cancellingPOST /api/withdrawals— withdrawal requests
Read endpoints (GET) ignore the header — they're already idempotent by nature.
Best practices
Even for one-shot operations you don't expect to retry. The cost is one header; the benefit is automatic retry safety the first time something goes wrong.
If you're using a job queue with retries, generate the key when the job is created, not when it runs. Then every retry of that job uses the same key. If you generate inside the job, each retry is a different key.
If two users on your platform both place an order with the same internal order ID, generate different keys. The API treats (user, key) as the identity, but your client should be defensive.
Common errors
| Error | Cause | Fix |
|---|---|---|
400 IDEMPOTENCY_KEY_INVALID | Not a UUID v4 | Generate with randomUUID() / uuid.uuid4() |
409 IDEMPOTENCY_PARAMS_MISMATCH | Same key, different request body | Use a fresh key for the new operation |
| Stale 4xx on retry | First call returned 4xx; key isn't cached | Fix the input and retry — same key is fine |
How retries should look
A reasonable retry policy for a webhook job:
async function createEscrowWithRetry(params, maxAttempts = 5) {
const key = randomUUID();
let attempt = 0;
while (attempt < maxAttempts) {
attempt++;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify(params),
});
if (res.ok) return await res.json();
if (res.status >= 500) {
// Server error — back off and retry with same key
await sleep(1000 * Math.pow(2, attempt - 1));
continue;
}
// 4xx: don't retry, the input is wrong
throw new Error(await res.text());
} catch (err) {
if (attempt >= maxAttempts) throw err;
await sleep(1000 * Math.pow(2, attempt - 1));
}
}
}Backoff sequence: 1s, 2s, 4s, 8s, 16s. Cap at 5 attempts. Same key throughout.
Next steps
- Handling webhooks — make your incoming side idempotent too
- Testing — verify your retry logic in sandbox