Development

Idempotency Keys for AI Actions: Design and Verification

Antonio Evans9 min read

A close-up of a server-side idempotency ledger on a monitor: a table with columns for key, scope, status (in-flight, completed), and response hash.
Key points

Key points

  1. 1An idempotency key is a client-supplied identifier that lets a server return the original result of a retried write instead of executing it again.
  2. 2For AI-generated actions, create one stable key per logical action before the first call and reuse that exact key on every retry.
  3. 3Store the key in a ledger table with a uniqueness constraint, an in-flight state, the completed response, and a TTL longer than your retry window.
  4. 4Prove the design with tests that deliver the same request twice, race two copies against each other, and crash between execution and response, plus a metric that counts duplicate executions.

What an AI action needs that a normal API call does not

An agent that plans and then acts has a very specific failure profile. The model decides to issue a write, a tool executes it, and somewhere between the tool call and the reply that says "done" the process can stall, time out, or be retried by an orchestrator that has no idea the write already landed. Webhook retries show the same shape: a soft timeout where work takes 5.1 seconds against a 5.0 second limit, a lost success response, or two near-simultaneous retries that reach different workers will each deliver the same event twice (Prismatic). Agents add two more wrinkles: they can loop and re-emit the same intended action several times within one conversation, and they often retry with slightly different wording, so a naive content hash changes between attempts.

That is why an idempotency key must be a decision made once, outside the model's control, rather than something the model recomputes. HTTP already encodes the difference between safe and unsafe calls. PUT denotes an idempotent action, while POST implies side effects: call it twice and you get two entries even with identical properties (Hookdeck). Most AI-initiated actions are POSTs with real-world consequences, so the server has to supply the idempotency contract that POST lacks.

Designing the key

The rule that survives contact with production: one key per logical action, derived from request content plus a scope, generated before the first call, and never regenerated on retry. The scope prevents collisions across tenants, so a key looks like tenant-42:refund:order-8891:v1. The instruction that follows the key must be included in the derivation, but the derivation itself must be stable: compute it once, persist it, and pass the stored value on every attempt.

The key works because the receiver treats it as a promise. Stripe saves the first response against the key and returns it verbatim on retries, including 500 errors, with keys pruned after 24 hours (Stripe). A draft IETF header field aims to standardize exactly this, making non-idempotent methods such as POST and PATCH fault-tolerant (devlume). You do not need the standard to adopt the pattern, but the semantics are worth copying: the first attempt defines the outcome, and every later attempt is a replay.

Watch for the failure mode that bites hardest. If you hash the request body to derive the key, you must hash a normalized form: sorted keys, trimmed whitespace, no timestamps, no model-generated prose. The moment the agent rephrases its intent, the hash changes and the deduplication silently stops working. Prefer a system-assigned action ID with the normalized request as a secondary scope, and treat a key mismatch on the same action ID as an error to alert on, not a new write.

A simple decision table helps when you are unsure whether a write needs a key at all:

OperationIdempotent by natureNeeds a key
Set order status to paidYes, absolute assignmentNo, but harmless
Increment balance by 100No, additiveYes
Create refund recordNo, creates a resourceYes
Update profile first nameYes, replaces valueNo
Send customer notificationNo, external side effectYes
Append audit log entryNo, appendsYes, with dedupe

Absolute state changes such as SET status = 'paid' are retry-safe, while balance += amount is not, which means the additive and creating operations are the ones that need the ledger (Prismatic).

Storage: the ledger table

The ledger is a table with the key as a primary key or a unique constraint, an action state, the stored response, and an expiry. The unique constraint is what does the real work: it lets the datastore arbitrate, so two requests that both pass a "have I seen this?" check cannot both proceed. Claiming the event with a unique-key insert such as INSERT ... ON CONFLICT DO NOTHING is what closes that check-then-act race, because a check-then-process deduplication has a window where two copies both pass the check before either records it (Prismatic).

A minimal schema looks like this:

CREATE TABLE action_ledger (
  idempotency_key text PRIMARY KEY,
  action_id       text NOT NULL,
  tenant_id       text NOT NULL,
  state           text NOT NULL CHECK (state IN ('in_flight','completed','failed')),
  request_hash    text NOT NULL,
  response_body   jsonb,
  response_status int,
  claimed_at      timestamptz NOT NULL DEFAULT now(),
  lease_until     timestamptz NOT NULL,
  expires_at      timestamptz NOT NULL
);

CREATE UNIQUE INDEX action_ledger_action ON action_ledger (tenant_id, action_id);

Claiming the action is one statement. If it returns a row, you own the work; if it returns nothing, someone else is already running it or already finished:

INSERT INTO action_ledger
  (idempotency_key, action_id, tenant_id, state, request_hash, lease_until, expires_at)
VALUES ($1, $2, $3, 'in_flight', $4, now() + interval '60 seconds', now() + interval '7 days')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;

The states matter more than people expect. Set to in_flight on claim, completed once the side effect commits and the response is stored, and failed only for outcomes you are prepared to make final. Cache carefully: replaying a 500 error from debug time is a real bug, and the fix is to treat 5xx as retryable by releasing the claim rather than storing the error as truth (Mathildaduku). The lease exists so a slow worker and a recovery worker cannot both act: reclaim stuck in-flight rows only after the lease expires, and use a fencing token so the original worker's late write is rejected.

Sizing the TTL is a decision rule, not a guess. Keep the deduplication window longer than the longest retry window you can expect, because a 1-hour TTL against a 72-hour retry period protects only the first hour (Prismatic). Published retry schedules give you concrete anchors. Svix retries eight times over roughly 24 hours, with delays of immediate, 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours (Svix). Stripe documents retries for up to three days. For agent actions, add the time a human might spend reviewing a stalled task before resuming it, then round up.

Two operational notes. First, an in-flight row that never completes is worse than a duplicate, because it blocks the action forever: run a sweeper that marks stale leases as failed and emits an alert. Second, put the ledger in the same transactional store as the business write wherever possible. If the side effect is a database row, commit the ledger state and the business row together. If the side effect is external, for example a payment call, you need the ledger plus a reconciliation job that asks the external system what actually happened.

Verification: tests that catch double execution

Three tests catch almost every real duplicate, and each one targets a different failure mode.

Test 1: duplicate delivery. Send the same key and body twice sequentially and assert the business side effect count is one and the second response is byte-identical to the first, including status code. This catches the common bug of storing the response but not replaying it faithfully.

Test 2: the race. Fire N concurrent copies of the same key through a single barrier so they all start together, then assert exactly one execution. The OWASP race condition guidance is useful here: Burp Repeater's parallel send group and Turbo Intruder can fire 100 simultaneous requests through one gate to maximize the chance of hitting the race window, and tools such as race-the-web do the same from a CLI (OWASP). In an automated suite, opening a barrier with a hundred goroutines or async tasks against a real database, not a mock, is the honest version of this test. Mocks hide the unique constraint, and the unique constraint is the thing under test.

Test 3: crash between execution and response. Kill the process after the side effect commits but before the response is stored, then retry with the same key. The correct outcome is that the second attempt sees in_flight (or a failed lease) and either waits or reconciles, never executes again. Injecting this failure is easiest with a test hook right after the commit and a process kill, or with a proxy that drops the response.

The multi-agent variant deserves its own test, because parallel agents are where the collisions concentrate. In systems with several agents running concurrently, race conditions are expected guests rather than edge cases, and idempotency is the pattern that keeps retries from producing duplicate writes (Machine Learning Mastery). Run two planner instances against the same action queue and assert the ledger absorbs the second.

Finally, instrument for double execution rather than trusting tests alone. Four metrics do most of the work:

MetricWhat it provesAlert condition
Replayed requests per keyDeduplication is being exercisedSudden drop to zero suggests keys regenerate
Key mismatch on same action IDRetry changed the keyAny occurrence is a bug
Side effect count per action IDExactly once executionAny value above 1 at read time
Stale in-flight rows older than leaseRecovery path workingSustained growth blocks actions

A fifth is worth adding for agents specifically: the count of distinct keys per conversation turn. If that number climbs, your key derivation is drifting and the first metric is lying to you.

Adapting the pattern when the receiver has no key support

Not every system you call accepts an idempotency key. When it does not, you have two options. The first is to wrap the call in your own proxy that owns the ledger and forwards a synthetic header, accepting that the downstream system may still double-act if the proxy crashes mid-request. The second is to make the downstream operation naturally idempotent: use absolute state writes where the API allows them, and use a unique business identifier, such as an order ID, in the payload so the downstream system rejects duplicates itself. The webhook world solved this with a receipts table keyed on the provider's event ID, storing the ID durably and using its unique constraint before any work happens (Hookdeck). The same move works for outbound calls when you control the receiving side but not the protocol.

Where the ecosystem has standardized, lean on it. Standard Webhooks defines conventions for signing, retries, and a webhook-id header that receivers deduplicate on, and it ships reference libraries plus a receiving-webhooks skill for AI coding agents (Standard Webhooks). Gateways such as Kong ship a plugin that validates incoming webhooks against that specification, which removes a class of signature and replay bugs before your code sees the request (Kong). If your agents call into a system with a documented retry schedule, mirror it: jittered exponential backoff cuts retry volume substantially, and a dead-letter queue catches what retries cannot fix, preserving failed events with their payload and error context for replay (Hookdeck).

For the AI-specific case, keep the ledger outside the model entirely. The agent proposes an action; a deterministic executor assigns the key, claims the ledger row, runs the tool, and stores the result. When the model retries, it retries the executor with the stored key, not with a fresh idea. That separation is also what makes the design testable: you can unit-test the executor with a fake model, and you can integration-test the ledger without a model at all.

If you are building agent tooling rather than a single app, the same pattern belongs in the toolkit your coding agent reaches for. Quanta Skills is a library of installable skills for coding agents, and a skill that scaffolds the ledger, the claim statement, and the three tests is a reasonable first install (https://quantaintelligence.ai/skills). For teams running actions across several model providers, routing tool calls through one API keeps the executor logic identical regardless of which model planned the action (https://quantaintelligence.ai/inference).

The decision rule

Ask three questions about any agent-initiated write. Does it create a resource, change a value additively, or trigger an external side effect? If yes to any, it needs a key. Is a human likely to retry the same intent after a delay measured in hours or days? Then the TTL must exceed that delay plus the longest retry window you documented. Can the receiver be made to reject duplicates on a business identifier instead? Then prefer that, because it removes your ordering dependency on the key.

Everything else follows from those answers: the key design is stable by construction, the ledger enforces uniqueness in the datastore, and the tests prove the three failure modes are covered before an agent ever runs in production.

FAQ

FAQ

What is the difference between an idempotency key and a request hash?

A request hash is derived from the content and changes the moment any byte changes, including a rephrased instruction or a timestamp. An idempotency key is a stable identifier assigned once per logical action, stored before the first call, and reused verbatim on retries. Hash the normalized request only as a secondary check so a genuine key collision or a changed payload is flagged instead of silently executed twice.

Where should the key be generated: in the model or in the executor?

In the executor, always. A model can re-emit the same intent with different wording, and any key derived from its output will change between attempts. The executor assigns the key, persists it, and passes the same value on every retry, which keeps the server-side uniqueness constraint meaningful. The model's job is to propose the action, not to guarantee its own retry safety.

How long should ledger rows live?

Longer than the longest retry window you can expect, plus whatever delay a human or orchestrator might introduce before resuming a stalled task. Published schedules give you anchors: Svix retries across roughly 24 hours, and Stripe documents retries for up to three days. Add review time on top, and treat yet another hour saved by a short TTL as a false economy.

What should happen when a retry arrives while the first attempt is still running?

Return a conflict or accepted response that tells the caller to wait, and let the lease on the in-flight row decide when recovery is allowed. Do not execute the work again, and do not mark the row failed while the original worker might still be writing. A fencing token prevents the slow original worker from committing after a recovery worker has taken over.

How do I test for double execution without a full production environment?

Run three tests against a real database: a sequential duplicate asserting one side effect and an identical replayed response, a barrier test firing many concurrent copies of one key, and a crash test that kills the process after the business commit but before the response is stored. Mock the model, never the unique constraint, and add a metric that counts side effects per action ID so production can confirm what the tests promise.

Sources

Sources

  1. How Do I Make Webhook Processing Idempotent - Prismatic.io
  2. How to Implement Webhook Idempotency - Hookdeck
  3. Idempotent requests | Stripe API Reference
  4. Webhook reliability: retries, idempotency, and the design ...
  5. How to Design Idempotent APIs Safely: What to Cache and ...
  6. Webhook Retry Best Practices | Svix Resources
  7. Race Conditions | OWASP Foundation
  8. Handling Race Conditions in Multi-Agent Orchestration
  9. Standard Webhooks
  10. Standard Webhooks - Plugin - Kong Docs
  11. Dead-Letter Queues for Webhook Reliability
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles