An idempotency key identifies one logical action across repeated API requests. When the receiving service honors it, a retry can recover the outcome without repeating the mutation. For teams building agents that book appointments, take payments, send messages, or update customer records, the hard part is keeping that identity stable through timeouts, concurrent requests, and restarts. Here is a practical design for keys, storage, response replay, and the external failures a local database cannot resolve alone.
What is an idempotency key?
An idempotency key is a unique identifier a caller attaches to a state-changing request so the receiving service can recognize later attempts at the same operation. The caller creates the key once and reuses it for retries. The server associates it with the request and its outcome under a defined retention policy.
Consider an appointment API. It creates the booking, but the response disappears before it reaches your application. A timeout tells you that no response arrived. It does not tell you whether the booking exists. Retrying with a new identity could create a second appointment.
With a supported idempotency contract, the retry carries the original key. The server recognizes the operation and returns its known outcome instead of creating another booking.
The operating rule: one key per logical action, the same key across attempts, and a separate recovery path when the outcome remains unknown. Sending a header alone provides no protection if the receiving service ignores it.
Idempotence concerns the effect, not identical responses
An operation is idempotent when repeating it has the same intended effect as performing it once. HTTP defines PUT, DELETE, and safe methods such as GET as idempotent. Responses can differ, and the server can still log every attempt. POST and PATCH do not inherently provide that retry guarantee. See the HTTP idempotence definition.
A PATCH request can be designed to be idempotent, as the PATCH method specification explains.
Setting a record's status to confirmed can be idempotent. Appending a new appointment or incrementing a balance usually needs an additional duplicate-control contract. The endpoint's behavior determines which protection is necessary.
Generate the key for the action, before the first attempt
Generate an opaque random identifier in application code. A version 4 universally unique identifier, or UUID, is a common choice:
import { randomUUID } from "node:crypto"; const idempotencyKey = randomUUID();
Persist that value with the approved action and its payload before sending the first write. Every retrying worker loads the existing record. Generating a fresh UUID inside a retry loop defeats the protection; keeping it only in memory loses it on restart.
The application also needs a durable way to recognize the action itself. Assign an action ID when your workflow records the confirmed intent. Repeated execution of that workflow must load the same action rather than create another action record with another key.
Do not use a hash of the arguments as the sole operation identity. Two intentional actions can have identical parameters. Amazon's caller-intent design explains this distinction with requests for two identical resources. Applied to agents, the same reasoning leads to a stable application-owned action ID:
| Situation | Identity decision |
|---|---|
| A booking times out and a worker retries | Reuse the booking action and key |
| A restarted agent resumes an unfinished booking | Load the existing action and key |
| A customer intentionally requests another identical message | Create a new action and key |
| A customer changes the appointment time | Record a new approved intent; resolve any earlier uncertain booking before dispatching |
A large language model's per-invocation tool-call ID is a different concept. Unless its contract explicitly preserves that ID across retries and replanning, it is unsuitable as the durable business identity. Likewise, one conversation ID is too broad when a call can contain several writes.
Keep customer names, email addresses, and other sensitive information out of keys. Treat the key as an identifier, never as permission to perform an action or retrieve its result.
Send the idempotency key with the saved request
For an API you control, an appointment request could look like this:
POST /appointments HTTP/1.1 Host: api.example.com Content-Type: application/json Idempotency-Key: 9bf20194-0136-4bed-a4b2-7ba5e383355c { "customer_id": "customer_42", "slot_id": "slot_731" }
Retries send the same saved key and the same approved payload. Authentication is required on every attempt.
Retry timing is separate from duplicate safety. Use bounded attempts with backoff for retryable failures; a key does not make immediate, unlimited retries appropriate.
For a third-party API, its contract controls the header or request-option name, key format, uniqueness scope, supported endpoints, and expiry. Some APIs use a request token in the body instead of this header.
Idempotency-Key is not a universal HTTP guarantee. The Internet Engineering Task Force's header proposal is an expired Internet-Draft, a work in progress rather than a published standard. Support comes from the receiving API's implementation.
Store and claim the operation on the receiving server
The receiving service needs shared, durable state. A process-local map cannot coordinate several workers or survive a restart. The following is an application design for an endpoint you own, independent of any agent platform.
1. Scope the key and bind it to a request fingerprint
Use a namespace such as (tenant_id, operation_type, idempotency_key). Derive the tenant from authenticated context. This scope prevents one tenant's key from selecting another tenant's result and separates operations such as booking an appointment and sending a message.
A request fingerprint records what the key originally meant. It can be a hash of a canonical representation of the operation's effect-bearing fields. For a booking, those might include customer, slot, service, and schema version.
Define normalization explicitly. JSON property order should not turn an equivalent request into a different booking intent. Conversely, normalization must preserve meaningful differences such as a changed slot. Keep the original approved payload so retries need no model-generated reconstruction.
Compare the fingerprint before returning any existing result. A matching key with a changed payload is a misuse error, not permission to overwrite the first operation. The expired header draft's examples use 422 for payload mismatch and 409 for an operation still running. Those are design examples, not mandatory behavior across providers.
2. Make the initial claim atomic
An atomic claim makes choosing the initial executor one indivisible database decision. This minimal PostgreSQL record separates execution state from the stored response:
CREATE TABLE idempotency_records ( tenant_id text NOT NULL, operation_type text NOT NULL, idempotency_key text NOT NULL, fingerprint text NOT NULL, request_body jsonb NOT NULL, provider_key text, state text NOT NULL CHECK (state IN ('running', 'completed', 'unknown')), response_status integer, response_body jsonb, provider_reference text, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, operation_type, idempotency_key) ); INSERT INTO idempotency_records ( tenant_id, operation_type, idempotency_key, fingerprint, request_body, provider_key, state ) VALUES ($1, $2, $3, $4, $5, $6, 'running') ON CONFLICT (tenant_id, operation_type, idempotency_key) DO NOTHING RETURNING *;
The primary key enforces uniqueness; ON CONFLICT handles a competing insert. PostgreSQL's conflict-handling semantics provide the database primitive. The application still defines duplicate behavior. If a downstream API supports keys, save its provider_key with this claim before dispatch.
If the insert returns a row, this request acquired the initial claim. The conflict-then-read pattern assumes PostgreSQL's default Read Committed isolation, where each statement gets a fresh snapshot. DO NOTHING can skip an insert because of a concurrent commit that was invisible to the insert's snapshot. If it returns no row, read the retained record in a separate subsequent statement, compare its fingerprint, and follow its state. A separate “look up, then insert” sequence without a uniqueness constraint permits concurrent requests to both start work.
Under Repeatable Read or Serializable, a later statement does not refresh the transaction snapshot. Handle serialization failures (40001) by rolling back and retrying the whole transaction, including the logic that chooses statements and values, with a new snapshot. Keep independent provider writes outside that database retry loop; use the persisted downstream identity and external-outcome recovery below.
If the mutation lives entirely in this PostgreSQL database, put the claim, business write, and completed response in the same transaction. A rollback then undoes the whole unit. A remote API call needs the different recovery handling described below.
3. Distinguish running, completed, and unknown
An unknown outcome needs reconciliation, recovering what happened from the system that owns the write.
| Stored state | Response to a matching duplicate |
|---|---|
running | Return an explicit in-progress result or wait within a bounded policy. Do not execute another write. |
completed | Return the saved outcome according to the endpoint's replay contract. |
unknown | Expose pending reconciliation. Do not label the action successful or failed without evidence. |
completed means the outcome is known, including a definitive rejection. Store the response status, safe response body, and relevant resource or provider reference. Persist completion before reporting success to the caller.
Response replay needs its own contract. For example, Stripe's idempotency contract for API v1 saves the initial status and body after endpoint execution begins, including 500 responses. Validation failures and conflicts with an already executing request do not save a result. Stripe API v2 has different retry behavior, including updated responses while recovering failed or partially failed work. Exact first-response replay is therefore not a universal property of idempotency.
Do not automatically classify every server error as a known failed mutation. The service may have changed state before returning the error.
Handle the external-write crash window
A local uniqueness constraint does not protect a separate calendar, payment, messaging, or customer-system API. This sequence remains possible:
- Your backend commits a
runningrecord. - The provider accepts the write.
- Your worker crashes before saving the provider's response.
- Another worker sees an unfinished record.
Releasing a lock or declaring the worker stale does not reveal whether step 2 happened. Automatically issuing a new write at step 4 can duplicate the external effect. PostgreSQL transaction guarantees cover database changes in the transaction, not an independent provider's HTTP request.
There are two recovery paths:
- The provider supports idempotency. Persist its key before dispatch, reuse it for that downstream operation, and recover through the provider's supported retry or lookup contract within its retention window. A replacement worker must not invent a new provider key.
- The provider lacks that contract. Reconcile using an authoritative operation lookup or a unique external business reference before deciding whether another write is safe. An empty search result may be inconclusive if the provider's data is delayed. Keep unresolved work pending and route it to an operator when necessary.
A worker can crash before recording unknown, leaving running behind. Recovery must therefore treat abandoned running records as potentially ambiguous too. A time-limited worker lease can coordinate which worker may act; its expiry is not evidence that the provider did nothing.
For a workflow that books an appointment and sends confirmation, store separate step outcomes and downstream identities. If the message fails, recovery resumes the message step without rebooking the appointment. A single key cannot make several independent APIs commit as one transaction.
Set expiration around the full retry horizon
An idempotency record's time to live, or TTL, defines how long duplicate recognition is retained. It is part of the safety contract.
For Stripe API v1, keys may be pruned once they are at least 24 hours old. Reusing a key after pruning creates a new request. That interval is provider-specific; there is no universal 24-hour rule.
For an endpoint you own, account for queue delays, application retries, process restarts, workflow resumes, and operational recovery. A five-minute HTTP retry budget does not cover a job replayed the next morning.
Separate large response-body retention from durable action identity. Your application can retain a compact action record and resource reference longer than a replayable response. Deleting both makes an old retry indistinguishable from new work. Never delete unresolved records merely because their normal replay window elapsed, and never assume a retained local key extends a provider's expired protection.
Apply the boundary to voice-agent writes
In Dasha, our tools can send requests to a webhook your team controls. The backend is the right place to bind an approved business action to its durable identity, enforce duplicate handling, and manage the downstream provider outcome. Our API integration guide explains the surrounding tool and webhook setup.
The storage and recovery design here is application-owned. It does not imply that Dasha generates your keys, maintains your deduplication store, or guarantees exactly-once effects in an external system.
For a voice-agent integration, apply the same identity rule to each write:
- Appointments: Reuse the confirmed booking action after a timeout. A deliberate reschedule is a separate business action.
- Payments: Tie retries to the approved payment intent. An uncertain charge remains pending until the payment system resolves it.
- Messages: Give each intended send its own identity. Identical message text alone cannot distinguish a retry from an intentional second send.
- Customer records: Protect append operations and increments as well as record creation. Replaying the same update can trigger additional workflows even if the final field value looks unchanged.
Return structured outcomes that preserve pending, confirmed, and rejected as distinct states. A fallback response can keep a conversation moving, but it must not turn an unknown booking or payment into a spoken confirmation.
Failure coverage for the integration
A regression suite for a side-effecting tool needs assertions at both the application record and the receiving system:
| Failure scenario | Required assertion |
|---|---|
| The response is lost after the provider commits | Recovery returns the original resource without a second effect |
| Two matching requests arrive together | One initial claimant; the duplicate gets an explicit state |
| The key is reused with a changed payload | The mismatch is rejected before replay or execution |
| A worker restarts during dispatch | Recovery reloads the saved action and downstream key |
| A retry arrives after provider expiry | Reconciliation prevents a blind new write |
| A user intentionally repeats an identical action | The new intent remains possible with its own key |
Logs should correlate the business action ID, idempotency key, individual attempt ID, and provider reference without exposing unnecessary payload data. These identities answer different questions: what the user intended, which attempts belong together, and what the external system actually created.
For teams building production voice AI agents, we provide the managed conversational runtime and tool integration surface. Your backend keeps ownership of business authorization and external-write recovery. Build a Dasha integration around one narrowly defined action and its duplicate-safe backend contract.



