Idempotency
Networks fail mid-request, workers crash after sending, and webhook sources retry on their own schedule. The Idempotency-Key header makes all of that safe: a retried write returns the stored result of the first attempt instead of acting twice. This page specifies the header's exact semantics, the retention window, and how it composes with the kernel's own idempotency keys and single-flight locks, the layers that make a 657-message flood execute once.
Shared conventions, including authentication, pagination, and the error envelope, are defined in the API overview. The conceptual treatment is in Single-flight & idempotency.
The Idempotency-Key header
Send an Idempotency-Key header on any POST. The value is a string of your choosing, 1–255 characters. GET and DELETE requests are idempotent by definition and ignore the header; PATCH accepts it and applies the same replay semantics.
curl -X POST https://api.fibric.io/v1/events \
-H "Authorization: Bearer $FIBRIC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: magento:SO-10884:v7" \
-d '{"source": "magento", "event_type": "order.updated", "payload": {"order": "SO-10884"}}'
The header is optional on most routes but should be treated as mandatory practice: every write that a retry could duplicate deserves a key. Ingest and approval examples throughout these docs carry one for that reason.
Replay semantics
The first request to arrive with a given key binds that key to its exact request body and, once processing finishes, to its result. Every subsequent request with the same key is a replay. Three outcomes are possible, and only one is an error:
| Replay | Result | Meaning |
|---|---|---|
| Same key, same body | 200 with the original response | The safe retry. Nothing executed twice; you receive the stored result of the first attempt, including its original request_id. |
| Same key, different body | 409 idempotency_conflict | Two different requests claimed to be the same one, which means your client has a bug. Neither the stored result nor the new body is acted on. |
| New key | Normal processing | A genuinely new request. |
Body comparison is byte-exact on the canonicalized JSON: key order does not matter, whitespace does not matter, but a changed value, an added field, or a removed field makes the body different. Headers other than Idempotency-Key itself do not participate in the comparison.
Concurrent replays
If a replay arrives while the first attempt is still processing, the replay does not run the operation a second time and does not wait indefinitely. It fails fast with 409 and code idempotency_in_progress, carrying a Retry-After header. Retry the same request unchanged after the interval; by then the first attempt has finished and you receive its stored result.
HTTP/1.1 409 Conflict
Retry-After: 2
{
"error": {
"type": "conflict_error",
"code": "idempotency_in_progress",
"message": "A request with this Idempotency-Key is still processing; retry unchanged after Retry-After.",
"doc_url": "https://fibric.io/docs/api-idempotency#concurrent-replays",
"request_id": "req_2c88f014"
}
}
This is the API-layer cousin of the executor's single-flight lock: both refuse to interleave duplicate work, and both resolve with an unchanged retry.
Scope and retention
- Scope. Keys are scoped per tenant and per route. The same key value on
POST /eventsandPOST /operatorsnames two independent idempotency records; two tenants can use identical key values without interference. - Retention. A key and its stored result are retained for 24 hours from first use. After the window expires, the same key value is treated as new and the operation runs again.
- Errors are stored too. If the first attempt failed with a
4xx, the failure is the stored result, and a same-body replay returns the same error without reprocessing. A5xxor a network failure before the server accepted the request stores nothing; the retry processes normally.
After 24 hours, the HTTP layer will accept a reused key, but a side-effecting plan action still carries its own idempotency_key in the body, and the executor's dedup ledger does not expire with the header. A re-proposed action whose kernel key was already applied disposes as DEDUP, not as a second side effect. The header window is a convenience boundary; the kernel key is the lock. See below.
Deriving good keys
Derive keys from the operation's natural identity, not from random values. A random UUID generated per attempt defeats the mechanism: the retry gets a new key and duplicates the work. The key should answer "which real-world operation is this?" so that any process, including a crashed-and-restarted one, derives the same key for the same operation.
| Operation | Key pattern | Example |
|---|---|---|
| Ingesting a source webhook | source:entity:version | magento:SO-10884:v7 |
| Approving a plan | approve-plan_id | approve-pl_7c1a |
| Provisioning a resource | purpose-name | op-create-order-risk |
| A scheduled job's write | job:window | reconcile:2026-07-02T15 |
Two cautions. First, the key must change when the intended operation changes: ingesting version 8 of the same order needs a new key, hence the :v7 suffix. Second, the key must not change when only the attempt changes: no timestamps of the attempt, no retry counters.
Relation to kernel idempotency keys
Fibric has two idempotency layers, and they protect against different duplicates:
HTTP Idempotency-Key header | Kernel idempotency_key field | |
|---|---|---|
| Protects against | Duplicate requests: network retries, crashed clients, webhook storms. | Duplicate side effects: the same action proposed again in a new plan, by a new request, or after the header window. |
| Lives | In the request headers, set by you. | In each PlannedAction body, set by the proposer, enforced by the deterministic executor. |
| Scope | Per tenant and route, 24 hours. | Per tenant, durable in the executor's dedup ledger. |
| On duplicate | Returns the stored HTTP response. | Disposes the action as DEDUP, ok: true, no side effect, receipt written. |
The layers compose. Replay a plan approval with the same header key and you get the stored approval response. Approve a different plan that happens to contain an action whose kernel idempotency_key was already applied, and that one action disposes as DEDUP while the rest of the plan proceeds. Either way the side effect happens once, and the receipt ledger records exactly what was deduplicated and when.
Alongside both layers, the executor serializes side effects per entity_key, one thing in flight per order, per room, per asset, which is what surfaces as 409 entity_locked across requests. The three mechanisms together are specified in Single-flight & idempotency.
A correct retry loop
async function post(path: string, body: unknown, idempotencyKey: string) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`https://api.fibric.io/v1${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FIBRIC_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey, // same key on every attempt
},
body: JSON.stringify(body), // same body on every attempt
});
if (res.status < 500 && res.status !== 409 && res.status !== 429) return res;
const err = res.status === 409 ? (await res.clone().json()).error : null;
if (err && err.code === "idempotency_conflict") return res; // client bug: do not retry
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error("retries exhausted");
}
The two invariants the loop must hold: the key and the body are identical on every attempt, and idempotency_conflict is surfaced rather than retried, because it means the caller broke the first invariant.
Errors
| Status | Code | When | Retry? |
|---|---|---|---|
400 | invalid_parameter | The header value is empty or longer than 255 characters. | No; fix the key. |
409 | idempotency_conflict | The key was replayed with a different body. See Errors. | No; this is a client bug. |
409 | idempotency_in_progress | The first attempt with this key is still processing. | Yes, unchanged, after Retry-After. |
409 | entity_locked | The single-flight lock on the target entity_key is held. See Errors. | Yes, unchanged, after Retry-After. |