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_...)
Naming convention: Stripe restricted keys start with 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.

ResourceLevelWhy
ChargesReadLook up the original charge to refund
RefundsWriteCreate refunds against existing charges
CustomersReadVerify customer identity before refunding
Payment IntentsNoneNo new payment creation needed
All other resourcesNoneLeast-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.

ResourceLevelWhy
ChargesReadQuery transaction history
CustomersReadJoin customer records to charges
InvoicesReadSubscription billing detail
SubscriptionsReadMRR, churn, plan distribution
RefundsReadNet revenue calculation
Balance TransactionsReadSettled amounts, fees
All Write permissionsNoneAnalytics 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.

ResourceLevelWhy
SubscriptionsWriteCancel, update, create subscriptions
CustomersReadLook up subscriber identity
InvoicesReadCheck payment status before acting
CouponsReadValidate coupon codes before applying
ChargesNoneSubscription 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.

ResourceLevelWhy
Payment IntentsWriteCreate and confirm payments
CustomersReadLook up stored payment methods
Payment MethodsReadList saved cards for confirmation
ChargesReadVerify charge outcome after capture
RefundsNoneCapture agent must not refund
SubscriptionsNonePayment 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.

ResourceLevelWhy
DisputesWriteSubmit evidence responses
ChargesReadRetrieve the disputed charge details
CustomersReadPull customer purchase history as evidence
FilesWriteUpload evidence documents (receipts, logs)
All other resourcesNoneDispute 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",
        )
Use test-mode keys for these tests. Never run permission verification tests against a production restricted key — even if the 403s fire correctly, some calls (like retrieving charges) will hit real data. Create a parallel 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.

What the gap looks like in Python: Your 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.