OpenAI Agents SDK · Stripe · Function Tools
OpenAI Agents SDK + Stripe: wiring function tools safely
The OpenAI Agents SDK makes Stripe function tools so easy to add that the safety gaps are just as easy to miss. This post is about those gaps — and the two-line fix that closes all three.
The @function_tool decorator is genuinely well-designed. You annotate a Python function, and the SDK handles schema extraction, model binding, and call dispatch. A Stripe charge tool is 12 lines. The problem isn't the wiring — it's that the wiring is invisible to the policy enforcement you need before the tool hits production.
The basic @function_tool pattern
A working Stripe charge tool with the OpenAI Agents SDK looks like this:
import os
import stripe
from agents import Agent, Runner, function_tool
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
@function_tool
def charge_customer(customer_id: str, amount_cents: int, currency: str = "usd") -> str:
"""Charge a Stripe customer. Returns the PaymentIntent ID."""
intent = stripe.PaymentIntent.create(
amount=amount_cents,
currency=currency,
customer=customer_id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
)
return f"PaymentIntent {intent.id} status: {intent.status}"
billing_agent = Agent(
name="BillingAgent",
instructions="You process customer payments. When asked to charge a customer, call charge_customer.",
tools=[charge_customer],
)
result = Runner.run_sync(billing_agent, "Charge customer cus_ABC123 for $49.")
Twelve lines of business logic, four lines of agent wiring. The SDK handles everything between the model deciding to call charge_customer and your function receiving the arguments. The model sees the docstring and type annotations as the tool's schema — no manual JSON Schema required.
This is genuinely the right abstraction for capability boundaries: what the agent can call. The problem is that capability boundaries are not safety boundaries. The gap between "this agent can charge customers" and "this agent can charge customers up to $200 today" is not a gap @function_tool was designed to close.
Three gaps the decorator can't fill
Gap 1 of 3
No per-run spend cap
The agent has access to whatever STRIPE_SECRET_KEY can do, with no concept of "how much has this run spent so far." A billing agent processing 500 invoices uses the same key for invoice 1 and invoice 500, with no automatic stop when the session crosses a dollar threshold you didn't define. If the model enters a reasoning loop — retrying a payment that already succeeded, re-processing a batch due to a context error — it calls charge_customer as many times as it takes to exhaust the loop or the context window.
Gap 2 of 3
No sub-second mid-run revoke
When you notice something is wrong — the dashboard shows 40 charges for $49 when there should be one — your options are: rotate the Stripe key (invalidates the secret everywhere, takes up to five minutes to propagate through Stripe's infrastructure, takes down every other consumer of that key), or wait for the context window to exhaust. There is no middle path that stops this specific agent run in under a second without collateral damage. The rotate vs revoke playbook has the numbers; the short version is that rotation is not a real-time kill switch.
Gap 3 of 3
No per-call audit with agent context
Stripe logs every API call. What Stripe's log doesn't contain: which agent run triggered this call, which model invocation, what the agent's reasoning was, or what the cumulative spend was at the moment of this call. Stripe logs calls by IP and API key — your agent's "fingerprint" is the IP of whatever machine is running Runner.run_sync. Post-incident reconstruction means correlating your application logs, the Stripe dashboard, and the model's trace, manually, by timestamp. For a batch agent that made 400 calls in 12 minutes, this is an hour of forensics work.
These gaps aren't unique to the OpenAI Agents SDK — they show up in every framework that wraps the Stripe Python library. What makes the Agents SDK context notable is how it amplifies the risk: the SDK is designed for multi-agent handoffs and concurrent tool execution. A single triage agent can spawn five sub-agents, each running charge_customer in parallel, all sharing the same STRIPE_SECRET_KEY. In that topology, the gap from "no per-run budget" to "significant money out the door" is much smaller.
Why Stripe Restricted Keys don't solve this
The standard answer to "scope your Stripe key" is to use a Restricted Key. Issue a key with payment_intents:write only, strip everything else. This is the right starting point and you should do it regardless of what else you add.
But a Restricted Key is still a static credential. After you issue it:
- It has no per-session dollar cap. A key scoped to
payment_intents:writeallows the 40thPaymentIntent.createjust as freely as the first. - It cannot be revoked in real time without rotating the secret — which is the same collateral-damage problem as a full secret key, just with a narrower blast radius on the Stripe side.
- It has no per-call metadata. Stripe's audit log still has no
agent_run_id.
A Restricted Key closes the scope gap (which endpoints can be called) but leaves the spend gap, the revoke gap, and the audit gap open. For a detailed breakdown, see Why your Stripe Restricted Key probably isn't restricted enough.
The two-line fix: route through a proxy
The Stripe Python SDK exposes a base_url on the client object. Point it at a governance proxy instead of api.stripe.com and swap the real Stripe secret for a vault key — a scoped credential the proxy maps to the real secret:
import os
import stripe
from agents import Agent, Runner, function_tool
# Two-line change from the baseline:
stripe.api_key = os.environ["VAULT_KEY"] # vault_key_xxx, not sk_live_...
stripe.base_url = "https://proxy.keybrake.com/stripe/v1" # proxy, not api.stripe.com
@function_tool
def charge_customer(customer_id: str, amount_cents: int, currency: str = "usd") -> str:
"""Charge a Stripe customer. Returns the PaymentIntent ID or an error."""
try:
intent = stripe.PaymentIntent.create(
amount=amount_cents,
currency=currency,
customer=customer_id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
)
return f"PaymentIntent {intent.id} status: {intent.status}"
except stripe.error.RateLimitError as e:
return f"Charge blocked: {e.user_message}"
billing_agent = Agent(
name="BillingAgent",
instructions="You process customer payments. When asked to charge a customer, call charge_customer.",
tools=[charge_customer],
)
result = Runner.run_sync(billing_agent, "Charge customer cus_ABC123 for $49.")
The tool logic, the agent definition, and the runner call are unchanged. The proxy is invisible to the agent. Every Stripe call the tool makes — PaymentIntent.create, Refund.create, any other Stripe method — is intercepted, policy-checked, logged, and forwarded to the real Stripe API. The vault key, not the real secret, is what the agent environment sees.
Issuing the vault key per-run
The vault key is issued before starting the agent run. You configure the policy at issuance time — this is where the three gaps get closed:
import httpx
import os
def issue_vault_key(agent_run_id: str) -> str:
"""Issue a scoped vault key for one billing agent run."""
resp = httpx.post(
"https://proxy.keybrake.com/keys",
headers={"X-Admin-Key": os.environ["KEYBRAKE_ADMIN_KEY"]},
json={
"vendor": "stripe",
"stripe_secret": os.environ["STRIPE_SECRET_KEY"],
"daily_usd_cap": 200,
"allowed_endpoints": ["POST /v1/payment_intents"],
"max_amount_per_call_usd": 500,
"expires_in": "4h",
"metadata": {
"agent_name": "BillingAgent",
"agent_run_id": agent_run_id,
},
},
)
resp.raise_for_status()
return resp.json()["key"]
# Before every agent run:
run_id = f"run_{datetime.utcnow():%Y%m%d_%H%M%S}"
vault_key = issue_vault_key(run_id)
os.environ["VAULT_KEY"] = vault_key
result = Runner.run_sync(billing_agent, f"Charge customer cus_ABC123 for $49. Run ID: {run_id}")
Each policy field maps directly to one of the three gaps:
daily_usd_cap: 200— after the agent accumulates $200 in charges today, the proxy returns 429 on every subsequentPaymentIntent.create. The Stripe SDK raisesRateLimitError, the tool returns "Charge blocked," and the model stops retrying. This fires before the call reaches Stripe — the spend is blocked, not just logged.allowed_endpoints: ["POST /v1/payment_intents"]— even if context contamination causes the agent to call a Refund endpoint, the proxy returns 403. The vault key's allowed list is a second enforcement layer independent of the tool definitions.expires_in: "4h"— the key stops working four hours after issuance regardless of whether anyone revokes it explicitly. For overnight runs, set this to the expected run duration plus a buffer. Old keys become inert automatically.
Killing a run mid-flight
If the audit log shows something unexpected while the agent is still running, the revoke is a single DELETE call:
curl -X DELETE https://proxy.keybrake.com/keys/vk_abc123... \
-H "X-Admin-Key: $KEYBRAKE_ADMIN_KEY"
The proxy marks the key inactive. On the agent's next tool call, the proxy returns 401. The Stripe SDK raises AuthenticationError. The real Stripe secret is unchanged. Every other agent running against a different vault key is unaffected. The revoke takes effect within one request — there is no propagation delay because there is nothing to propagate. The secret has not changed; only the proxy's record of this key's validity has.
This is the core architectural difference between rotating a Stripe key and revoking a vault key. Rotation changes a credential that is distributed across infrastructure. Revocation changes one row in a proxy database. The latency difference is roughly three orders of magnitude.
What the audit log reveals
Every call that goes through the proxy produces one audit row. Here's what that looks like for a run that hit the cap:
| ts | agent_run_id | endpoint | amount_usd | cap_usage_after | verdict |
|---|---|---|---|---|---|
| 09:14:03 | run_20260603_... | POST /v1/payment_intents | 49.00 | $49 / $200 | allowed |
| 09:14:09 | run_20260603_... | POST /v1/payment_intents | 49.00 | $98 / $200 | allowed |
| 09:14:11 | run_20260603_... | POST /v1/payment_intents | 49.00 | $147 / $200 | allowed |
| 09:14:13 | run_20260603_... | POST /v1/payment_intents | 49.00 | $196 / $200 | allowed |
| 09:14:14 | run_20260603_... | POST /v1/payment_intents | 49.00 | $196 / $200 | cap_exceeded |
| 09:14:15 | run_20260603_... | POST /v1/payment_intents | 49.00 | $196 / $200 | cap_exceeded |
Compare this to what Stripe's dashboard shows: four PaymentIntent objects with different IDs and timestamps, and two API errors. No agent_run_id column. No cap_usage_after. No verdict. No indication that the fifth and sixth calls were blocked by your infrastructure rather than by a Stripe-side error.
The SQL query for a post-incident review:
-- All calls from a specific run, in order
SELECT ts, endpoint, amount_usd, cap_usage_after, verdict
FROM calls
WHERE metadata->>'agent_run_id' = 'run_20260603_091403'
ORDER BY ts ASC;
This is the query you run at 2am when something went wrong. Without the audit log, you're correlating application logs, Stripe timestamps, and the agent's trace output manually. With it, you have a chronological view of every call, what the cumulative spend was at the time of each call, and exactly which call triggered the enforcement. The forensics time drops from hours to minutes.
How the model handles the 429
When daily_usd_cap is exceeded, the proxy returns a 429 with a Reason: daily_spend_cap_exceeded header. The Stripe SDK raises stripe.error.RateLimitError. This is the same exception class that fires on a real Stripe rate limit, which means the error handling in the tool function is important:
@function_tool
def charge_customer(customer_id: str, amount_cents: int, currency: str = "usd") -> str:
"""Charge a Stripe customer. Returns the PaymentIntent ID or an error."""
try:
intent = stripe.PaymentIntent.create(
amount=amount_cents,
currency=currency,
customer=customer_id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
)
return f"PaymentIntent {intent.id} status: {intent.status}"
except stripe.error.RateLimitError as e:
if "daily_spend_cap_exceeded" in (e.headers or {}).get("Reason", ""):
return "BLOCKED: daily spend cap reached for this agent run. Do not retry — contact the operator."
return f"Rate limit error (transient, can retry): {e.user_message}"
except stripe.error.StripeError as e:
return f"Stripe error: {e.user_message}"
The distinction matters for model behavior. If the tool returns a generic "rate limit error," the model will retry — it's been trained to treat rate limits as transient. If the tool returns "BLOCKED: daily spend cap reached — do not retry," the model surfaces the message to the user instead of entering a retry loop. The instruction "Do not retry" is load-bearing. The OpenAI Agents SDK documentation notes that models follow explicit tool return instructions; a clear "do not retry" in the return string is more reliable than hoping the model infers from the exception type.
Multi-agent patterns: passing vault keys through handoffs
The OpenAI Agents SDK supports agent handoffs — a parent agent spawning sub-agents to handle subtasks. In a billing triage pattern, a parent agent might hand off specific customers to sub-agents, each of which has a charge_customer tool. The naive approach is to share the same vault key across all sub-agents. The correct approach is to issue a separate vault key per sub-agent:
from agents import Agent, Runner, function_tool
def create_billing_subagent(customer_id: str, max_charge_usd: float) -> Agent:
"""Create a sub-agent with a scoped vault key for one customer."""
vault_key = issue_vault_key(
agent_run_id=f"sub_{customer_id}_{datetime.utcnow():%H%M%S}",
daily_usd_cap=max_charge_usd,
allowed_endpoints=["POST /v1/payment_intents"],
expires_in="30m",
)
# Each sub-agent gets its own stripe client scoped to one vault key
client = stripe.StripeClient(
api_key=vault_key,
base_url="https://proxy.keybrake.com/stripe/v1",
)
@function_tool
def charge_this_customer(amount_cents: int) -> str:
"""Charge the pre-assigned customer."""
intent = client.payment_intents.create(params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"confirm": True,
})
return f"PaymentIntent {intent.id} status: {intent.status}"
return Agent(
name=f"BillingAgent-{customer_id}",
instructions=f"Process payment for customer {customer_id} only.",
tools=[charge_this_customer],
)
Per-sub-agent vault keys give you per-agent attribution in the audit log (each sub-agent has a distinct agent_run_id in the vault key metadata), per-agent spend caps (sub-agent A burning its cap doesn't affect sub-agent B), and per-agent revoke (you can kill one sub-agent's key without affecting the others). The parent agent uses the factory function to spawn sub-agents rather than sharing a module-level Stripe client.
This pattern is covered in more depth in Five things multi-agent systems break when they share an API key.
The two changes, summarized
The full change from "working but unsafe" to "working and governed" is:
- Swap the key:
STRIPE_SECRET_KEY→VAULT_KEY(a per-run credential with a policy attached) - Swap the base URL:
api.stripe.com→proxy.keybrake.com/stripe/v1(an enforcement layer that runs before every call reaches Stripe)
The tool definitions, the agent instructions, the runner call, and the LLM model all remain unchanged. The proxy is transparent to the agent — it sees the same Stripe API surface, the same response format, and the same error types. The difference is that every call now has a spending limit, a revoke path, and a timestamped audit row with your agent's identity attached.
Put the brakes on your agent's Stripe key
Keybrake is the proxy. Two environment variable swaps per agent, one dashboard for vault key policies and the audit log. Free tier: 1,000 proxied requests/month. Hobby ($29/mo): all vendors, 30-day log retention, email alerts on cap breach.
Further reading
- OpenAI Agents SDK + Stripe: quick reference — the SEO page companion: @function_tool setup, three gaps, two-line fix, FAQ on hosted tool execution and streaming mode.
- LangChain + Stripe: the spend-cap your agent doesn't have — the same proxy pattern for LangChain's
BaseTool, with three failure modes and the full policy configuration walkthrough. - Giving Stripe Agent Toolkit an off-switch — the MCP-based toolkit approach; same architectural problem, same proxy fix.
- Five things multi-agent systems break when they share an API key — attribution collapse, rate-limit contention, blast radius on compromise, scope mismatch, audit log collapse.
- Rotate vs revoke: a 2am playbook for a stuck AI agent — the propagation math behind why key rotation is not a real-time kill switch, and when to use each approach.