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
| Situation | Result |
|---|---|
| First request with a key | Processed normally; response is cached |
| Same key, identical body | Cached response is replayed. Nothing happens twice |
| Same key, different body | 409 with idempotency_key_reuse |
| Same key, original request still in flight | 409 with idempotency_request_in_progress |
| Key is older than 24 hours | Treated 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.
| Situation | Result |
|---|---|
| First movement with an ID | Processed normally |
| Same ID, same amount and direction | 200, Idempotent-Replay: true, nothing moves |
| Same ID, different amount or direction | 409 with adjustment_already_applied |
| Same ID, original request still in flight | 409 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
| Method | Behavior |
|---|---|
POST | Honors Idempotency-Key. Always send one |
GET | Naturally idempotent. Key is ignored |
PATCH | Honors the key. Recommended |
DELETE | Naturally 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:
- Create the key once, outside the loop. Generating a new key for every attempt defeats idempotency and is the most common mistake.
- Use exponential backoff with jitter.
- Don't retry
4xxerrors, except409 idempotency_request_in_progress. Other4xxerrors 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.