Skip to main content

Idempotency

Networks can fail after the server has already done its work. A timeout doesn't tell you whether a card was actually issued.

Idempotency keys let you safely retry a request without accidentally creating the same thing twice.

Using it​

Send a unique key with every POST request:

curl -X POST "https://api.aletaplanet.com/v1/cards" \
-H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \
-H "Idempotency-Key: 1f3a8c2e-5b7d-4e91-a0c6-2d8f4b6a9e13" \
-H "Content-Type: application/json" \
-d '{ "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTV", "form_factor": "virtual" }'

Create the key before the first attempt and reuse the same key for every retry of that operation.

A UUID v4 works well. A key based on your own primary key, such as card-issue-{your_id}, can be even better because you can recreate it after a process restart.

Semantics​

SituationResult
First request with a keyProcessed normally; response is cached
Same key, identical bodyCached response is replayed. Nothing happens twice
Same key, different body409 with idempotency_key_reuse
Same key, original request still in flight409 with idempotency_request_in_progress
Key is older than 24 hoursTreated as a new request

The replay window is 24 hours.

Replayed responses include this header:

Idempotent-Replay: true

The replay contains the exact status code and body from the original request, including errors. If the first attempt returned 422, the replay returns 422 as well.

Beyond the 24-hour window: client_transaction_id​

An idempotency key protects a retry. It doesn't protect you if the same batch job runs again a week later, after the idempotency record has expired.

For the two endpoints that move money, POST /v1/accounts/{id}/credit and POST /v1/accounts/{id}/debit, also send your own client_transaction_id in the request body.

This should be the ID of the thing that caused the movement in your system, such as a payroll line, invoice, or sweep. It's unique per account and doesn't expire.

SituationResult
First movement with an IDProcessed normally
Same ID, same amount and direction200, Idempotent-Replay: true, nothing moves
Same ID, different amount or direction409 with adjustment_already_applied
Same ID, original request still in flight409 with idempotency_request_in_progress

reference is different. It's just a free-text label with no uniqueness requirement and is intended for reconciliation. Multiple movements can have the same reference.

Which methods​

MethodBehavior
POSTHonors Idempotency-Key. Always send one
GETNaturally idempotent. Key is ignored
PATCHHonors the key. Recommended
DELETENaturally idempotent. Key is ignored

Body matching​

Requests are compared using a canonical form of the body. Key order and whitespace don't matter, but values do.

These two requests are treated as the same:

{"form_factor": "virtual", "cardholder_id": "chd_01HX8Z"}
{"cardholder_id":"chd_01HX8Z","form_factor":"virtual"}

This one is different and returns 409:

{"cardholder_id": "chd_01HX8Z", "form_factor": "physical"}

That 409 is intentional. It means a request that was supposed to be a retry wasn't actually identical. That's something your application should surface rather than silently resolving.

Retrying correctly​

const idempotencyKey = randomUUID();

for (let attempt = 0; attempt < 5; attempt++) {
try {
return await aleta.cards.create(body, { idempotencyKey });
} catch (err) {
// 4xx other than 409-in-progress won't succeed on retry.
if (err.status < 500 && err.code !== 'idempotency_request_in_progress') {
throw err;
}

await sleep(2 ** attempt * 200 + Math.random() * 100);
}
}

Keep these three rules in mind:

  1. Create the key once, outside the loop. Generating a new key for every attempt defeats idempotency and is the most common mistake.
  2. Use exponential backoff with jitter.
  3. Don't retry 4xx errors, except 409 idempotency_request_in_progress. Other 4xx errors won't start succeeding just because you retry them.

Scope​

Idempotency keys are scoped to your organization and environment. The same key in sandbox and production refers to completely separate requests.

Keys are not scoped per endpoint, either. Reusing the same key on two different endpoints returns 409.