Stripe Idempotency Key for AI Agents: Stop the Silent Double-Charge
When a human clicks "Pay" twice, your UI blocks the second click.
When an agent retries a failed POST /v1/charges, nothing blocks it —
and Stripe will happily create two charges if you do not include an idempotency key.
Here is how to do it right.
Why agents get this wrong more often than humans
Idempotency keys are not new. Stripe has supported the
Idempotency-Key header since 2014. Most payment tutorials mention them.
Yet agents routinely omit them — and the failure is worse than in human-driven flows.
Here is the core difference:
- A human checkout flow has one request per button click. The UI disables the button after the first click. The failure window is narrow.
- An agent may retry a failed HTTP call 3–5 times with exponential backoff, restart mid-transaction after a crash, run in parallel across multiple instances, and lose track of which operations completed when its state resets.
Without idempotency keys, each retry creates a new Stripe object. A stuck retry loop
that runs 10 times against a POST /v1/charges endpoint creates 10
charges. Stripe will not deduplicate them — deduplication is your responsibility.
How Stripe idempotency keys actually work
Send the Idempotency-Key header with any POST request.
Stripe caches the response for that key for 24 hours. If you send
the same key + same endpoint within that window, Stripe returns the cached response
without creating a new object.
POST /v1/charges HTTP/1.1
Authorization: Bearer sk_live_...
Idempotency-Key: agent-run-a1b2c3-charge-cus_xyz
Content-Type: application/x-www-form-urlencoded
amount=5000¤cy=usd&customer=cus_xyz&description=Pro+plan
The rules to know:
- Key format: Any string up to 255 characters. UUIDs, hashes, and human-readable slugs all work.
- Scope: Per Stripe secret key + per endpoint. The same key value can be reused across different endpoints without conflict.
-
Payload mismatch: If you send the same key but a different request
body, Stripe returns a 422 error (
idempotency_keyreuse with different parameters). This is intentional — it prevents accidental data mutation. - 24-hour expiry: After 24 hours, the key is no longer cached. If you retry after the cache expires, Stripe will process the request again as if it were new.
The three agent failure modes without idempotency
1. Network timeout + retry loop
Your agent sends a POST /v1/charges. The connection drops after
the request reaches Stripe but before the response comes back. Stripe already
processed the charge. Your agent sees a timeout, logs an error, and retries.
Without an idempotency key: second charge created.
This is the most common failure mode. Network latency between your agent host and Stripe averages 50–300ms; timeouts in the 5–30s range happen several times a day under normal traffic. Retry without idempotency = silent double-charge.
2. Agent restart mid-transaction
Multi-step agent workflows often checkpoint state. If a crash or OOM kills the
agent after POST /v1/charges returns but before the next checkpoint
is written, the agent restarts from its last saved state and issues the charge
again. Without idempotency: duplicate.
LangChain agents using SQLite checkpointing, CrewAI with intermediate task results, and OpenAI Assistants with thread resumption all have this risk.
3. Parallel agent instances
Orchestrators sometimes run multiple agent instances for reliability or fan-out. If two instances process the same logical operation at the same time, both may call Stripe before either has the other's result. Without shared idempotency keys derived from a common source of truth (e.g., a run ID): two charges.
Patterns for agent-safe idempotency
Pattern 1: Per-run UUID (recommended for most agents)
Generate a UUID at agent startup. Derive per-operation keys from it by appending the operation type and a deterministic identifier.
import uuid
import stripe
# At agent startup — generate once, pass through all tools
run_id = str(uuid.uuid4())
def charge_customer(customer_id: str, amount_cents: int, run_id: str) -> dict:
# Key = run_id + operation + customer — deterministic for this run
idempotency_key = f"{run_id}-charge-{customer_id}"
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
idempotency_key=idempotency_key,
)
return charge
Why this works: The same run, same customer, same operation always
produces the same key. Retries within the run are idempotent. Different runs
(different run_id) produce different keys, which is correct behavior
— a fresh run should be able to create a new charge.
Edge case: If an agent is legitimately supposed to charge the same
customer twice in one run (e.g., invoice line items), append an index:
{run_id}-charge-{customer_id}-{index}.
Pattern 2: Content hash (for deterministic operations)
Hash the canonical form of the operation parameters. Identical operations always produce identical keys — no shared state required.
import hashlib, json, stripe
def charge_idempotent(amount: int, currency: str, customer: str) -> dict:
payload = {"amount": amount, "currency": currency, "customer": customer}
key = hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()[:40] # 40 chars is plenty
return stripe.Charge.create(
**payload,
idempotency_key=f"hash-{key}",
)
Warning: Content hashing only works when parameters are truly identical across retries. If your agent mutates parameters on retry (e.g., bumps the amount after a partial failure), the hash changes and you get a new key — which means a new charge. Use the UUID pattern when parameters might change.
Pattern 3: External sequence number (for multi-instance agents)
For parallel agent instances, derive the idempotency key from a shared external source — a database row ID, a queue message ID, or a Stripe customer's metadata.
def charge_from_order(order_id: str, amount: int, customer: str) -> dict:
# order_id comes from your database — unique, shared across instances
idempotency_key = f"order-{order_id}-charge"
return stripe.Charge.create(
amount=amount,
currency="usd",
customer=customer,
idempotency_key=idempotency_key,
)
If two instances try to charge for the same order, both send the same idempotency key. The first one wins; the second gets the cached response — no duplicate.
Idempotency in LangChain, CrewAI, and OpenAI Agents SDK
LangChain (tool wrapper pattern)
LangChain tools are called with the agent's current input. Pass the
run_id through the tool's context so the idempotency key is stable
across retries on the same run.
from langchain.tools import BaseTool
import stripe, uuid
class StripeTool(BaseTool):
name = "stripe_charge"
description = "Creates a Stripe charge for the given customer"
run_id: str = ""
def __init__(self, run_id: str):
super().__init__()
self.run_id = run_id
def _run(self, customer_id: str, amount_cents: int) -> str:
key = f"{self.run_id}-charge-{customer_id}"
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
idempotency_key=key,
)
return charge.id
# At run time
run_id = str(uuid.uuid4())
tool = StripeTool(run_id=run_id)
agent_executor.invoke({"input": "...", "tools": [tool]})
CrewAI (per-crew idempotency)
CrewAI passes a run_id to each task via the crew's run context.
Store it in the tool and derive keys from it.
from crewai.tools import BaseTool
import stripe, os
class GovernedChargeTool(BaseTool):
name = "create_stripe_charge"
description = "Create a Stripe charge with idempotency enforcement"
def _run(self, customer_id: str, amount: int) -> str:
run_id = os.environ.get("CREW_RUN_ID", "fallback-run")
key = f"{run_id}-charge-{customer_id}"
resp = stripe.Charge.create(
amount=amount,
currency="usd",
customer=customer_id,
idempotency_key=key,
)
return f"Charge {resp.id} created: ${amount / 100:.2f}"
OpenAI Assistants (thread-based idempotency)
OpenAI Assistants have a persistent thread_id that survives across
runs. Use it as the stable root for idempotency keys.
def handle_charge_tool_call(thread_id: str, tool_call_id: str,
customer_id: str, amount: int) -> dict:
# thread_id is stable; tool_call_id is unique per tool invocation
key = f"{thread_id}-{tool_call_id}-charge"
charge = stripe.Charge.create(
amount=amount,
currency="usd",
customer=customer_id,
idempotency_key=key,
)
return {"charge_id": charge.id, "status": charge.status}
Proxy-layer idempotency with Keybrake
Even with the patterns above, agents can forget to set the
Idempotency-Key header. If your agent calls the Stripe API directly,
a missing header means no protection. Keybrake's proxy adds a safety net:
-
Auto-generate idempotency keys when the agent omits them — using
{vault_key_id}-{http_method}-{path}-{request_hash}so the same logical call from the same agent always maps to the same key. -
Pass through agent-supplied keys unchanged — if your agent
already sets
Idempotency-Key, the proxy forwards it as-is. - Log the key in the audit trail — every call in the audit table includes the idempotency key used, making it easy to spot retry storms (multiple log entries with the same key all returning the same cached response).
# Agent only needs to swap the base URL:
stripe.api_base = "https://proxy.keybrake.com/stripe"
stripe.api_key = os.environ["VAULT_KEY"]
# Idempotency key is handled by the proxy if omitted,
# or passed through if the agent sets it:
charge = stripe.Charge.create(
amount=5000,
currency="usd",
customer="cus_xyz",
idempotency_key=f"{run_id}-charge-{customer_id}", # optional but recommended
)
This gives you defense-in-depth: the agent sets an idempotency key, and the proxy provides a fallback if it does not. Two layers of protection against double-charges from retry storms.
See Stripe restricted API key permissions for the full list of permission toggles relevant to charge operations, and Stripe restricted API key Python examples for the stripe-python patterns used alongside the proxy.
Testing idempotency in your agent
The Stripe test environment honors idempotency keys the same way live mode does. Use it to verify your agent handles both cases:
import pytest, stripe, uuid
stripe.api_key = "sk_test_..."
def test_idempotent_charge():
key = f"test-{uuid.uuid4()}-charge"
params = {
"amount": 1000,
"currency": "usd",
"source": "tok_visa",
"idempotency_key": key,
}
first = stripe.Charge.create(**params)
second = stripe.Charge.create(**params) # replay
assert first.id == second.id, "Idempotent replay should return the same charge"
def test_no_idempotency_creates_duplicate():
"""Verify that omitting the key creates two separate charges (baseline)."""
params = {"amount": 1000, "currency": "usd", "source": "tok_visa"}
c1 = stripe.Charge.create(**params)
c2 = stripe.Charge.create(**params)
assert c1.id != c2.id, "Without idempotency key, two charges are created"
def test_payload_mismatch_raises():
key = f"test-{uuid.uuid4()}-mismatch"
stripe.Charge.create(amount=1000, currency="usd", source="tok_visa",
idempotency_key=key)
with pytest.raises(stripe.error.InvalidRequestError):
stripe.Charge.create(amount=2000, currency="usd", source="tok_visa",
idempotency_key=key)
FAQ
- Does the Idempotency-Key header work for GET requests?
-
No. GET requests are already idempotent by nature — they do not mutate state.
Stripe only processes the header on POST and DELETE requests. Retrieving a charge
with
GET /v1/charges/ch_xxxis always safe to retry without a key. - Can I reuse the same idempotency key across multiple API endpoints?
-
Yes. The key is scoped to a specific Stripe secret key + a specific API endpoint.
Sending
my-keytoPOST /v1/chargesandmy-keytoPOST /v1/customersare completely independent — they do not conflict. Only reuse within the same endpoint creates the deduplication behavior. - What happens when the 24-hour cache expires and my agent retries?
- Stripe will process the request as if it were new. If the original charge succeeded, a retry after cache expiry will create a second charge. For long-lived workflows, store the original response (charge ID) in durable storage and check it before retrying rather than relying solely on the idempotency key.
- Should I use the same run_id for the entire multi-day workflow or per-session?
- Per-session is safer. A new agent session represents a new decision to create a charge — even if it is logically "the same workflow." Using the same run_id across days would prevent legitimate retries after the 24-hour window. Persist the Stripe charge ID across sessions instead; check the ID before deciding to create a new charge.
- Do LiteLLM or other LLM proxies handle idempotency for Stripe calls?
-
No. LiteLLM and similar LLM-endpoint proxies handle idempotency for OpenAI API
calls (
POST /v1/chat/completions), not for non-LLM SaaS APIs like Stripe. If your agent calls Stripe through LiteLLM's function-calling layer, idempotency for the Stripe call is still entirely your responsibility — or Keybrake's, if you route through the proxy. - What error code does Stripe return when an idempotency key is reused with different parameters?
-
HTTP 422 with error code
idempotency_key_in_use. If you get this error, it means a request with the same key and different parameters is already in-flight (or the prior request failed in a way that left the key in a conflicted state). Wait a moment and retry, or use a new key. In practice this error is most common in parallel agent instances racing to submit the same key.
Protect your agents from double-charges at the proxy layer
Keybrake proxies your agent's Stripe calls, auto-generates idempotency keys for agents that omit them, and logs every call with the key used — so you can audit retry storms before they become duplicate charges.
Check the proxy is live → curl https://proxy.keybrake.com/health