AI 智能體重試付費 API 呼叫時,如何避免重複扣款
When an AI agent retries a paid API call: how we stop it from paying twice
作者團隊的 MCP gateway 採用四層機制,避免 AI 智能體重試付費 API 呼叫時重複扣款。x402 on Base 和 MPP 的付款憑證只能使用一次;訂單付款重用相同 payment id 時,系統會在 24 小時內返回已儲存答案,付款內容不同則回傳衝突。結算超時會按未知狀態處理並讀取鏈上狀態,工具呼叫只對已交付的回應收費;上週有 1,905 次遭上游拒絕的呼叫未收費。
An AI agent calls a paid API, the payment goes through, and the response never arrives. The connection dropped, a proxy timed out, or the agent's own runtime restarted. From the agent's side the call failed, so it does the reasonable thing and tries again.
If the API charges per call, that retry is where money goes missing. The first payment may have settled on chain while the agent never saw the result. A naive retry pays a second time for the same work.
We run an MCP gateway where agents pay per call in USDC, over x402 on Base or MPP on Tempo. This post walks through the four layers we use so that a retry never turns into a double charge, and where each layer stops helping.
Layer 1: the payment authorization is single-use
With x402 on Base, the agent does not send money directly. It signs an authorization for a USDC transfer (EIP-3009), and the server submits it. Each authorization carries a nonce, and the token contract accepts a given nonce from a given payer only once.
So if the agent resends the exact same signed payment, the second transfer fails on chain. No server-side bookkeeping is needed for that guarantee, and it holds even if our own records are lost. A new charge always needs a new signature from the agent.
MPP has the same property on its side. A credential that was already used comes back as 403 credential_replayed, and the call is not charged.
Layer 2: a payment id makes the retry return the stored answer
Layer 1 stops the double charge, but it leaves the agent stuck: it paid, it has no result, and resending the payment now fails. To fix that, on our order payments (agents buying from shops through the gateway) the agent can attach a payment id to the payment: the payment-identifier extension of x402, 16 to 128 characters.
The rules we apply:
- Same id, same payment: we return the stored answer from the first request, with no second verify and no second settle. We keep that answer for 24 hours.
- Same id while the first request is still running:
409 payment_in_flight. - Same id with a different quote, amount, recipient or payer:
409 payment_identifier_conflict.
The last rule matters more than it looks. If a changed request with an old id quietly got the old result, idempotency would hide a client bug. The agent would think it bought one thing and actually receive another. A conflict forces the client to notice.
Layer 3: "unknown" is not "failed"
The hardest case is the one in the middle. We broadcast the settlement, and then the facilitator call times out or the transaction is still pending. Did the payment happen?
We treat that state as unknown and never as failed. A background job reads the chain: the authorization state for that payer and nonce, and the USDC transfer itself. If it confirms, the order becomes paid and the result is delivered. If it is still not confirmed after 24 hours, the order is marked failed for good and any reserved stock is released.
What we never do is settle the same authorization a second time to "make sure". That is the classic way a timeout turns into two charges.
Layer 4: failed calls are not billed
For tool calls, we only charge for a delivered response. If the upstream provider rejects the input or fails, the call is not charged. Last week that rule covered 1,905 calls rejected by providers, none of them billed.
This does not prevent retries, but it means a retry after a genuine failure is a fresh, fair purchase rather than a second payment for a call that never produced anything.
Where the layers stop
The 24-hour window is a choice, not a law. An agent that comes back after 25 hours with the same payment id will not get the stored answer. It still cannot be charged twice, because Layer 1 rejects the old signature on chain. What it loses is the cached result, so it has to decide whether to pay again for a fresh call.
That is the trade-off we accepted. The record only needs to cover the agent's realistic retry and recovery period. The guarantee against double charging does not depend on the record at all, because it lives in the payment itself.
A checklist if you build paid tools for agents
- Make the payment instrument single-use (a nonce or a one-time credential), so a resend cannot charge twice even if your database is wrong.
- Give clients an idempotency key and return the stored answer for a repeat, for a stated window.
- Return a conflict when the same key arrives with a different request instead of replaying the old result.
- Treat a timed-out settlement as unknown, resolve it by reading the chain, and never settle again to find out.
- Charge only for delivered results, so a retry after a real failure is a fair new purchase.
None of this is specific to AI. It is distributed-systems hygiene that matters more once the client is a program that retries on its own, at machine speed, with a wallet attached.
來源:DEV Community · dev.to