Idempotency and conflicts
The operation key is what makes a lost response, a repeated callback or a concurrent retry converge on one action and one side effect. Get it right before you write any retry logic.
Developer guide reference text
On submit, send a stable operation key and the material fields the application will commit. A replay with the same key and same request returns the existing action. Reusing it for a changed amount, beneficiary, tenant or other material parameter returns a conflict without altering the record. Duplicate notifications cause a status read. A target timeout after dispatch leads to reconciliation on the original operation key.
The rest of this page is how the code implements each sentence, and the one place it differs.
What the key identifies
An operation key is unique per tenant and service: Axiom looks up an existing action by (tenant, service, operation_key). The tenant is always your authenticated agent's tenant, so two customers can use the same key string without colliding.
The request it is compared against is the canonical action (axiom.action.v1): tenant, agent, audience (service), operation, resource, the normalised material params, not_after and, for a delegated child, the root action. Its SHA-256 over the RFC 8785 canonical JSON is the request_digest, the same digest an approver's decision is bound to.
Two consequences of normalisation:
"25000","25000.0"and25000produce the same digest for adecimalfield;"usd"and"USD"the same for acurrencyfield.- If you omit
not_after, a replay reuses the original action'snot_after, so the digest stays stable across retries. If you send an explicitnot_after, send the same value every time.
Non-material inputs do not affect the digest: intent, payload and execute.
Replays
Re-sending the same body with the same key returns the existing action. Nothing is evaluated, approved or dispatched again.
| Existing action is… | Replay returns |
|---|---|
pending_approval or approved | 202 with the current status |
| any other state | 200 with the current status |
Differs from the published contract
The OpenAPI contract describes 200 as "replay of an existing action". In the code a replay of an action that is still pending or approved returns 202, like the original submit. Branch on state, not on the HTTP status.
A replay does not auto-execute an approved action: only the first submit of an action allowed within the standing grant dispatches at once. An approved action from an earlier submit needs an explicit resume.
Two submits racing with the same key converge too: the database's unique key lets one insert win, and the loser replays against the winner.
Conflicts
Reusing a key for a materially different request is refused, and the existing record is not touched:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://docs.sectigoaxiom.com/reference/errors#idempotency_conflict",
"title": "idempotency conflict",
"status": 409,
"code": "idempotency_conflict",
"detail": "operation key reused with different material parameters; nothing was changed",
"changed_fields": ["params.amount"],
"action_id": "act_01kd0m7s2v8x9yz3abcdefghjk",
"reason_code": "material_change"
}changed_fieldsnames every differing field (tenant,agent,audience,operation,not_after,root_action,resource,params.<name>).action_idis the action the key already belongs to. It is yours; read it.- A key already used by another agent in the same tenant and service is also
409 idempotency_conflict("operation key already used by another actor"), withoutchanged_fields. - In the rare race where two different requests with the same key are inserted at the same time, the loser gets
409 idempotency_conflictwithaction_idbut withoutchanged_fields.
Do not "fix" a conflict by editing the request until it matches. A changed amount, beneficiary or tenant is a new intent: it needs a new key and, usually, a fresh approval.
Resume has its own binding check: POST …/resume with parameters or a key that do not reproduce the approved digest is 409 conflict (not idempotency_conflict) with changed_fields and reason_code: material_change.
Choosing keys
Choose the key once per intent, before the first attempt, and store it with the intent. Reuse it for every retry of that intent, never for anything else.
import { newOperationKey, deriveOperationKey } from "@sectigo/axiom-sdk";
// A fresh random key for a new intent: "refund-417-6f3c…"
const key = newOperationKey("refund-417");
// A deterministic key from the intent's identifying fields, for runtimes that cannot store a
// random key before submitting. Same parts, same key; any difference, a different key.
const derived = await deriveOperationKey("refund", { ticket: "CS-88121", invoice: "417" });Keys are 8 to 200 characters. The SDK's submit retries a transport failure (no response) up to twice by default, always with the same key, which is safe for exactly the reasons above.
Keys on the other paths
| Path | Where the key comes from |
|---|---|
| REST API, Axiom MCP server | operation_key in the body or tool arguments. |
| Hosted HTTP proxy | The Idempotency-Key request header. Required for writes; reads without one get a random gw_read_… key. |
| Hosted MCP proxy | _meta["com.sectigoaxiom/operation_key"] on tools/call if present (8–200 chars). Otherwise a write gets a key derived from agent, service, tool name and the exact arguments (mcp_…), and a read a random mcp_read_… key. |
| Embedded SDK | The provider passes the agent's operation key in the POST /v1/authorize action. |
The derived MCP key means a retried tool call after a lost response converges on the same action. It also means two deliberate, identical tool calls (same arguments) are treated as one; pass an explicit key in _meta if you intend two separate operations. A tool call with a changed amount gets a different derived key, so it becomes a new action rather than a conflict.
On the provider side
Axiom forwards the key to the upstream as Idempotency-Key, and inside the signed Axiom-Authorization as operation_key. The provider must persist it atomically with the business write, or use a target with equivalent idempotency and result lookup. A shared key alone cannot guarantee one side effect across independent systems. The SDK's executeOnce does this, and its operation records are keyed by (tenant, operation_key).
Duplicate notifications
Wake events (webhooks or a relay) are deduplicated by (action_id, seq). See webhooks and relay.
As the brief requires, every notification causes a status read: the SDK's handleWake reads authoritative status for fresh, duplicate and stale deliveries alike, and the sequence store only tells you whether you have seen the event before. Because the read returns the current record, a duplicate or out-of-order delivery converges on the same state.
Timeouts after dispatch
If the provider does not answer after dispatch (network error, timeout, a 5xx, 408 or 429, or an outcome attestation that fails verification), the action becomes outcome_unknown with reason_code: target_timeout. Axiom then asks the provider for the outcome of the original operation key at GET /.well-known/axiom/operations/{operation_key}, and restores succeeded or failed only from a valid signed attestation. Lookups back off exponentially (from about a minute, capped at an hour) for up to 12 attempts; an action still unknown after that stays outcome_unknown for an operator to resolve. The agent never resubmits or resumes again; it tracks the same action ID. See execution and delivery.