Skip to content

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

  1. Generate a UUID v4 for each unique operation.
  2. Send it in the Idempotency-Key header.
  3. RekberPay caches the full response (status code + body) for 24 hours.
  4. Retry the same key → get the cached response with Idempotent-Replay: true.
  5. The side effects (creating an escrow, charging a card) only happen on the first successful request.

Example

Make a request with an idempotency key:

bash
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:

bash
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:

js
import { randomUUID } from 'node:crypto';
const key = randomUUID();
php
<?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();
python
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:

js
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:

sql
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

OutcomeCached?Why
2xx successYesThe operation succeeded — replay returns the same result
4xx (validation, signature, expired link)NoThe operation didn't happen — retrying with fixed input should work
429 (rate-limited)NoSame — retry after backoff
5xx (server error)NoThe 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:

js
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 escrows
  • POST /api/escrows/:id/pay — initiating payment
  • POST /api/escrows/:id/cancel — cancelling
  • POST /api/withdrawals — withdrawal requests

Read endpoints (GET) ignore the header — they're already idempotent by nature.

Best practices

Send a key on every mutation

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.

Generate per-attempt, not per-operation

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.

Don't reuse keys across users

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

ErrorCauseFix
400 IDEMPOTENCY_KEY_INVALIDNot a UUID v4Generate with randomUUID() / uuid.uuid4()
409 IDEMPOTENCY_PARAMS_MISMATCHSame key, different request bodyUse a fresh key for the new operation
Stale 4xx on retryFirst call returned 4xx; key isn't cachedFix the input and retry — same key is fine

How retries should look

A reasonable retry policy for a webhook job:

js
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

Released under the proprietary RekberPay license. Built for Indonesian merchants.