Fibric. Docs fibric.io →
v1.0.0 ยท stable
Reference

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 · an idempotent write
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:

ReplayResultMeaning
Same key, same body200 with the original responseThe safe retry. Nothing executed twice; you receive the stored result of the first attempt, including its original request_id.
Same key, different body409 idempotency_conflictTwo 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 keyNormal processingA 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.

json · 409 idempotency_in_progress
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

!
The window bounds the API layer only

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.

OperationKey patternExample
Ingesting a source webhooksource:entity:versionmagento:SO-10884:v7
Approving a planapprove-plan_idapprove-pl_7c1a
Provisioning a resourcepurpose-nameop-create-order-risk
A scheduled job's writejob:windowreconcile: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 headerKernel idempotency_key field
Protects againstDuplicate 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.
LivesIn the request headers, set by you.In each PlannedAction body, set by the proposer, enforced by the deterministic executor.
ScopePer tenant and route, 24 hours.Per tenant, durable in the executor's dedup ledger.
On duplicateReturns 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

typescript · retry with a stable key
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

StatusCodeWhenRetry?
400invalid_parameterThe header value is empty or longer than 255 characters.No; fix the key.
409idempotency_conflictThe key was replayed with a different body. See Errors.No; this is a client bug.
409idempotency_in_progressThe first attempt with this key is still processing.Yes, unchanged, after Retry-After.
409entity_lockedThe single-flight lock on the target entity_key is held. See Errors.Yes, unchanged, after Retry-After.