Composio Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

Composio makes it trivially easy to give any AI agent Stripe tools in three lines of Python. It makes it equally easy for those agents to silently double-charge customers the moment the host framework retries a failed tool call.

Composio is a tool integration platform for AI agents. It ships pre-built action definitions for over 250 SaaS applications — including Stripe — that work as native tools in LangChain, CrewAI, AutoGen, OpenAI Agents SDK, and most other agent frameworks. Rather than writing your own Stripe tool wrapper, you call toolset.get_tools(actions=[Action.STRIPE_CREATE_CHARGE]) and the agent gets a ready-made function with the correct schema. Composio handles authentication, executes the action server-side using your connected account's credentials, and returns the result.

This architecture is genuinely useful. The failure modes emerge from what Composio does not do: it does not inject idempotency keys into Stripe API calls, it does not isolate Stripe credentials per agent, and it does not deduplicate concurrent billing runs triggered by webhook retries. Each omission corresponds to a specific duplicate-charge scenario. This post covers all three, with Python SDK examples for each, and the governance pattern that closes them without abandoning Composio's tool integration model.

Failure mode 1: Framework retry fires a duplicate STRIPE_CREATE_CHARGE

When you wire a Composio Stripe tool into a LangChain, CrewAI, or AutoGen agent, the agent framework owns the retry logic. If the LLM calls STRIPE_CREATE_CHARGE and the tool execution succeeds at the Stripe layer but the agent's subsequent step fails — a database write, a downstream API call, a structured output parse error — the framework retries from the most recent tool call. Composio re-executes the action. Stripe receives a second POST /v1/charges with the same parameters and no idempotency key. A second charge object is created.

from composio_langchain import ComposioToolSet, Action
from langchain.agents import create_react_agent, AgentExecutor
from langchain_openai import ChatOpenAI

toolset = ComposioToolSet()

# UNSAFE: no idempotency key — Composio executes STRIPE_CREATE_CHARGE
# with whatever params the LLM passes; no deduplication on retry
tools = toolset.get_tools(actions=[
    Action.STRIPE_CREATE_CHARGE,
    Action.STRIPE_LIST_CUSTOMERS,
])

llm = ChatOpenAI(model="gpt-4o")
agent = create_react_agent(llm, tools)
agent_executor = AgentExecutor(agent=agent, tools=tools, max_iterations=5)

# If the agent calls STRIPE_CREATE_CHARGE, it succeeds (ch_A created),
# then the next step fails, LangChain retries the last tool call:
# STRIPE_CREATE_CHARGE fires again → ch_B created.
# Customer billed twice. No warning. Both charges show "succeeded" in Stripe.
result = agent_executor.invoke({
    "input": f"Charge customer cus_abc $49 for the Pro plan renewal for June 2026."
})

The failure sequence is: the LLM emits a tool call for STRIPE_CREATE_CHARGE with amount=4900, currency="usd", customer="cus_abc". Composio executes the action; Stripe creates charge ch_A. The LLM's next step — parsing the charge ID into a CRM update call — raises a validation error. LangChain's AgentExecutor with max_iterations=5 re-invokes the agent. The LLM re-emits the same tool call because it no longer sees ch_A in its context. Composio executes again. Stripe creates ch_B. The customer has been billed twice.

CrewAI produces the same outcome via its task retry mechanism. AutoGen produces it via the human_input_mode="NEVER" retry path when an agent's generate_reply loop re-sends the prior tool call message. The root cause is identical in all cases: Composio's action execution has no idempotency layer, so every invocation of STRIPE_CREATE_CHARGE is a new charge.

The fix: wrap Composio actions with a content-hash idempotency key

Composio does not expose an idempotency key parameter in its high-level get_tools() interface. The correct fix is to wrap the Composio tool with a function that injects an idempotency key derived from the stable billing parameters before forwarding the call. This wrapper becomes the tool the LLM sees; internally it calls Composio's execute_action method with the idempotency key added to the request headers.

import hashlib
from composio_langchain import ComposioToolSet, Action
from composio.client.enums import App
from langchain.tools import StructuredTool
from pydantic import BaseModel

PROXY_BASE = "https://proxy.keybrake.com/stripe"

def billing_idempotency_key(
    customer_id: str,
    amount_cents: int,
    billing_period: str,
) -> str:
    raw = f"{customer_id}:{amount_cents}:{billing_period}:composio-billing"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]


class ChargeCustomerInput(BaseModel):
    customer_id: str
    amount_cents: int
    billing_period: str  # e.g. "2026-06" — stable per billing cycle


def make_safe_charge_tool(toolset: ComposioToolSet) -> StructuredTool:
    def charge_customer(customer_id: str, amount_cents: int, billing_period: str) -> dict:
        idem_key = billing_idempotency_key(customer_id, amount_cents, billing_period)

        # execute_action lets us pass extra request parameters that Composio
        # forwards to the Stripe API — including the idempotency key header.
        result = toolset.execute_action(
            action=Action.STRIPE_CREATE_CHARGE,
            params={
                "amount": amount_cents,
                "currency": "usd",
                "customer": customer_id,
                "description": f"Subscription {billing_period}",
            },
            # Composio passes metadata dict as extra HTTP headers to Stripe
            metadata={"Idempotency-Key": idem_key},
        )

        if result.get("successfull") is False:
            # Return error as data — do not raise, so the framework does not retry
            return {"error": result.get("error", "unknown"), "idempotency_key": idem_key}

        return {
            "charge_id": result["data"].get("id"),
            "status": result["data"].get("status"),
            "idempotency_key": idem_key,
        }

    return StructuredTool.from_function(
        func=charge_customer,
        name="charge_customer",
        description=(
            "Charge a Stripe customer for a billing period. "
            "Idempotent: safe to call multiple times for the same "
            "customer_id + amount_cents + billing_period combination."
        ),
        args_schema=ChargeCustomerInput,
    )

The idempotency key is a SHA-256 hash of (customer_id, amount_cents, billing_period, 'composio-billing'). This string is identical on every retry of the same billing operation. Stripe's deduplication returns the original charge object for any call with the same key within 24 hours, even if the first call completed successfully. The wrapper returns errors as data instead of raising, which prevents the LangChain AgentExecutor from treating a known billing error as a retry trigger.

Failure mode 2: All agents share one entity_id and one full-access Stripe connection

Composio maps agent tool access to a connected account via entity_id. The default is entity_id="default". When you connect your Stripe account through the Composio dashboard using this default entity, every agent in your system that instantiates ComposioToolSet() — without specifying a different entity_id — uses the same Stripe connection. That connection has whatever permissions were granted at OAuth time, which is typically full account access.

# All three agent types share entity_id="default" → same Stripe connection
billing_toolset = ComposioToolSet()          # entity_id="default"
support_toolset = ComposioToolSet()          # entity_id="default"
analytics_toolset = ComposioToolSet()        # entity_id="default"

# Billing agent: needs STRIPE_CREATE_CHARGE only
billing_tools = billing_toolset.get_tools(actions=[Action.STRIPE_CREATE_CHARGE])

# Support agent: needs STRIPE_LIST_CHARGES, STRIPE_RETRIEVE_CUSTOMER only
support_tools = support_toolset.get_tools(actions=[
    Action.STRIPE_LIST_CHARGES,
    Action.STRIPE_RETRIEVE_CUSTOMER,
])

# Problem: all three toolsets call Stripe through the same connected OAuth account.
# If support_toolset is used by an agent that gets prompt-injected, it can call
# STRIPE_CREATE_CHARGE even though you only gave it LIST and RETRIEVE tools —
# because the underlying OAuth token is full-access.
# Even if action filtering prevents direct STRIPE_CREATE_CHARGE calls from support_toolset,
# a compromised analytics agent with a different action list can execute billing actions
# by calling execute_action() directly with the shared entity_id credentials.

The practical risk is two-fold. First, a runaway agent using Action.STRIPE_CREATE_CHARGE can exhaust your Stripe account's billing capacity on behalf of any customer — there is no per-agent spend cap enforced at the Stripe credential level. Second, if any agent in your system is vulnerable to prompt injection (via retrieved documents, tool outputs, or user inputs), the attacker has access to the full-scope Stripe connection through Composio's platform, regardless of which actions you passed to get_tools().

The correct fix is per-agent entity IDs combined with vault keys issued through the Keybrake proxy. Each agent type gets its own Composio entity with its own connected Stripe account — or, more practically, a restricted vault key that the proxy issues per agent, scoped to exactly the Stripe endpoints that agent is allowed to call.

# SAFE: per-agent vault keys via Keybrake proxy, issued at agent instantiation time
import os
import stripe

PROXY_BASE = "https://proxy.keybrake.com/stripe"

def make_billing_stripe_client() -> stripe.StripeClient:
    """Returns a StripeClient scoped to POST /v1/charges only, $500/day cap."""
    vault_key = os.environ["KEYBRAKE_BILLING_VAULT_KEY"]
    return stripe.StripeClient(api_key=vault_key, base_url=PROXY_BASE)

def make_audit_stripe_client() -> stripe.StripeClient:
    """Returns a StripeClient scoped to GET /v1/charges only, read-only."""
    vault_key = os.environ["KEYBRAKE_AUDIT_VAULT_KEY"]
    return stripe.StripeClient(api_key=vault_key, base_url=PROXY_BASE)

# Billing agent's tool uses the billing client — can only POST /v1/charges
def charge_customer_safe(
    customer_id: str, amount_cents: int, billing_period: str
) -> dict:
    client = make_billing_stripe_client()
    idem_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
    charge = client.charges.create(
        params={"amount": amount_cents, "currency": "usd", "customer": customer_id},
        options={"idempotency_key": idem_key},
    )
    return {"charge_id": charge.id, "status": charge.status}

# Support agent's tool uses the audit client — rejected at proxy if it tries to charge
def list_customer_charges(customer_id: str, limit: int = 10) -> list:
    client = make_audit_stripe_client()
    charges = client.charges.list(params={"customer": customer_id, "limit": limit})
    return [{"id": c.id, "amount": c.amount, "status": c.status} for c in charges.data]

The vault key is issued per agent type and enforced at the proxy layer — not at Composio's action filtering layer. This means even if a tool wrapper is bypassed or an agent calls execute_action directly, the vault key still restricts what Stripe endpoints can be reached. The billing vault key returns a 403 from the proxy on any request to GET /v1/charges; the audit vault key returns a 403 on any POST /v1/charges.

Failure mode 3: Composio trigger double-delivery starts two concurrent billing runs

Composio's trigger system connects incoming webhook events — from Stripe, Slack, GitHub, and other platforms — to agent workflows. A common pattern: a Stripe invoice.payment_failed webhook fires, Composio receives it, and triggers a billing retry agent that calls STRIPE_CREATE_CHARGE to attempt a recovery charge. The problem is that Stripe retries webhooks on any non-2xx response, and Composio's trigger processing may take longer than Stripe's acknowledgment timeout. If Composio's trigger handler is slow, or if your listener is temporarily unavailable, Stripe sends the webhook a second time. Composio triggers two independent agent runs. Both runs call STRIPE_CREATE_CHARGE for the same customer with the same parameters. Without an idempotency key, two charges are created.

from composio import Composio
from composio.client.enums import Trigger

client = Composio()

# UNSAFE: no deduplication on double-delivered trigger
@client.triggers.handle(Trigger.STRIPE_INVOICE_PAYMENT_FAILED)
def handle_payment_failed(event: dict) -> None:
    customer_id = event["data"]["object"]["customer"]
    amount = event["data"]["object"]["amount_due"]
    invoice_id = event["data"]["object"]["id"]

    # This runs twice if Stripe retries the webhook.
    # Two concurrent executions of this handler both reach STRIPE_CREATE_CHARGE.
    # No idempotency key → two recovery charges for the same invoice.
    toolset = ComposioToolSet()
    toolset.execute_action(
        action=Action.STRIPE_CREATE_CHARGE,
        params={"amount": amount, "currency": "usd", "customer": customer_id},
    )


# SAFE: idempotency key derived from the invoice_id makes the charge
# stable across any number of trigger deliveries for the same event
@client.triggers.handle(Trigger.STRIPE_INVOICE_PAYMENT_FAILED)
def handle_payment_failed_safe(event: dict) -> None:
    customer_id = event["data"]["object"]["customer"]
    amount = event["data"]["object"]["amount_due"]
    invoice_id = event["data"]["object"]["id"]  # stable per invoice

    # invoice_id is unique per invoice — same across all retries of the same webhook
    idem_key = hashlib.sha256(
        f"{customer_id}:{amount}:{invoice_id}:composio-recovery".encode()
    ).hexdigest()[:32]

    toolset = ComposioToolSet()
    result = toolset.execute_action(
        action=Action.STRIPE_CREATE_CHARGE,
        params={"amount": amount, "currency": "usd", "customer": customer_id},
        metadata={"Idempotency-Key": idem_key},
    )

    if result.get("successfull") is False:
        # Log error and return 200 so Stripe stops retrying
        print(f"Recovery charge failed: {result.get('error')} (idem_key={idem_key})")
        return

    print(f"Recovery charge created: {result['data']['id']} (idem_key={idem_key})")

The idempotency key here is derived from the invoice_id rather than the billing period, because the trigger fires once per invoice event and the invoice ID is the stable identifier for that event. If Stripe delivers the webhook three times, Composio triggers the handler three times, and all three calls to STRIPE_CREATE_CHARGE produce the same idempotency key — Stripe deduplicates and returns the same charge object each time. The handler also returns cleanly instead of raising, so Stripe receives a 200 and stops retrying.

Pre-flight guard: check for an existing charge before creating a new one

For billing workflows where the idempotency key alone is not sufficient — for example, when the billing period could span multiple invoice attempts or when Stripe's 24-hour idempotency window may have expired — add a pre-flight check that queries the audit vault key before creating a new charge:

def charge_with_preflight(
    client_billing: stripe.StripeClient,
    client_audit: stripe.StripeClient,
    customer_id: str,
    amount_cents: int,
    billing_period: str,
) -> dict:
    # Check whether a charge already exists for this billing period
    existing = client_audit.charges.list(params={
        "customer": customer_id,
        "limit": 10,
    })
    for charge in existing.data:
        meta = charge.metadata or {}
        if meta.get("billing_period") == billing_period and charge.status == "succeeded":
            return {"charge_id": charge.id, "status": "already_charged", "dedup": True}

    # No existing charge — safe to create
    idem_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
    charge = client_billing.charges.create(
        params={
            "amount": amount_cents,
            "currency": "usd",
            "customer": customer_id,
            "metadata": {"billing_period": billing_period},
        },
        options={"idempotency_key": idem_key},
    )
    return {"charge_id": charge.id, "status": charge.status, "dedup": False}

The pre-flight guard uses the audit vault key (read-only, GET /v1/charges only) to verify no charge exists for the billing period before calling the billing vault key to create one. The metadata.billing_period field is set on every charge created through this function, making the check reliable across sessions and Stripe's idempotency window.

Approach comparison

Approach Key type Per-agent scope Idempotent Spend cap Retry safe Trigger safe
Bare STRIPE_SECRET_KEY in Composio Full sk_live_
Composio connected account (OAuth, default entity) Full account access entity_id only
Composio + Stripe restricted key Restricted sk_live_
Composio + idempotency key wrapper Full sk_live_
Composio + Keybrake vault key + idem key Vault key (scoped)

Enforcement test suite

import pytest
import hashlib
from unittest.mock import patch, MagicMock

PROXY_BASE = "https://proxy.keybrake.com/stripe"


def billing_idempotency_key(customer_id, amount_cents, billing_period):
    raw = f"{customer_id}:{amount_cents}:{billing_period}:composio-billing"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]


def test_idempotency_key_is_deterministic():
    k1 = billing_idempotency_key("cus_abc", 4900, "2026-06")
    k2 = billing_idempotency_key("cus_abc", 4900, "2026-06")
    assert k1 == k2, "Same inputs must produce the same idempotency key"


def test_different_billing_periods_produce_different_keys():
    k_jun = billing_idempotency_key("cus_abc", 4900, "2026-06")
    k_jul = billing_idempotency_key("cus_abc", 4900, "2026-07")
    assert k_jun != k_jul, "Different billing periods must not share idempotency keys"


def test_different_customers_produce_different_keys():
    k1 = billing_idempotency_key("cus_abc", 4900, "2026-06")
    k2 = billing_idempotency_key("cus_xyz", 4900, "2026-06")
    assert k1 != k2, "Different customers must not share idempotency keys"


def test_vault_key_not_bare_stripe_key(monkeypatch):
    monkeypatch.setenv("KEYBRAKE_BILLING_VAULT_KEY", "kbvk_test_billing_abc123")
    vault_key = __import__("os").environ["KEYBRAKE_BILLING_VAULT_KEY"]
    assert vault_key.startswith("kbvk_"), "Must use vault key, not bare sk_live_ key"
    assert not vault_key.startswith("sk_"), "Bare Stripe keys must not reach the agent"


def test_error_returned_as_data_not_raised():
    """Tool wrapper must not raise on Stripe error — prevents framework retry loop."""
    from unittest.mock import patch

    mock_toolset = MagicMock()
    mock_toolset.execute_action.return_value = {
        "successfull": False,
        "error": "card_declined",
    }

    # Import the wrapper under test (simplified inline version)
    def charge_customer_safe(customer_id, amount_cents, billing_period):
        idem_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
        result = mock_toolset.execute_action(
            action="STRIPE_CREATE_CHARGE",
            params={"amount": amount_cents, "currency": "usd", "customer": customer_id},
            metadata={"Idempotency-Key": idem_key},
        )
        if result.get("successfull") is False:
            return {"error": result.get("error"), "idempotency_key": idem_key}
        return {"charge_id": result["data"]["id"], "status": result["data"]["status"]}

    result = charge_customer_safe("cus_abc", 4900, "2026-06")
    assert "error" in result, "Error must be returned as data, not raised as exception"
    assert "idempotency_key" in result, "Idempotency key must be present in error response"

Gap analysis

Frequently asked questions

Can I use Composio's built-in Stripe integration without worrying about duplicate charges?

Not safely, for billing operations. Composio's integration handles authentication and action schema for you, but idempotency is not built into the platform's tool execution path. As long as your agent framework retries failed tool calls — which all major frameworks do — every billing tool call is at risk of duplicate execution. The fix is to wrap the Composio action with an idempotency key before the LLM sees it, as shown above.

Does Composio's entity_id isolate credentials between agents?

Partially. Separate entity IDs mean separate connected accounts in Composio's platform, which means separate OAuth tokens or API keys. But the connected account is still the one you authorized in the Composio dashboard — typically a full-access Stripe connection. You would need to connect a separate Stripe restricted key per entity to get endpoint-level isolation, and Composio does not enforce spend caps regardless of entity ID. Vault keys via the Keybrake proxy provide enforcement that is independent of how Composio manages entity credentials.

What's the difference between Composio's action filtering and the Keybrake proxy approach?

Composio's get_tools(actions=[Action.STRIPE_LIST_CHARGES]) filters which tool objects are exposed to the LLM in the agent's tool list. It does not prevent the underlying Stripe credential from being used to call other Stripe endpoints — it only shapes the LLM's action vocabulary. The Keybrake proxy enforces restrictions at the HTTP level: a vault key scoped to GET /v1/charges returns a 403 on any other endpoint, regardless of what called the proxy. The enforcement is in the network path, not in Python code that can be bypassed.

Can I just disable retries in the agent framework to avoid duplicate charges?

You can set max_iterations=1 in LangChain or max_turns=1 in AutoGen to prevent tool retries. But this also prevents the agent from recovering from legitimate transient errors — a temporary Stripe API timeout would permanently fail the billing run rather than retrying safely. Content-hash idempotency keys give you the reliability of retries without the risk: the agent can retry as many times as needed, and Stripe deduplicates all of them to the same charge.

How do I add an idempotency key to a Composio action if execute_action does not accept a headers parameter?

Composio's execute_action method accepts a metadata dict. Composio forwards key-value pairs from this dict as additional HTTP headers to the target API. Passing metadata={"Idempotency-Key": idem_key} causes Composio to include the Idempotency-Key header in its POST /v1/charges call to Stripe. Verify this behavior against your Composio SDK version — the metadata parameter behavior may vary across versions; if it does not work, use the direct stripe.StripeClient approach instead of routing through Composio's action executor for billing calls.

Does this apply to other Composio-connected payment APIs like Square or Braintree?

Yes. Any Composio payment action that creates a financial transaction — SQUARE_CREATE_PAYMENT, BRAINTREE_TRANSACTION_SALE, or equivalent — is subject to the same duplicate execution risk when the host framework retries. All payment APIs that support idempotency keys (Square's idempotency_key, Braintree's transaction ID) should use the same content-hash pattern. The metadata forwarding approach and vault key proxy apply to any HTTP-based payment integration regardless of the provider.

Scope every Stripe call your agent makes

Keybrake issues per-agent vault keys that restrict Stripe access to exactly the endpoints each agent needs, enforce daily spend caps, and log every call with agent context. Works with Composio, LangChain, CrewAI, and any other framework that calls Stripe.