Shipping reliable AI apps, part 8 of 8
Idempotency Keys: Why a Retried AI Call Charges Twice
A timed-out charge can still succeed, and the official AI SDKs retry by default. How idempotency keys make retries safe, built in TypeScript, plus where an AI agent's key should come from.
After this video
You can add idempotency keys to a side-effecting API or agent tool in TypeScript, so retried calls and repeated webhooks happen once.
The short version
A timeout doesn't tell you whether the work happened, and SDKs, agents and users retry anyway, so a charge can run twice. An idempotency key is one unique ID per operation, sent with every try: the server claims it first, does the work once and replays the saved result to repeats. Store a fingerprint so a reused key with a different body is refused, create an agent's key from the user's confirmed intent rather than the tool call ID, and dedupe incoming webhooks by event ID.
A timeout is not a failure
A call can fail before it reaches the server, midway, or after the work finished when only the reply was lost. From the caller's side these look the same, and the official AI SDKs retry certain failures twice by default.
The double upgrade. An agent's $20 charge tool times out, the agent tries again, and both charges go through: $40.
Idempotent actions
An action is idempotent when doing it twice has the same effect as once. In HTTP, GET, PUT and DELETE are idempotent by definition; POST is not.
Set vs add. Setting a balance to $50 ten times leaves $50; adding $50 ten times adds $500.
The idempotency key
The client sends one unique key (Stripe suggests a V4 UUID) with every try of an operation. The server does the work the first time, saves the response under the key, and replays it to retries.
Claim first, then work
Insert the key with a unique column and ON CONFLICT DO NOTHING before doing the work, so only one of two simultaneous retries wins. A retry that arrives while the first is still running gets a 409 Conflict. Give each claim a time limit so a crash between charging and saving doesn't block the key forever; the retry reuses the same key with the payment API, which returns the first charge.
Fingerprints
Store a hash of the request body with the key. Same key and fingerprint: replay. Same key, different body: refuse (the draft standard says 422).
$20 becomes $200. A buggy retry reuses the key with a new amount; replaying the old receipt would hide it.
Keys for agents and webhooks
A model's retry is a new tool call with a new ID, so the key must come from the user's confirmed intent, created in your code and read by the tool. Incoming webhooks can repeat; store each event ID in the same transaction as the work and skip any you've seen.
The code
async function chargeOnce(key: string, order: Order) {
const claimed = await db.query(
"INSERT INTO idem_keys (key, fingerprint) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING",
[key, hash(order)]);
if (claimed.rowCount === 0) return replay(key, order); // seen it: saved result, 409 or 422
const receipt = await payments.charge(order, { idempotencyKey: key });
await saveResult(key, receipt);
return receipt;
}Check yourself
Your charge call timed out. Why is retrying it without a key risky?
The first charge may have succeeded and only the reply was lost, so a plain retry can charge twice.
Why claim the key with an insert before charging, instead of checking whether it exists first?
Two retries can both pass a check at the same moment; a unique key column lets only one insert win.
Why shouldn't an agent's charge tool use the tool call ID as its idempotency key?
When the model calls the tool again, that's a new tool call with a new ID, so the key changes and the charge repeats. Create the key from the user's confirmed intent in your own code.
Sources
Every claim in this video was checked against these pages. Open one to see the claims it backs and the exact words they rest on.
docs.stripe.com/…/idempotent_requests
- Idempotency lets a client safely retry a request without the operation happening twice (Stripe).
The API supports idempotency for safely retrying requests without accidentally performing the same operation twice.
- The client generates a unique idempotency key and the server uses it to recognise retries of the same request.
A client generates an idempotency key, which is a unique key that the server uses to recognize subsequent retries of the same request.
- Stripe suggests V4 UUIDs or another random string with enough entropy to avoid collisions.
we suggest using V4 UUIDs, or another random string with enough entropy to avoid collisions.
- Stripe (API v1) saves the status code and body of the first request with a key; retries with that key get the saved response, including 500 errors.
Retries with the same key return the saved response, including 500 errors.
- Stripe compares a retry's parameters with the original request's and errors if they differ.
The idempotency layer compares incoming parameters to those of the original request and errors if they’re not the same to prevent accidental misuse.
- Stripe (API v1) can prune keys once they are at least 24 hours old; a key reused after that is treated as a new request.
We generate a new request if a key is reused after the original is pruned.
- Stripe doesn't save a result when the request conflicts with a concurrently executing request with the same key; such requests can be retried.
or the request conflicts with another request that’s executing concurrently, we don’t save the idempotent result
- Stripe API v1 accepts idempotency keys on all POST requests.
For API v1, all POST requests accept idempotency keys.
- Stripe advises against using sensitive data such as email addresses as idempotency keys.
Avoid using sensitive data (for example, email addresses or personal identifiers) as idempotency keys.
- Idempotency lets a client safely retry a request without the operation happening twice (Stripe).
rfc-editor.org/…/rfc9110
- HTTP definition: a method is idempotent if multiple identical requests have the same intended effect on the server as a single one.
if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.
- In HTTP, PUT, DELETE and the safe methods (GET, HEAD, OPTIONS, TRACE) are idempotent; POST is not defined as idempotent.
PUT , DELETE , and safe request methods are idempotent.
- HTTP definition: a method is idempotent if multiple identical requests have the same intended effect on the server as a single one.
stripe.com/…/idempotency
- A call can fail at connect, fail midway leaving work in limbo, or succeed with the connection breaking before the client hears back.
The call could succeed, but the connection break before the server can tell its client about it.
- A call can fail at connect, fail midway leaving work in limbo, or succeed with the connection breaking before the client hears back.
aws.amazon.com/…/making-retries-safe-with-idempotent-APIs
- After a timeout with no response the caller can't tell whether the work happened, and simply retrying can do it twice.
Simply retrying the request could result in multiple workloads
- After a timeout with no response the caller can't tell whether the work happened, and simply retrying can do it twice.
platform.claude.com/…/typescript
- The official Claude TypeScript SDK retries certain failed requests (connection errors, 408, 409, 429, 5xx) 2 times by default.
Certain errors are automatically retried 2 times by default, with a short exponential backoff.
- The official Claude TypeScript SDK retries certain failed requests (connection errors, 408, 409, 429, 5xx) 2 times by default.
platform.claude.com/…/handle-tool-calls
- Each tool_use block a model returns has its own unique id, used to match the tool result.
A unique identifier for this particular tool use block.
- After an error tool_result the model can adapt and call the tool again; a tool that times out and reports an error may be called again.
Claude will attempt to use the tool again with the missing information filled in
- Each tool_use block a model returns has its own unique id, used to match the tool result.
datatracker.ietf.org/…/draft-ietf-httpapi-idempotency-key-header
- There is an IETF draft standard for an Idempotency-Key HTTP header field.
The Idempotency-Key HTTP Header Field
- The draft says reusing a key with a different payload SHOULD get a 422 response.
If there is an attempt to reuse an idempotency key with a different request payload, the resource SHOULD reply with an HTTP 422 status code
- The draft says a retry that arrives before the original completes SHOULD get a resource conflict error (409).
The request was retried before the original request completed. The resource SHOULD respond with a resource conflict error.
- A fingerprint generated from the request payload can be stored with the key to tell whether a retry is the same request.
Such a fingerprint is generated from request payload data by the resource.
- There is an IETF draft standard for an Idempotency-Key HTTP header field.
postgresql.org/…/sql-insert
- Postgres INSERT ... ON CONFLICT DO NOTHING skips the insert when a row with the same unique key exists.
ON CONFLICT DO NOTHING simply avoids inserting a row as its alternative action.
- With a unique index, concurrent inserts of the same key can't both succeed; ON CONFLICT handles the losers even under high concurrency.
provided there is no independent error, one of those two outcomes is guaranteed, even under high concurrency.
- Postgres INSERT ... ON CONFLICT DO NOTHING skips the insert when a row with the same unique key exists.
docs.stripe.com/webhooks
- Stripe: webhook endpoints might receive the same event more than once; guard by logging processed event IDs.
Webhook endpoints might occasionally receive the same event more than once.
- Stripe: track event IDs to identify duplicate deliveries.
Track event IDs to identify duplicate deliveries instead.
- Stripe retries a webhook delivery when the endpoint replied with a non-2xx status; endpoints should return 2xx quickly.
If Stripe retries an event (for example, your endpoint previously replied with a non-
- Stripe: webhook endpoints might receive the same event more than once; guard by logging processed event IDs.
postgresql.org/…/tutorial-transactions
- A database transaction bundles steps into one all-or-nothing operation: if it fails partway, none of the steps take effect.
bundles multiple steps into a single, all-or-nothing operation.
- A database transaction bundles steps into one all-or-nothing operation: if it fails partway, none of the steps take effect.
raw.githubusercontent.com/…/README.md
- The official OpenAI Node SDK retries certain failed requests 2 times by default.
Certain errors will be automatically retried 2 times by default
- The official OpenAI Node SDK retries certain failed requests 2 times by default.
Spotted a mistake? Say so in the video's comments. Fixes are listed on corrections.