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&currency=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_key reuse 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:

  1. 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.
  2. Pass through agent-supplied keys unchanged — if your agent already sets Idempotency-Key, the proxy forwards it as-is.
  3. 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_xxx is 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-key to POST /v1/charges and my-key to POST /v1/customers are 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

View pricing (free tier: 1,000 requests/month)