---
title: "Idempotency"
description: "Obol mints an idempotency key for every tool call, coordinates the claim atomically in Redis, owns every retry, and disables broker-side retries because no catalog broker offers idempotency."
---
Invariant 7 gives every tool call an idempotency key. Invariant 9 says whose it is: idempotency is always Obol's. Neither Nango nor Composio offers one, and their retry knobs generate duplicate writes, so broker-side retries are disabled on every call.
## The key
An Obol idempotency key is an `idt_`-prefixed id. You may supply one on the MCP call:
```http Supplying a key
POST /mcp HTTP/1.1
Authorization: Bearer ob_live_your_key
Idempotency-Key: idt_5c1f2a9b0e4d7c6a3b8f1d2e5a4c7b90
Content-Type: application/json
```
If you do not, the gateway mints one. Either way the **effective** key comes back on the response, both as an `Idempotency-Key` header and in the result metadata, so a client that did not supply one can still retry against the same logical call.
A malformed `Idempotency-Key` header is an error, not a silently ignored value.
### Approval binds the key
When a call is held for [approval](/policy/approvals), the approval ticket carries the idempotency key it was minted under. On resume, a supplied key that disagrees with the approved key is denied rather than reconciled. Approval resumption addresses the same logical slot; it does not open a second one.
## Scope
The claim is coordinated atomically in Redis, keyed by workspace, resolved connection, tool, and idempotency key:
```text Idempotency key layout
{prefix}idem:{workspace_id}:{connection_id|unresolved}:{hash(tool)}:{idempotency_key}
```
Two properties follow from that shape.
**A request-hash conflict never dispatches.** The claim record pins the canonical request hash, the tool's declared idempotency binding, and a dispatch fingerprint of the non-secret target facts. A second call with the same key but different arguments is refused; it does not become a second effect.
**A capability call is pinned independently of the provider it resolves to.** A routed `cap.*` invocation uses the literal `unresolved` connection segment, so retrying the same capability and idempotency key after a pin change or a republication addresses the same logical slot instead of dispatching another effect through a newly selected provider ([ADR-0044](/routing/capabilities)). The concrete connection, tool, and non-secret dispatch facts become immutable fingerprint data once the claim succeeds; an existing record with another destination conflicts rather than redispatching, and a matching replay preserves the original route record.
Native tool calls keep their connection-scoped identity — the connection is resolved before the claim, so there is nothing to defer.
The capability scope corrected a previously deployed key-scope mismatch and is not a mixed-version rolling cutover. Drain older gateways and retire their capability idempotency and approval records before admitting traffic under the new scope. Do not delete live records to speed the transition: that can redispatch an already executed effect.
## Propagating the key to the vendor
Whether Obol's key reaches the vendor is declared per tool in the [`EffectSpec`](/connectors/effectspec):
| `idempotency` binding | Behavior |
|---|---|
| `header` | The key is sent in the named request header, for example `Idempotency-Key` |
| `body_pointer` | The key is written at the named JSON Pointer in the request body |
| `unsupported` | The vendor has no idempotency mechanism Obol can drive |
Propagation is `none` on federated routes, because Obol does not construct the vendor request there. Gateway-side idempotency is unconditional regardless — the declaration is about the vendor hop, not about Obol's — and an honest `unsupported` is policy-deniable for production writes.
## Retries
The gateway owns every retry, and every attempt is durably reserved before it happens, so `attempt_count` on the receipt cannot disagree with the number of outbound calls ([ADR-0031](/gateway/overview)).
A worker executor performs exactly one outbound attempt. Gateway orchestration may reserve one immediate second attempt only when:
- the failure is a proven **before-send** retryable failure, or
- the result is a **responded**, internally consistent retryable upstream error **and** the tool declares a vendor idempotency binding.
A post-send transport failure finalizes as `unknown` with one attempt. It is never converted into an immediate retry merely because a connector reports that a retry would be safe. A later request under the same key may redispatch only through the ordinary idempotency lifecycle, with the same safety checks.
Dispatch status classification is declarative, not inferred: each tool's `EffectSpec` names the statuses that mean `accepted` and the ones that are `definitely_rejected`. Anything else is `unknown`, and unknown never becomes optimistic success.
## Why broker retries are disabled
A catalog broker is a vendor, never an authority. Retries executed by a broker bypass the gateway's compare-and-set claim, its attempt reservation, its receipts, and its policy ownership — and they can duplicate a write with no record on Obol's side that a second effect occurred.
Both brokers make the case themselves. Nango's documentation tells the caller to be idempotent while shipping retry knobs that generate duplicate writes. Composio disabled retries after duplicate sends. Neither exposes an idempotency mechanism Obol could propagate to.
So federated connectors declare idempotency `unsupported`, broker-side retries are turned off on every call, and the only retry that can happen is one the gateway reserved.
## Related
Where the effective key, request hash, and attempt count are recorded.
What a broker does and does not get to decide.