I want an AI assistant to do more than explain a bill. It should be able to collect a payment after the customer confirms. But what happens if the payment succeeds, the response times out, and the assistant tries again? Could a repeated tool call charge the customer twice?
This is a different problem from getting a model to produce the right answer. Once an assistant can change something outside the conversation, a repeated request has consequences. Payments make the risk obvious, but the same issue appears with refunds, order creation, email delivery, and cloud resource provisioning.
The application—not the prompt—must enforce whether an action may execute again. Treat a model’s tool call as a proposed operation. Your backend decides whether it is authorized, whether it has already happened, and whether retrying it is safe.
Why “never call this tool twice” is not enough
A prompt can tell an assistant to avoid duplicate actions. That is useful guidance, but it cannot guarantee execution behavior across network failures, worker restarts, concurrent requests, or a switch between model providers.
Consider a payment workflow:
- The customer approves paying an outstanding invoice.
- The model requests the payment tool.
- Your backend sends the charge to the payment processor.
- The processor accepts it, but your backend loses the response.
- The assistant receives an error or no tool result and requests another attempt.
The assistant cannot infer whether the money moved from a missing response. Neither can a backend that only recorded “request timed out.” A timeout describes what your application observed, not necessarily what the payment processor did.
This remains true whether the model is accessed through OpenAI, Claude, Grok, DeepSeek, or another API that supports the required tool workflow. Tool availability and message formats vary by product, endpoint, and model, but the execution boundary belongs in your application.
Give the business action an identity before execution
The central mechanism is business-level idempotency keys: stable identifiers for intended operations, rather than identifiers for individual HTTP attempts.
Suppose a customer approves payment of invoice INV-2048. Your application could create an internal operation such as payment-op-731, associated with that invoice, customer, payment method reference, amount, and currency. That operation stays the same if the assistant repeats its tool request or your worker retries the payment.
A second legitimate purchase needs a different operation. A retry of the existing purchase does not.
Do not simply use the model provider’s tool-call ID as the payment’s identity. It is useful for matching a tool result to a particular model request, but a regenerated response may contain a different call ID for the same business action. Likewise, two providers may independently propose paying the same invoice.
The backend should establish the operation identity from a trusted workflow: for example, an approved checkout session or a payment intent created by your application. The model may reference that identity, but it must not be able to invent arbitrary identifiers and thereby bypass duplicate protection.
There is a subtle distinction here: identical arguments do not always mean an identical intention. Two $25 purchases might both be legitimate. Hashing a tool name and its arguments is therefore not a reliable substitute for an explicit business operation.
Keep an execution record that survives the conversation
A chat transcript is not an execution ledger. It may be truncated, summarized, replayed, or unavailable to a different worker.
Use a durable record for each consequential operation. Useful fields include:
- Operation ID: the application-controlled identity of the intended action.
- Scope: the customer or tenant that owns the operation.
- Approved details: the exact amount, currency, destination, and relevant resource identifiers.
- Status: whether the operation is awaiting approval, ready, executing, succeeded, definitively failed, or has an unknown outcome.
- External reference: the processor’s payment identifier, when available.
- Stored result: the authoritative outcome that can be returned on later requests.
Enforce uniqueness at the database layer for the relevant operation scope. A “check whether this exists, then insert” sequence is vulnerable if two workers perform the check simultaneously.
Use an atomic insert or guarded state transition so only one worker initially claims an operation. This is a foundation for duplicate tool call prevention, but it does not complete the design: the database and the external payment service do not usually share one transaction.
The difficult case: the worker dies after the charge
Imagine the processor accepts a payment, then the worker crashes before saving the success locally. Your database now says “executing,” even though the payment succeeded.
A database lock cannot undo or detect that external success. A lease expiring also does not prove that the original worker failed before submitting the charge. Starting another worker without downstream protection can still create a duplicate.
For this reason, forward a stable idempotency key to the payment processor when its API supports one. Repeated submissions for the same approved operation should use the same key and the same relevant payment parameters.
Check the processor’s actual contract. Key retention periods, account scope, parameter-mismatch behavior, and handling of simultaneous requests can differ. Do not assume a key remains effective indefinitely or that every endpoint honors it.
Idempotency is not a universal promise of exactly-once execution. It is a mechanism whose guarantees depend on the systems participating in the operation.
Treat an unknown outcome differently from a failure
A useful workflow distinguishes three situations that are often collapsed into “the tool failed”:
- Rejected before submission: validation or authorization failed, and no external payment request was made.
- Definitively unsuccessful: the processor reports a terminal unsuccessful outcome under its documented semantics.
- Unknown: the request may have reached the processor, but your application lacks a reliable result.
The third case calls for payment status reconciliation, not a fresh operation with a new key.
Depending on the processor, reconciliation may use an existing payment reference, a supported lookup mechanism, a retry with the original idempotency key, or a verified webhook. Webhook handling also needs deduplication because delivery may be repeated.
Until the outcome is known, the assistant should say something like: “The payment status is still being checked. I won’t submit a separate payment while that check is in progress.” It should not claim the payment failed merely because the tool timed out.
If the external service offers neither idempotent submission nor a reliable way to identify the prior operation, your application may be unable to guarantee both automatic progress and freedom from duplicates after an ambiguous failure. For high-impact actions, pause for investigation rather than pretending another attempt is harmless.
Bind approval to the exact action, not just to the conversation
Duplicate protection does not establish permission. A perfectly deduplicated unauthorized charge is still an unauthorized charge.
Human approval for payments should be recorded against the exact operation details. An approval to pay $49 must not authorize $490, a different invoice, or a different recipient. If consequential details change, invalidate the approval and request confirmation again.
The backend should also perform server-side tool authorization on every execution request. Resolve the user and tenant from authenticated application context, verify access to the invoice, and retrieve authoritative values from trusted records. Do not accept a customer ID or amount as trustworthy just because the model supplied it in valid JSON.
Keep the tool narrow. A tool that pays a specific approved invoice gives the model less dangerous freedom than a general-purpose tool accepting arbitrary recipients and amounts.
Where approval is required, prefer a trusted confirmation interface over treating any conversational “yes” as sufficient. The interface can show the amount and target, then record exactly what the customer authorized.
What should a repeated tool request return?
A repeated request should produce a result based on the existing operation, rather than automatically trigger another external call.
- Already succeeded: return the stored payment result and reference.
- Still executing: return a pending status associated with the same operation.
- Outcome unknown: return a reconciliation status without inviting a separate charge.
- Details conflict: reject the request rather than reusing an operation ID for a changed payment.
- Approval missing or expired: require the appropriate confirmation before execution.
This makes tool result replay useful rather than dangerous. The assistant can recover a lost result and continue explaining what happened without repeating the side effect.
It also simplifies changing model providers. A replacement model may generate a new tool-call ID, but your execution service still recognizes the same approved business operation.
Test the failure window, not just the successful checkout
A normal integration test proves that a payment can happen. It does not prove that it cannot happen twice.
For failure injection testing, include concurrent requests for one operation, a crash immediately after processor acceptance, a lost tool result, duplicate webhooks, and a retry after the processor’s idempotency retention period. Also test an attempted amount change under an existing operation ID.
Check both sides: the external processor should contain no unintended duplicate payment, and your application should eventually show the correct outcome. “The user saw one success message” is not sufficient evidence.
The practical boundary is straightforward: let the model propose the action and explain the result. Let trusted application code own approval, operation identity, execution state, and recovery. That way, asking twice does not automatically mean paying twice.