Appearance
Idempotency
A request times out. Your client has no idea whether it landed. Retry it and you have two purchase orders; leave it and you may have none.
Send Idempotency-Key on the write and the retry is safe. The second request with that key does not run: it is answered with the first one's status and the first one's body, so the retry ends holding the order's id rather than an acknowledgement it cannot use.
bash
curl -X POST "https://api.app.tightly.io/api/v1/organizations/<organization_id>/purchase-orders" \
-H "Authorization: Bearer $TIGHTLY_API_KEY" \
-H "Idempotency-Key: 5f8b0c8a-1d9c-4b7e-9d1a-2f0e6c3b7a44" \
-H "Content-Type: application/json" \
-d @body.jsonWhere it applies
| Which requests | Every mutating request on a key: POST, PATCH, PUT, DELETE. A GET is already idempotent and takes no key. |
| Which credential | An API key. A signed-in session never reaches this, so the app's own behaviour is unchanged. |
| Whether it is required | No, on all but two writes. Send no header and you are byte for byte where you were. The two below refuse the write without one. |
| What the key may be | Any string up to 255 characters. A UUID, a ULID, or your own order reference. Longer than that and the header is ignored, and the write runs without the guarantee rather than being refused. On the two below, an over-long key is refused instead of ignored. |
| How long it is remembered | 24 hours from the first request, then the record expires. 30 days on the two below. |
| Whose key it is | The API key that sent it. Two of your own keys can use the same string for different writes. |
Generate one key per intended write, not one per attempt. A key reused in a loop is the one client bug this feature turns into a refusal instead of a duplicate.
The two writes that require one
Create a sales order (POST /api/v1/sales/orders) and record a return (POST /api/v1/sales/returns) do not treat the header as optional. Both refuse the write when it is missing, and both keep the key 30 days rather than 24 hours.
The key on those two is usually a retailer's own order number rather than a UUID, and the second attempt can be a week later, when a buyer resends the same purchase order and nobody wants a second order out of it. A day is not long enough to catch that.
The rest of this page holds with one exception, and it is the exception that week-long window creates. The same key with the same body still replays, and the key is still yours rather than your organisation's, so another integrator on the same organisation may pick the same retailer's order number without either of you seeing the other's answer. But the code on a spent key is not the same after the first day. Inside 24 hours these two answer idempotency_conflict like every other write. Past 24 hours the 24-hour record is gone and the 30-day one answers in its place: a different body on a spent key is 409 idempotency_key_reused, and a key a concurrent request is still holding is 409 idempotency_key_in_flight with Retry-After: 2. Match on both spellings for these two.
The three answers
The same key and the same body. The first request's answer, replayed: the same status code and the same JSON. Nothing runs a second time.
The same key and a different request. 409 idempotency_conflict.
json
{
"code": "idempotency_conflict",
"message": {
"code": "idempotency_conflict",
"desc": "This Idempotency-Key was already used for a different request. Use a new key for a new write, or resend the original request unchanged to replay its answer.",
"doc_url": "https://docs.tightly.io/api/guides/errors#idempotency_conflict"
}
}This is the dangerous case and it is why the middle answer is a refusal rather than a replay. If you believe you are placing a second order and the API hands back the first one's response, you record it as the second and the two are one for ever. A different body, a different operation or a different method all count as a different request.
The same key, still running. 409 idempotency_in_flight, with Retry-After: 1.
json
{
"code": "idempotency_in_flight",
"message": {
"code": "idempotency_in_flight",
"desc": "A request with this Idempotency-Key is still being processed. Retry in a moment to read its answer.",
"doc_url": "https://docs.tightly.io/api/guides/errors#idempotency_in_flight"
}
}Two of your workers retried at once. The loser is told to wait rather than allowed to write a second order while the first is unfinished. Retry after a second and you get the replay.
The two 409s carry different codes because they mean opposite things. One never resolves however long you wait; the other resolves in about a second. Branch on message.code, never on the status.
A failed write frees its key
Only an answer worth replaying is stored. If the write is refused or raises, the key is released and your next attempt with the same key is a fresh attempt.
That matters more than it sounds. Without it a 400 for a malformed body would hold your key for 24 hours, your retry would read idempotency_in_flight for ever, and you would learn to generate a new key every time, which is the habit that makes the header useless.
If the store cannot answer
503 with a Retry-After, and the write is not attempted:
json
{
"message": {
"desc": "This write was not attempted: the idempotency store could not be reached, so a retry could not be told apart from a new request. Nothing has changed. Retry with the same Idempotency-Key."
}
}Everything else on this door degrades quietly when a store is down. This one refuses, because the promise it makes is exactly the promise it cannot keep, and running the write anyway would break it at the moment you were relying on it. Nothing changed, so wait the seconds Retry-After names and retry with the same key.
A client that gets it right
js
import { randomUUID } from 'node:crypto'
async function createOrder(order) {
const key = randomUUID() // Once per order, kept across retries.
for (let attempt = 0; attempt < 5; attempt++) {
const response = await fetch(`${base}/api/v1/organizations/${org}/purchase-orders`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.TIGHTLY_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify(order),
})
const body = await response.json()
if (response.ok) return body.data
// Still running, or the store was briefly out. Both are safe to retry with the same key, and
// both name the seconds to wait, so obey the header rather than backing off on a guess.
if (body.code === 'idempotency_in_flight' || response.status === 503) {
const wait = Number(response.headers.get('Retry-After')) || 2 ** attempt
await new Promise(resolve => setTimeout(resolve, wait * 1000))
continue
}
// A reused key on a different body is a bug in this function, not a transient failure.
throw new Error(`${body.code}: ${body.message.desc}`)
}
throw new Error('Gave up waiting for the first attempt to finish.')
}Store the key beside the order in your own system, not in a variable that dies with the process. A key you cannot find again after a crash is a key that cannot protect the retry the crash caused.
What it does not do
- It does not make a write repeatable. It makes it happen once. A genuinely new order needs a new key.
- It does not order your writes. Two different keys arriving together both run.
- It does not cover reads. A
GETchanges nothing, so there is nothing to protect. - It does not survive a day. After 24 hours the record is gone and the same string is a new key. The two writes that require a key keep theirs 30 days.
See Errors for the two codes beside the rest, and Rate limits for what a retry costs you.