Python · Stripe
Stripe restricted API key Python: complete guide for AI agents
How to set up, use, and test Stripe restricted API keys in Python-based AI agents — with stripe-python code examples for five common agent archetypes and a clear-eyed look at what restricted keys still can't protect you from.
Most Stripe restricted key guides show you the dashboard form and stop there. That's enough for a human developer making deliberate API calls. It's not enough for a Python AI agent making hundreds of calls a minute — where a wrong permission level, a missing error handler, or a misconfigured key on the wrong environment can mean real money out the door before anyone notices.
This guide is specifically for Python agents. We cover the stripe-python SDK patterns that matter, minimum permission configurations for five common agent roles, how to test that your restrictions actually enforce what you think they do, and the two gaps where restricted keys run out of road regardless of how carefully you configure them.
For a complete breakdown of every Stripe permission toggle, see our Stripe restricted API key permissions reference. For five ready-to-use configuration examples with CLI commands, see Stripe Restricted API key examples. This post assumes you already know what a restricted key is and focuses on the Python-specific implementation layer.
Setting up the stripe-python SDK with a restricted key
The stripe-python library handles restricted keys the same way it handles secret keys — you assign the key to stripe.api_key and the library uses it for all subsequent calls. The restriction enforcement happens server-side at Stripe; the SDK never sees or validates the permission set locally.
pip install stripe
The recommended pattern for agent code is to set the key once at module initialization from an environment variable, not inline in the code:
import stripe
import os
stripe.api_key = os.environ["STRIPE_RESTRICTED_KEY"]
# rk_live_... or rk_test_... (not sk_live_... or sk_test_...)
rk_live_ (production) or rk_test_ (test mode). If your key starts with sk_ you have a secret key, not a restricted one. Secret keys have no permission scoping — any agent with a secret key can do everything.
For agents that run multiple instances or serve multiple users, set the key per-request rather than globally. The stripe-python SDK supports this via the api_key parameter on individual method calls:
import stripe
def get_customer(customer_id: str, key: str) -> stripe.Customer:
return stripe.Customer.retrieve(customer_id, api_key=key)
This pattern is important for multi-agent systems where each agent instance or user session should use its own scoped key. It prevents one agent's key from being accidentally used for another agent's request if your code ever runs concurrent requests in the same process.
Permission configurations for five Python agent archetypes
The minimum viable permission set depends entirely on what your agent does. Here are five common Python agent roles with their exact configurations. For each, the table shows what to enable in the Stripe Dashboard under Developers → API keys → Restricted key.
1. Refund agent (customer support automation)
This agent looks up purchases and issues refunds. It needs to read charges and create refunds, but should not be able to create new charges or modify subscriptions.
| Resource | Level | Why |
|---|---|---|
Charges | Read | Look up the original charge to refund |
Refunds | Write | Create refunds against existing charges |
Customers | Read | Verify customer identity before refunding |
Payment Intents | None | No new payment creation needed |
| All other resources | None | Least-privilege default |
import stripe
import os
stripe.api_key = os.environ["STRIPE_REFUND_AGENT_KEY"]
def issue_refund(charge_id: str, amount_cents: int | None = None) -> stripe.Refund:
"""Issue a full or partial refund. amount_cents=None means full refund."""
params: dict = {"charge": charge_id}
if amount_cents is not None:
params["amount"] = amount_cents
return stripe.Refund.create(**params)
def get_charge(charge_id: str) -> stripe.Charge:
return stripe.Charge.retrieve(charge_id)
2. Analytics agent (read-only reporting)
This agent queries Stripe data to generate reports. It needs read access to financial records but should never be able to write anything. A misconfigured analytics agent that accidentally touches Write endpoints can be expensive.
| Resource | Level | Why |
|---|---|---|
Charges | Read | Query transaction history |
Customers | Read | Join customer records to charges |
Invoices | Read | Subscription billing detail |
Subscriptions | Read | MRR, churn, plan distribution |
Refunds | Read | Net revenue calculation |
Balance Transactions | Read | Settled amounts, fees |
| All Write permissions | None | Analytics agents must never write |
import stripe
import os
from datetime import datetime, timedelta
stripe.api_key = os.environ["STRIPE_ANALYTICS_KEY"]
def get_revenue_last_30_days() -> int:
"""Returns total charge amount in cents for the last 30 days."""
since = int((datetime.utcnow() - timedelta(days=30)).timestamp())
total = 0
for charge in stripe.Charge.list(created={"gte": since}, limit=100).auto_paging_iter():
if charge.paid and not charge.refunded:
total += charge.amount
return total
3. Subscription manager (billing automation)
This agent cancels, upgrades, and applies coupons to subscriptions based on customer actions or automated rules. The key risk here is accidental cancellation of subscriptions at scale.
| Resource | Level | Why |
|---|---|---|
Subscriptions | Write | Cancel, update, create subscriptions |
Customers | Read | Look up subscriber identity |
Invoices | Read | Check payment status before acting |
Coupons | Read | Validate coupon codes before applying |
Charges | None | Subscription agent must not create charges directly |
import stripe
import os
stripe.api_key = os.environ["STRIPE_SUBSCRIPTION_KEY"]
def cancel_subscription(subscription_id: str, at_period_end: bool = True) -> stripe.Subscription:
"""Cancel a subscription. Default: cancel at period end to avoid proration disputes."""
return stripe.Subscription.modify(
subscription_id,
cancel_at_period_end=at_period_end
)
def apply_coupon(subscription_id: str, coupon_id: str) -> stripe.Subscription:
return stripe.Subscription.modify(subscription_id, coupon=coupon_id)
4. Payment capture agent (checkout automation)
This agent confirms payment intents as part of a checkout or order fulfillment flow. It needs Write on Payment Intents only — it should not be able to issue refunds or modify subscriptions.
| Resource | Level | Why |
|---|---|---|
Payment Intents | Write | Create and confirm payments |
Customers | Read | Look up stored payment methods |
Payment Methods | Read | List saved cards for confirmation |
Charges | Read | Verify charge outcome after capture |
Refunds | None | Capture agent must not refund |
Subscriptions | None | Payment agent must not touch subscriptions |
import stripe
import os
stripe.api_key = os.environ["STRIPE_CAPTURE_KEY"]
def create_and_confirm_payment(
amount_cents: int,
currency: str,
customer_id: str,
payment_method_id: str,
) -> stripe.PaymentIntent:
return stripe.PaymentIntent.create(
amount=amount_cents,
currency=currency,
customer=customer_id,
payment_method=payment_method_id,
confirm=True,
off_session=True, # agent-initiated, not browser session
)
5. Dispute handler (chargeback response)
This agent monitors for new disputes and submits evidence automatically. It needs Read on Disputes plus Write to submit evidence, and Read on Charges to look up the underlying transaction.
| Resource | Level | Why |
|---|---|---|
Disputes | Write | Submit evidence responses |
Charges | Read | Retrieve the disputed charge details |
Customers | Read | Pull customer purchase history as evidence |
Files | Write | Upload evidence documents (receipts, logs) |
| All other resources | None | Dispute agent has no reason to touch subscriptions or payment methods |
import stripe
import os
stripe.api_key = os.environ["STRIPE_DISPUTE_KEY"]
def submit_dispute_evidence(dispute_id: str, evidence: dict) -> stripe.Dispute:
"""
evidence dict example:
{
"customer_name": "Jane Smith",
"customer_email_address": "jane@example.com",
"product_description": "Annual subscription to ...",
"shipping_documentation": "file_xxx", # uploaded file ID
}
"""
return stripe.Dispute.modify(dispute_id, evidence=evidence, submit=True)
Testing that your restrictions actually work
Creating a restricted key in test mode and verifying that forbidden operations are rejected is the most important step that most guides skip. Here's how to do it properly in Python.
Stripe returns a stripe.error.PermissionError (HTTP 403) when a restricted key tries to access a resource it doesn't have permission for. Your test should assert this exception is raised — not just that the call doesn't succeed.
import stripe
import pytest
import os
# Use your test-mode restricted key
stripe.api_key = os.environ["STRIPE_ANALYTICS_KEY"] # read-only key
def test_analytics_key_cannot_create_refund():
"""Verify the analytics key is blocked from issuing refunds."""
with pytest.raises(stripe.error.PermissionError) as exc_info:
stripe.Refund.create(charge="ch_test_placeholder")
assert exc_info.value.http_status == 403
def test_analytics_key_can_list_charges():
"""Verify the analytics key can read charges."""
charges = stripe.Charge.list(limit=1)
assert isinstance(charges, stripe.ListObject)
def test_analytics_key_cannot_create_charge():
"""Verify the analytics key cannot initiate payments."""
with pytest.raises(stripe.error.PermissionError):
stripe.PaymentIntent.create(
amount=1000,
currency="usd",
)
rk_test_... key with the same permission set for CI verification.
Run these tests in CI whenever you rotate the restricted key. If a key rotation accidentally gives the new key broader permissions than the old one, the permission tests catch it before the agent goes to production.
Python-specific gotchas
Async agents and thread safety
Setting stripe.api_key at the module level is a global mutation. In async Python agents using asyncio, or in multi-threaded agents, two concurrent requests from different agent instances can race on this global. The fix is to always pass the key explicitly per-call:
import stripe
import asyncio
async def refund_charge(charge_id: str, key: str) -> stripe.Refund:
# Always pass api_key explicitly in async/multi-tenant contexts
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: stripe.Refund.create(charge=charge_id, api_key=key)
)
The stripe-python library is synchronous. For async agents, wrap calls in run_in_executor as above, or use a thread pool. The stripe-python async client (stripe.AsyncStripe) was introduced in stripe-python v7 and accepts the API key as a constructor argument, making it safe for concurrent use:
from stripe import AsyncStripe
async def refund_async(charge_id: str, key: str) -> stripe.Refund:
client = AsyncStripe(api_key=key)
return await client.refunds.create(charge=charge_id)
Environment variable naming
Give each agent archetype a distinct environment variable name. Mixing STRIPE_SECRET_KEY and STRIPE_RESTRICTED_KEY in the same codebase leads to agents accidentally using the wrong key after a config change. A naming convention that includes the role prevents this:
# Good: intent is unambiguous
STRIPE_REFUND_AGENT_KEY=rk_live_...
STRIPE_ANALYTICS_KEY=rk_live_...
STRIPE_SUBSCRIPTION_KEY=rk_live_...
# Bad: undifferentiated
STRIPE_API_KEY=rk_live_...
STRIPE_KEY=sk_live_... # mixed key types in one project
Handling 403s gracefully
In production, a 403 from a restricted key usually means one of two things: the permission set was misconfigured, or the agent is being asked to do something outside its designed role. Neither is recoverable by retrying. Log the error with the specific resource and permission level attempted, then raise — don't swallow.
import stripe
import logging
log = logging.getLogger(__name__)
def safe_create_refund(charge_id: str) -> stripe.Refund | None:
try:
return stripe.Refund.create(charge=charge_id)
except stripe.error.PermissionError as e:
log.error(
"Stripe permission denied",
extra={"charge_id": charge_id, "stripe_error": str(e)},
)
raise # don't retry; this is a configuration error, not a transient failure
except stripe.error.RateLimitError:
raise # caller should retry with exponential backoff
except stripe.error.StripeError as e:
log.error("Stripe API error", extra={"error": str(e)})
raise
The two gaps that restricted keys can't close
Stripe restricted keys do one thing well: they scope what API endpoints a key can call. What they cannot do — by design — is enforce spend volume or limit the blast radius once the permitted endpoint is accessible.
Gap 1: No per-day spend cap
A refund agent with Refunds: Write can issue $0 in refunds or $400,000 in refunds. The restricted key has no awareness of either. A stuck retry loop — common in agents that don't handle idempotency correctly — will keep issuing refunds until the agent crashes or someone notices. The restricted key does nothing to stop it.
stripe.Refund.create() call is wrapped in a retry loop. The LLM decides to refund a customer three times because it didn't see the first confirmation. Each call succeeds (the key has Write on Refunds). $300 in unintended refunds happens in under a second.
Gap 2: No sub-second revoke
Stripe restricted keys live in Stripe's system. Revoking one goes through the Stripe API, which has propagation delay measured in seconds. If an agent is running in a tight loop when you decide to revoke the key, some calls will go through before the revoke takes effect. For Stripe's own estimate, see the Stripe keys documentation.
A proxy layer in front of Stripe — receiving VAULT_KEY per-agent-run instead of the actual Stripe key — can revoke access sub-second at the proxy level without touching Stripe's API at all. The revoke happens before the call reaches Stripe, which is the only way to get true kill-switch behavior.
For a detailed breakdown of these two gaps and how to close them, see our post on why your Stripe Restricted Key probably isn't restricted enough. For the proxy approach: Keybrake sits at proxy.keybrake.com/stripe/v1/ and adds per-run spend caps and sub-second revoke on top of Stripe's own permission system.
Frequently asked questions
Can I create a Stripe restricted key programmatically with stripe-python?
Yes, via the Stripe API — but you need a secret key (not a restricted key) to create new restricted keys. The endpoint is POST /v1/restricted_keys. As of stripe-python v8, you can call stripe.restricted_keys.create() with a restrictions object that maps resource names to permission levels ("none", "read", or "write"). This is useful for agents that need to provision their own scoped keys at runtime.
What happens when a restricted key tries a forbidden operation?
Stripe returns HTTP 403 with error code api_key_expired or insufficient_permissions. In stripe-python, this raises stripe.error.PermissionError. Do not retry on a 403 — it will not resolve on its own. Log the specific resource and operation, then surface the error as a configuration issue to be fixed.
Should I use test-mode restricted keys in development?
Always. Create a rk_test_... key with the same permission set as your production rk_live_... key. Develop and test against the test key. Use the test key in CI. This ensures your permission set is validated before the key is used against real Stripe data.
Can one restricted key serve multiple Python agent instances?
Technically yes — Stripe doesn't limit how many processes use the same key simultaneously. But sharing a key across instances breaks per-agent audit attribution in the Stripe Dashboard. If something goes wrong, you cannot tell which instance made a call. The better pattern is one key per agent run or per user session, issued at startup and revoked on completion.
Does stripe-python cache the restricted key anywhere?
No. The stripe-python library sends the key as a Bearer token on every request — it doesn't cache responses tied to a specific key. Rotating the key by changing the environment variable and reinitializing stripe.api_key takes effect immediately on the next call. In long-running processes, you may want to reload the key from your secrets manager on each request to catch rotations without a process restart.
What's the difference between a restricted key and a webhook secret?
These are entirely separate. A Stripe restricted API key controls what operations your code can perform against the Stripe API. A webhook signing secret (whsec_...) is used to verify that incoming webhook events were actually sent by Stripe, not by an attacker replaying requests. They serve different security functions and are configured separately in the Dashboard.
Restricted keys + a spend cap layer
Stripe restricted keys scope what your agent can call. Keybrake adds what restricted keys can't — a per-run daily spend cap, a sub-second kill switch, and a per-call audit log with parsed cost. Point your Python agent at proxy.keybrake.com/stripe/v1/ instead of api.stripe.com and use a scoped vault_key instead of the restricted key directly.