Development
Key points

Key points

  1. 1Enforce a per-run spending cap in deterministic code before the agent makes any billing call, and refuse or escalate anything above the threshold.
  2. 2Require human approval for refunds, credits or charges above a set amount or outside a payee allowlist.
  3. 3Make idempotency the agent's responsibility: write a unique key to durable storage before the call, reuse it on every retry, and record the result against it.
  4. 4Treat a timeout as an unknown outcome, not a failure, because that is the case that produces double charges.

Why retries, not malice, cause double charges

The failure that puts two charges on a customer's card is mundane. A client sends a request, the provider completes the work, and the response is lost on the way back. The caller sees a timeout and cannot tell whether the request never arrived or succeeded and merely lost its reply. That uncertainty is the core problem, and a timeout does not mean nothing happened (Ali Aftab K. on idempotency design).

Stripe frames it the same way: a call between two nodes can fail at connection, mid-operation, or after success but before the client hears back, and in the ambiguous cases the client does not know whether retrying is safe (Stripe engineering on idempotency). Distributed networks fail at some ambient background rate, so the design assumption is that the same request will arrive more than once (Stripe engineering on idempotency).

For a human clicking a Pay button, this is annoying. For an autonomous agent that may retry a tool call several times in a loop, autonomously, at machine speed, it is a money leak. The agent does not get tired or notice the second charge. Nobody notices until the reconciliation report runs.

A second, subtler path to duplicate side effects is concurrency: two workers pick up the same job, or a message consumer processes the same event twice (Balmik Prajapati on idempotency keys in payment APIs). That is why the controls below stack. Caps limit total exposure, gates catch the unusual, idempotency makes each individual call safe to repeat. All three are needed; none substitutes for another.

Control one: a per-run cap enforced in code, not in the prompt

A per-run cap is a number the agent cannot exceed for a single task or work session. It belongs in deterministic code that wraps the billing tool, not in the system prompt, because a prompt is a suggestion and a limit must be a wall.

The mechanics are simple: track cumulative spend for the run, compare any proposed amount against the remaining headroom, and refuse the call locally if it would breach the cap. Do not call the payment API and hope it rejects the charge. The rejection has to happen on your side so the agent receives a clean, structured error it can reason about.

The cap should be set per run and per day. A per-run cap limits the blast radius of one confused agent; a daily cap per account limits the blast radius of one bad afternoon.

ControlWhere it livesWhat it stopsWhat it misses
Per-run capWrapper code before the API callOne runaway sessionMany small sessions over days
Daily/period capAccount-level counter in the databaseSlow bleed across runsA single large amount
Amount threshold gateRouting rule to approverLarge or unusual single paymentsLots of small fraudulent amounts
Payee allowlistTool input validationNew or unexpected recipientsWrong amount to the right payee
Idempotency keyAgent storage plus request headerDuplicate execution of one intentTwo genuinely distinct intents

Two implementation rules make caps trustworthy. First, reserve before you call: increment the counter, then call, then reconcile on the result. If you increment only after success, two concurrent calls can both pass the check. Second, persist the counter in the same durable store the agent uses for other state, so a restart does not reset it to zero.

A worked example. A support agent runs with a per-run cap of 200 USD and a daily cap of 1,000 USD per account. During one run it proposes refunds of 40, 60, 60 and 75 dollars. After the first three, 160 is spent and 40 remains. The fourth proposal (75) exceeds the remaining 40, so the wrapper returns a denial and the agent must either ask a human to raise the cap or shrink the request. The cap is checked before any call, so no refund was issued for the denied item and no manual reversal is needed.

Control two: approval gates for anything unusual

Human-in-the-loop means the agent proposes an action and pauses, a person reviews the exact change and its evidence, and the agent resumes only if the request is approved (Permit.io on human-in-the-loop for agents). This is deliberate: in finance it is maker-checker applied to AI, where a named person with authority reviews the specific change before it reaches the ledger (Keboola on human-in-the-loop for finance).

The classic failure is rubber-stamping. Approval fatigue turns a gate into a formality. A good gate shows the reviewer one screen with the amount, currency, payee, the customer-facing reason and the data the agent relied on. Approving is one decision about one action. Never bundle approvals.

Route by consequence, not by convenience. A defensible default routing matrix looks like this:

SituationRouteApprover evidence recorded
Refund under a small floor, repeat payee, within capAuto-approve, run within limits, loggedAmount, order ID, policy clause
Refund above floor or first-time payeeApprove before it runsRequest page plus linked order history
Charge to a new vendor, any amountApprove before it runs, two approversVendor onboarding record
Anything outside the allowlistDeny, do not routeThe denial reason and the agent's attempt
Bulk run over the daily capDeny, escalate to ownerCounter value at denial

Note the asymmetry: denials are silent blocks, approvals are human actions, and every path writes a record. COSO's guidance on generative AI asks for human corroboration proportionate to risk, and treats an approval that leans solely on AI output as control evidence in its own right (COSO guidance summarized at Keboola). In practice that means the reviewer's screen has to show what the reviewer was shown, not just that a button was clicked.

Choose the mode consciously. The EU High-Level Expert Group distinguishes human in the loop (intervene in every decision cycle), on the loop (shape the design and monitor while it runs) and in command (decide whether the system runs at all) (EU ethics guidelines summarized at Keboola). Reviewing every single refund is usually the wrong choice; reviewing consequential actions, which the same summary describes as the finance norm, is the right one, and writes the routing rules as fixed policy in code so the agent never decides its own gate (Keboola on human-in-the-loop for finance).

Idempotency as a property of the agent that calls the API

Here is the angle you rarely see in the search results: most guides put idempotency on the server. For an agent, key generation and storage belong to the caller, because the agent is the thing that retries.

An idempotent operation is one you can run any number of times while guaranteeing that side effects occur once, which is what lets a caller converge its own state by retrying until it verifiably succeeds (Stripe engineering on idempotency). For a charge, though, exactly-once semantics need help, and that help is the idempotency key (Stripe engineering on idempotency).

The contract has three parts. Before the call, the agent writes a unique key to durable storage. It then sends the key with the request. On any retry, it reuses the same key, and the payment API returns the saved result instead of executing again. After the call, the agent records the outcome against the key.

The key must identify the intent, not the network attempt. Every retry of the same refund uses one key; a genuinely new refund gets a new one (Ali Aftab K. on idempotency design). Payment APIs implement this with a header, conventionally Idempotency-Key, and clients generate a UUID or similar high-entropy value (Zuplo on implementing idempotency keys, Stripe API reference on idempotent requests).

1. AGENT receives intent: refund order O-8817, 60.00 USD
2. AGENT key = stable_id(agent_run_id, order_id, amount, "refund")   # or UUIDv4 created once
3. AGENT INSERT into action_log (key, intent, status="pending")      # atomic; unique constraint on key
   - if insert conflicts and status=succeeded: return saved result
   - if insert conflicts and status=pending:   return 409 in-progress
4. AGENT POST /v1/refunds  Header: Idempotency-Key: <key>  Body: {order, amount}
5. AGENT receives 200 (or cached replay of the original 200)
6. AGENT UPDATE action_log SET status="succeeded", result_id=P-4471 WHERE key=<key>
   - on timeout / 5xx: status stays "pending"; retry with SAME key, not a new one

Three details decide whether this holds under failure. The insert must be atomic, so only one concurrent request wins the race for a given key (Zuplo on implementing idempotency keys). The key must be created before the first request is sent and reused on retries, never regenerated per attempt (Zuplo on implementing idempotency keys). And the agent should only delete its local key once it knows the final outcome; a clear decline is final, but a timeout keeps the key live so the next attempt can return the saved result (Balmik Prajapati on idempotency keys in payment APIs).

Where an external provider sits behind your own API, derive the provider's key from the agent's key and save it before the provider call, so a crash between provider success and your write does not produce a second charge (Balmik Prajapati on idempotency keys in payment APIs). Then keep keys long enough to cover realistic retry windows, commonly 24 hours to 7 days, and not forever, since storing keys too long adds cost (Zuplo on implementing idempotency keys).

Return errors the agent can act on

A cap that denies and a gate that blocks are only useful if the agent can tell the difference between denied by policy, in progress, succeeded and the network died. If your tool returns a bare string, the agent will guess, and a guessing agent retries with a fresh key.

For API-shaped tools, standardize the error body on Problem Details, the JSON format in RFC 9457 that lets an API carry machine-readable details about what went wrong (RFC 9457). It obsoletes RFC 7807, which carried the same idea in an earlier version (RFC 7807). The format exists because HTTP status codes alone cannot convey enough detail for non-human consumers, which is exactly the situation of an agent hitting a cap (RFC 9457).

A useful body for a billing tool carries a stable problem type URI, the cap value that was hit, the amount that would have been spent, and the remaining headroom, so the agent's next step is determined rather than invented. Falling back to custom error payloads creates the inconsistent handling that complicates every integration, and the new wave of machine consumers makes that inconsistency more expensive (Swagger on RFC 9457). If you build this in a framework that already supports the standard, use it rather than hand-rolling a schema; the Spring Framework, for example, lets a controller return a problem detail object directly (Spring Framework error responses).

Design test for the whole system: drop the network mid-call and see what the agent does on resume. It should read its action log, find a pending key, and retry with that same key. If a fresh key appears in the logs, your idempotency is decorative.

A week-one checklist and the things that bite later

Build the stack in this order, and test each piece before adding the next.

  1. Durable action log with a unique constraint on the idempotency key, containing intent, amount, status and result ID.
  2. A billing wrapper that reserves against a per-run counter before calling, then reconciles on the outcome.
  3. Per-run and daily caps, stored in the same database, with the counter reset by an explicit operator action, never on restart.
  4. A routing matrix implemented as fixed rules: auto-approve, approve, deny, escalate.
  5. An approval page that shows the exact action and the evidence, with one approval covering one action.
  6. Problem Details error bodies for every non-success, with the cap, headroom and a stable type URI.
  7. Logs and alerting on denials, escalations and pending keys older than a few hours.

Two failure modes deserve attention up front. The first is approval fatigue: if you route too much to humans, they approve without reading, and the audit record says a person reviewed something they did not, which is why sampling override and reversal rates belongs in the design (Keboola on human-in-the-loop for finance). The second is cap creep: raise a threshold quietly and the control drifts, so any threshold change needs multi-party approval (Keboola on human-in-the-loop for finance).

When ticket volume grows, most of your team's cost moves to the approval queue rather than the agent run. A workflow that routes, records and displays evidence cheaply is worth more than a slightly smarter agent.

If you want the agent calling models across text, routing and classification inside the same workflow, Quanta Inference exposes every model category through one OpenAI-compatible key, and you can see the endpoint details at Quanta Inference.

FAQ

FAQ

Can the agent just set its own spending cap?

No. The cap has to live in code the agent cannot edit, because a model that reasons about its own limits will eventually find a reason to exceed them. The safer pattern is to let the agent propose an amount while deterministic wrapper code enforces a counter it cannot see or change.

What stops two agents from double-spending the same cap?

Reserve before the call and keep the counter in shared durable storage, then reconcile on the result. If you increment only on success, two concurrent calls can both pass the remaining-headroom check. On the idempotency side, the atomic insert on the key ensures only one of two concurrent requests executes, while the other waits or receives the saved result.

What should the agent do when an approval is never answered?

Keep the action parked and leave the idempotency key in pending state. Set a deadline, and if nobody decides, the action stays blocked or goes to a senior reviewer rather than auto-releasing.

When should the agent remove the idempotency key from its store?

Only when the outcome is known. A clear success or a definite decline means the operation is finished, so the key can go. A timeout or a 5xx means the result is unknown, so keep the key and retry with it, because removing it too early is the direct cause of the double charge the key exists to prevent.

Does a human gate remove the need for idempotency keys?

No. A person approving one refund does not stop the agent from retrying a network call that already succeeded. The controls solve different problems: caps limit total exposure, gates catch the unusual, and idempotency makes each call safe to repeat. Stripe's own framing is that exactly-once semantics for a charge need a key, not an approver (Stripe engineering on idempotency).

Sources

Sources

  1. Idempotency Keys in Node.js: A Production-Safe Design ... - Medium
  2. Designing robust and predictable APIs with idempotency - Stripe
  3. Idempotency Keys in Payment APIs: The Small Header ...
  4. Human in the Loop AI Agents: Approval Workflows That Work
  5. What Is Human-in-the-Loop AI in Finance? | Keboola
  6. Implementing Idempotency Keys in REST APIs - Zuplo
  7. Idempotent requests | Stripe API Reference
  8. RFC 9457: Problem Details for HTTP APIs
  9. RFC 7807: Problem Details for HTTP APIs
  10. Problem Details (RFC 9457): Doing API Errors Well - Swagger
  11. Error Responses :: Spring Framework
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