Idempotency
Retry a paid request safely. Payer-scoped keys, recorded outcomes, and retries after released or refunded attempts.
Agents retry. A response lost to a timeout must not turn into a second payment.
curl -H "Payment: …" -H "Idempotency-Key: 8f1c…" https://api.example.com/v1/translateOn MCP, send the key in _meta["tollstile/idempotency-key"]. Adapters forward it; there is nothing to configure.
What a retry gets
A retry by the same payer with the same key finds the charge its first attempt created:
| First attempt | Retry gets |
|---|---|
| Still running | 409 request_in_progress, retry later |
| Settlement outcome unknown | 503 payment_outcome_unknown, retry later |
| Settled and fulfillment completed | 409 already_paid with chargeId, settlement, and result; not charged, not run |
| Settlement rejected | 402 settlement_rejected with a fresh quote |
| Released or refunded | A new charge attempt may run after verification and requirements pass |
| Settled but fulfillment failed and no automatic refund succeeded | 409 already_paid; contact the merchant |
| Same key, different request | 422 idempotency_key_reused |
- Keys are scoped to the payer. A retry that signs a new payment under the same key is still the same request, and the new payment is not charged. One payer cannot occupy another payer's keys.
- Request matching:
commit: "route"is strengthened to"request"for keyed charges: method, resource, path/query and exact body bytes, or MCP tool and canonical arguments.commit: "request"uses that same hash. A customcommitfunction is also used for idempotency matching; include every input that distinguishes the operation. This is separate from the quote commitment. - Keys must contain 1–255 visible ASCII characters (no spaces). Invalid client keys return
400 invalid_request. - A rail may supply a protocol payment identifier when the client sends none; the client key takes precedence. MPP charge rails use their challenge ID; x402 can use its payment-identifier extension.
- Payer identity limits the scope: MPP Stripe uses
stripe:<challengeId>, and L402 usesl402:<paymentHash>. A new challenge or invoice changes that scope. KYAPay uses<issuer>#<sub>. - The current implementation allows 20 charge attempts per key. After released/refunded attempts exhaust that limit, it returns
422 idempotency_key_reused. - Verification and requirements can reject a request before charge lookup. A key does not bypass expired credentials, current access rules, or single-use identity nonces.
- A rejected settlement remains
failed; the same key continues to reportsettlement_rejected. After confirming that failure, use a new key and valid proof for a new attempt.
Without a key
When neither the client nor the rail supplies a key:
- A single-use payment presented again while its charge has not been released is refused with
409 proof_already_used. It is not charged twice, but the retry does not get the original result. - A reusable credential (L402, KYAPay, credits) presented again is a new request and is charged again. Send a key when retrying reusable credentials.
Returning the original response
Tollstile does not store handler responses. Store the result where you like, and record where with fulfill:
app.post("/v1/images", tollstile(toll.price("$0.04")), async (c) => {
const image = await generate(await c.req.json());
const key = await storage.put(image);
await c.get("payment").fulfill({ resultRef: key });
return c.json({ image: storage.url(key) });
});A retry with the same key gets 409 already_paid with "result": "<key>", so the agent can fetch what it already paid for without paying or running the handler again.
See the Internal Contract for the normative rules.
A resultRef must be 1–1024 characters and contain no secrets. It is a reference, not a cached response or proof of delivery. Subscriber grants create no charge and do not deduplicate handler execution. Credits do create charges and use the policy account as the payer scope.