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

Letta (formerly MemGPT) is a framework for building stateful AI agents with persistent memory — core memory that stays in context every step, recall memory drawn from the agent's message history, and archival memory for long-term storage. AI teams use Letta to build agents that carry state across sessions: billing agents that remember which customers they processed last month, audit agents that maintain running summaries in core memory, and multi-agent pipelines where a manager agent delegates tasks to specialised workers. When those agents call Stripe billing tools, Letta's stateful architecture introduces three specific failure modes that neither Stripe restricted keys nor a simple try/except block prevents.

This post covers all three failure modes with Python code, and the two-layer governance pattern — content-hash idempotency keys plus per-agent vault keys via a spend-cap proxy — that eliminates all three without restructuring your agent logic.

Failure mode 1: agent step retry re-executes the billing tool after a downstream exception removes evidence of the completed charge

Letta's agent executor runs a step as a single transaction: the LLM generates a response (possibly with tool calls), all tool calls are executed, tool results are appended to the message history, and the updated state is persisted. If a tool call raises an unhandled exception — or if the Letta server process encounters an error while persisting the updated message history after a successful tool call — the step can be retried from the beginning. When the step restarts, the LLM receives the same context it had before the original tool execution, with no record of the completed charge, and generates the same decision: call charge_customer again.

This failure mode is not hypothetical. Consider a billing tool that calls stripe.charges.create() and then writes the charge ID to a database. The Stripe charge succeeds, but the database write raises a DatabaseConnectionError. The tool function raises an exception. The Letta agent server catches it, records a tool error in the message history, and triggers a new agent step. The LLM's next step begins with no knowledge that ch_A was created — it sees only the tool call error message. Its next decision is to retry the billing tool. The retry calls stripe.charges.create() without an idempotency key. Stripe creates ch_B.

# billing_tools.py — UNSAFE: no idempotency key, DB failure triggers duplicate charge
import stripe
import os
from letta import create_client
from letta.schemas.tool import Tool

def charge_customer(customer_id: str, amount_cents: int, billing_period: str) -> str:
    """Charge a customer for the given billing period. Returns the Stripe charge ID."""
    stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

    # UNSAFE: no idempotency key.
    # If write_charge_record() raises below, the agent step fails.
    # The Letta runtime records a tool error and triggers a new step.
    # The LLM sees no evidence of ch_A and calls charge_customer again → ch_B.
    charge = stripe.charges.create(
        amount=amount_cents,
        currency="usd",
        customer=customer_id,
        description=f"Billing {billing_period}",
    )

    # DB write: if this raises, the tool raises, and the agent retries
    write_charge_record(customer_id, billing_period, charge["id"])
    return charge["id"]


def write_charge_record(customer_id: str, billing_period: str, charge_id: str) -> None:
    """Write charge to DB — can raise DatabaseConnectionError on timeout."""
    import sqlite3
    conn = sqlite3.connect(os.environ["BILLING_DB_PATH"])
    conn.execute(
        "INSERT INTO charges (customer_id, billing_period, charge_id) VALUES (?, ?, ?)",
        (customer_id, billing_period, charge_id),
    )
    conn.commit()

The fix is a content-hash idempotency key computed from the billing inputs before the Stripe call. This key is identical every time the agent calls charge_customer with the same inputs — whether on the first attempt or after three retries. When the agent retries after a database failure, stripe.charges.create() fires with the same idempotency key, and Stripe returns the original ch_A object without creating a new charge. The database write can then use an INSERT OR IGNORE pattern to safely handle the case where a partial write may have already succeeded.

# billing_tools.py — SAFE: content-hash idempotency key + upsert DB write
import stripe
import os
import hashlib
import sqlite3

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

def charge_customer(customer_id: str, amount_cents: int, billing_period: str) -> str:
    """Charge a customer for the given billing period. Returns the Stripe charge ID."""
    vault_key = os.environ["KEYBRAKE_VAULT_KEY"]  # per-agent vault key, not bare Stripe key

    # Idempotency key is stable across every agent step retry with the same inputs
    idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)

    stripe.api_key = vault_key
    stripe.api_base = "https://proxy.keybrake.com/stripe"

    try:
        charge = stripe.charges.create(
            amount=amount_cents,
            currency="usd",
            customer=customer_id,
            description=f"Billing {billing_period}",
            idempotency_key=idempotency_key,
        )
    except stripe.error.CardError as e:
        # Permanent failure — surface clearly so the agent doesn't retry indefinitely
        return f"ERROR: Card declined for {customer_id}: {e.user_message}"
    except stripe.error.InvalidRequestError as e:
        return f"ERROR: Invalid Stripe request for {customer_id}: {str(e)}"

    # INSERT OR IGNORE: safe to call multiple times if prior attempt partially succeeded
    conn = sqlite3.connect(os.environ["BILLING_DB_PATH"])
    conn.execute(
        "INSERT OR IGNORE INTO charges (customer_id, billing_period, charge_id) VALUES (?, ?, ?)",
        (customer_id, billing_period, charge["id"]),
    )
    conn.commit()
    return charge["id"]

Return error descriptions as strings rather than raising exceptions for permanent Stripe failures. A raised exception triggers Letta's error-handling loop and gives the LLM an opportunity to retry; a returned error string gives the LLM the information it needs to take a different action (escalate, skip this customer, notify) without triggering another tool call cycle. Transient errors — connection timeouts to the proxy, rate limits — should still raise, so the step retry mechanism fires with the same idempotency key and eventually succeeds.

Failure mode 2: multi-agent billing network fans out to N worker agents all sharing one unrestricted STRIPE_SECRET_KEY

Letta supports multi-agent networks where a manager agent coordinates multiple worker agents by sending messages via send_message_to_agent(). A common billing architecture: a manager agent receives the billing trigger and dispatches individual customer billing tasks to a pool of worker agents, each of which calls the Stripe billing tool for its assigned customer. If each worker agent is configured with STRIPE_SECRET_KEY as an environment variable in its tool runtime, all N workers share the same unrestricted key. A data error — an incorrect amount_cents value in the task payload — charges all N customers the wrong amount simultaneously. No worker can cap the others: each worker's environment is independent, but the Stripe key itself has no per-agent dollar limit. The manager agent learns of the data error only after all N charges have been processed.

# manager_agent_tools.py — UNSAFE: dispatches billing tasks with shared unrestricted key
from letta import create_client

def dispatch_billing_run(billing_period: str, customer_ids: list[str]) -> str:
    """Dispatch billing tasks to worker agents for the given period."""
    client = create_client()

    results = []
    for customer_id in customer_ids:
        # Each worker agent receives the billing task.
        # Worker agents use STRIPE_SECRET_KEY from env — no per-customer cap.
        # A data error in amount_cents charges all customers wrong amount before any result returns.
        resp = client.send_message_to_agent(
            agent_id=get_worker_agent_for(customer_id),
            message=f"Charge customer {customer_id} for billing period {billing_period}",
            role="user",
        )
        results.append(resp)

    return f"Dispatched {len(customer_ids)} billing tasks for {billing_period}"

The fix is for the manager agent to issue one vault key per worker agent before dispatching, each capped at that customer's expected charge amount plus a 10% buffer, scoped to POST /v1/charges only. The manager passes the vault key to the worker agent as part of the task message. Each worker uses its issued vault key — not the shared environment variable — for the Stripe call. If a data error causes an oversized amount, the vault key's dollar cap catches it at the proxy layer before the charge is created. The cap hit on one worker does not affect the others.

# manager_agent_tools.py — SAFE: per-worker vault keys issued before dispatch
import os
import json
import urllib.request
from letta import create_client

KEYBRAKE_API_KEY = os.environ["KEYBRAKE_API_KEY"]
KEYBRAKE_PROXY_URL = os.environ.get("KEYBRAKE_PROXY_URL", "https://proxy.keybrake.com")

def issue_vault_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
    """Issue a per-customer vault key capped at the expected charge amount + 10%."""
    daily_usd_cap = int((amount_cents * 1.1) / 100) + 1

    body = json.dumps({
        "vendor": "stripe",
        "daily_usd_cap": daily_usd_cap,
        "allowed_endpoints": ["POST /v1/charges"],
        "expires_in_seconds": 1800,
        "label": f"letta/{billing_period}/{customer_id}",
    }).encode()

    req = urllib.request.Request(
        f"{KEYBRAKE_PROXY_URL}/vault/keys",
        data=body,
        headers={
            "Authorization": f"Bearer {KEYBRAKE_API_KEY}",
            "Content-Type": "application/json",
        },
        method="POST",
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())["vault_key"]

def dispatch_billing_run(billing_period: str, customers: list[dict]) -> str:
    """Dispatch billing tasks to worker agents, each with its own capped vault key."""
    client = create_client()

    dispatched = 0
    for customer in customers:
        customer_id = customer["customer_id"]
        amount_cents = customer["amount_cents"]

        # Issue per-customer vault key before dispatch — any data error hits the cap
        vault_key = issue_vault_key(customer_id, amount_cents, billing_period)

        # Pass vault key in the task message so the worker uses it for the Stripe call
        resp = client.send_message_to_agent(
            agent_id=get_worker_agent_for(customer_id),
            message=(
                f"Charge customer {customer_id} for billing period {billing_period}. "
                f"Amount: {amount_cents} cents. "
                f"Use vault key: {vault_key}"
            ),
            role="user",
        )
        dispatched += 1

    return f"Dispatched {dispatched} billing tasks for {billing_period} with per-customer vault keys"

Worker agents must extract the vault key from the task message and use it to initialise the Stripe client — they must not fall back to the STRIPE_SECRET_KEY environment variable. In Letta's tool system, enforce this by having the charge_customer tool require a vault_key parameter and raise a ValueError if it is empty or begins with sk_ rather than kb_. This turns a misconfiguration (worker not receiving vault key from manager) into an immediate, visible error rather than a silent fallback to the unrestricted key.

Failure mode 3: recall memory eviction removes evidence of completed charges, triggering billing replay

Letta's memory architecture distinguishes between recall memory (the agent's message history, available as context in each step up to the context window limit) and archival memory (a vector store for long-term storage, queried by the agent with archival_memory_search()). In a long-running billing agent session — one that processes hundreds of customers across multiple rounds, or one that is interrupted and resumed — the message history grows beyond what fits in the context window. Letta truncates the active context, retaining only recent messages. The tool call result for an earlier customer — including the returned charge_id — may be dropped from the active context window.

When the agent receives a prompt that implies "process the remaining customers", it searches its current context for evidence of what work has already been done. If ch_A for cus_abc was returned in a tool message that is now outside the context window, the agent has no in-context evidence of that completed charge. It may decide that cus_abc still needs to be billed. The agent calls charge_customer again. With no idempotency key — and without checking archival memory or the database first — Stripe creates ch_B.

# UNSAFE billing agent pattern — relies on context window for charge tracking
# If context window truncates old tool results, agent replays completed charges.

# In the agent's system prompt / persona:
# "You are a billing agent. Process each customer in the list by calling charge_customer.
#  Track which customers you have billed by reviewing the conversation history."

# This is fragile: conversation history is truncated at context limit.
# An agent resumed after a long session will lose track of already-billed customers.

There are two complementary fixes. First, the charge_customer tool should write a completion record to archival memory immediately after a successful charge, and a companion is_already_billed tool should search archival memory before attempting any charge. This gives the agent a persistent, queryable record of billing completions that survives context window truncation.

# billing_tools.py — SAFE: archival memory for durable charge tracking
import stripe
import os
import hashlib

def is_already_billed(customer_id: str, billing_period: str) -> bool:
    """
    Check archival memory for a completed billing record.
    The agent should call this before charge_customer on every billing attempt.
    """
    # Letta provides archival_memory_search as a built-in tool;
    # here we simulate the pattern for illustration.
    # In practice, the agent calls archival_memory_search("billing_complete cus_abc 2026-07")
    # and inspects the results before deciding whether to charge.
    #
    # For application-level enforcement, query the billing DB directly:
    import sqlite3
    conn = sqlite3.connect(os.environ["BILLING_DB_PATH"])
    row = conn.execute(
        "SELECT charge_id FROM charges WHERE customer_id = ? AND billing_period = ?",
        (customer_id, billing_period),
    ).fetchone()
    return row is not None

def charge_customer_safe(
    customer_id: str,
    amount_cents: int,
    billing_period: str,
) -> str:
    """
    Charge a customer. Returns the charge ID if successful, or a status string.
    Checks for existing billing record first; uses idempotency key for all Stripe calls.
    """
    # Pre-flight: check DB before calling Stripe
    if is_already_billed(customer_id, billing_period):
        return f"ALREADY_BILLED: {customer_id} for {billing_period} — skipping"

    vault_key = os.environ["KEYBRAKE_VAULT_KEY"]
    idempotency_key = hashlib.sha256(
        f"{customer_id}:{amount_cents}:{billing_period}:letta-billing".encode()
    ).hexdigest()[:32]

    stripe.api_key = vault_key
    stripe.api_base = "https://proxy.keybrake.com/stripe"

    try:
        charge = stripe.charges.create(
            amount=amount_cents,
            currency="usd",
            customer=customer_id,
            description=f"Billing {billing_period}",
            idempotency_key=idempotency_key,
        )
    except stripe.error.CardError as e:
        return f"ERROR: Card declined for {customer_id}: {e.user_message}"

    # Persist to DB (INSERT OR IGNORE for idempotent writes)
    import sqlite3
    conn = sqlite3.connect(os.environ["BILLING_DB_PATH"])
    conn.execute(
        "INSERT OR IGNORE INTO charges (customer_id, billing_period, charge_id) VALUES (?, ?, ?)",
        (customer_id, billing_period, charge["id"]),
    )
    conn.commit()

    # Return a string the agent can store in archival memory if desired
    return f"CHARGED: {customer_id} for {billing_period} → {charge['id']}"

Second, in the agent's system prompt, instruct the agent to call is_already_billed (or the Letta built-in archival_memory_search) before every charge_customer call. This makes the pre-flight check part of the agent's reasoning loop rather than relying on the context window containing tool results from prior steps. Pair this with an application-level pre-flight check in the tool itself — belt and suspenders — so that even if the agent's prompt-following lapses under a long reasoning chain, the tool enforces uniqueness at the code layer.

Approach comparison

Approach Retry safe? Multi-agent cap? Memory-eviction safe? Per-agent audit? Mid-run revoke?
Bare key, no idempotency key No — every retry is a new charge No No No No
Content-hash idempotency key only Yes (within 24h Stripe window) No Partial — idempotency key prevents double charge but agent may still loop No No
Archival memory check only No — doesn't prevent duplicate Stripe calls No Yes — if agent follows the check No No
Stripe restricted key (no proxy) No No — endpoint scope only, no dollar cap No No No
Content-hash idem key + DB pre-flight + per-agent vault key via Keybrake Yes Yes — per-agent dollar cap Yes Yes Yes — single DELETE /vault/keys/{id}

pytest enforcement suite

# test_letta_billing.py
import hashlib
import pytest

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

def test_idempotency_key_stable_across_step_retries():
    key1 = billing_idempotency_key("cus_abc", 4999, "2026-07")
    key2 = billing_idempotency_key("cus_abc", 4999, "2026-07")
    assert key1 == key2
    assert len(key1) == 32

def test_idempotency_key_differs_across_customers():
    key_a = billing_idempotency_key("cus_abc", 4999, "2026-07")
    key_b = billing_idempotency_key("cus_xyz", 4999, "2026-07")
    assert key_a != key_b

def test_idempotency_key_differs_across_periods():
    key_jun = billing_idempotency_key("cus_abc", 4999, "2026-06")
    key_jul = billing_idempotency_key("cus_abc", 4999, "2026-07")
    assert key_jun != key_jul

def test_vault_key_not_bare_stripe_key(monkeypatch):
    monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_live_real_secret")
    monkeypatch.setenv("KEYBRAKE_VAULT_KEY", "kb_vault_test_abc123")
    import os
    vault_key = os.environ["KEYBRAKE_VAULT_KEY"]
    # Tool must use vault key (kb_ prefix) not bare Stripe key (sk_)
    assert vault_key.startswith("kb_"), "Worker agent must use vault key, not bare Stripe secret"
    assert vault_key != os.environ["STRIPE_SECRET_KEY"]

def test_is_already_billed_blocks_replay(tmp_path, monkeypatch):
    import sqlite3
    db_path = tmp_path / "billing.db"
    monkeypatch.setenv("BILLING_DB_PATH", str(db_path))
    conn = sqlite3.connect(str(db_path))
    conn.execute("CREATE TABLE charges (customer_id TEXT, billing_period TEXT, charge_id TEXT, PRIMARY KEY (customer_id, billing_period))")
    conn.execute("INSERT INTO charges VALUES ('cus_abc', '2026-07', 'ch_already')")
    conn.commit()

    conn2 = sqlite3.connect(str(db_path))
    row = conn2.execute("SELECT charge_id FROM charges WHERE customer_id = ? AND billing_period = ?", ("cus_abc", "2026-07")).fetchone()
    # is_already_billed must return True — prevents replay of completed charge
    assert row is not None, "Pre-flight check must detect existing charge and block replay"

Gap analysis

1. Core memory mutation during billing loop exposes billing state to LLM reasoning errors

Letta's core memory is always in context and can be edited by the agent via core_memory_replace(). Some billing agent designs use core memory to track a billing run's status — writing "billing run 2026-07 in progress" at start and "completed" at finish. If the agent's reasoning encounters an ambiguous state ("in progress" from a previous crashed session), it may decide to restart the billing run from the beginning rather than resume from where it left off. Do not use core memory as the authoritative billing state store. Use the billing database as the source of truth, and limit core memory to high-level agent persona context. The agent should query the DB (via is_already_billed) rather than reading its own core memory to determine billing status.

2. Letta server's max_steps budget exhaustion may leave billing partially complete

Letta's agent runner accepts a max_steps parameter that limits the number of agent steps per invocation. If a billing run for 50 customers requires 100 steps (two steps per customer: one to call is_already_billed, one to call charge_customer) but max_steps is set to 80, the agent stops after 40 customers with no error — it simply reaches the step budget. The billing run appears to have "completed" to the caller but has left 10 customers uncharged. Set max_steps to a value that covers the full customer cohort, or break large billing runs into smaller batches at the manager level with each batch dispatched to a separate agent invocation.

3. Concurrent send_message_to_agent() calls can create duplicate step execution

Letta's server serialises steps within a single agent (one step at a time per agent). However, if the manager agent sends the same billing task to the same worker agent twice — from two concurrent manager invocations, or from a retry in the manager's own step loop — the worker agent may queue both messages and execute both steps. Each step triggers a charge_customer call. Content-hash idempotency keys prevent the second charge at the Stripe layer, but the worker's message history will contain two completed billing tool calls for the same customer in the same period, creating duplicate audit records. Use a distributed lock keyed on (worker_agent_id, billing_period) at the manager level to prevent concurrent dispatch of the same billing task to the same worker.

4. Vault key value in agent message history appears in Letta's message log

When the manager agent passes a vault key to a worker agent as part of a task message — "Use vault key: kb_vault_abc123..." — that value is stored in the worker agent's message history in Letta's database. If the Letta server is self-hosted, this key appears in the agent's recall memory and is accessible via the Letta REST API to any caller with API access. Treat vault keys as short-lived credentials (set expires_in_seconds to 30 minutes) and revoke them immediately after the billing run completes. Alternatively, pass only the vault key ID in the agent message and have the worker tool fetch the key value from Secrets Manager at execution time, keeping the raw key value out of Letta's message store entirely.

Put the brakes on your Letta billing agents

Keybrake issues per-agent vault keys before multi-agent dispatch, enforces dollar caps atomically before any Stripe call reaches the API, and logs every charge with the Letta agent ID and billing period. One proxy endpoint swap in your billing tool — no agent architecture changes required.

Frequently asked questions

Does Letta's built-in archival_memory_search tool replace the billing database pre-flight check?

No — archival_memory_search is a semantic vector search over the agent's memory store. It returns relevant memories based on embedding similarity, not an exact lookup by structured key. A query for "billed cus_abc 2026-07" might return a memory from a different period that mentions the same customer, or might miss the exact billing record if the embedding distance is above the retrieval threshold. For billing decisions, use a structured database query (SELECT by customer_id + billing_period) as the authoritative pre-flight check. Use archival_memory_search as a supplement for the agent's reasoning, not as the enforcement layer.

Can I use a Letta agent's agent_id as the Stripe idempotency key?

No. The agent_id identifies the agent instance, not the specific billing action. A single billing agent may charge many customers across many periods — using agent_id as the idempotency key makes all charges by the same agent collide at the Stripe layer after the first one. The idempotency key must encode the specific charge: customer ID, amount, and billing period, combined with a workflow-specific salt. The content-hash pattern — SHA-256(customer_id:amount_cents:billing_period:letta-billing)[:32] — produces a key that is unique per charge and stable across every retry of the same charge.

How do I handle the case where a Letta agent is interrupted mid-billing-run and resumed in a new session?

When a Letta agent session ends (server restart, timeout, or explicit close) and is resumed in a new invocation, the agent starts from its persisted memory state. If the previous session billed 30 of 50 customers before being interrupted, the new session must determine which 20 remain. The correct approach: the manager agent calls a get_unbilled_customers(billing_period) tool that queries the billing database directly and returns only customers who do not have a completed charge record. The agent then processes only the returned list. Do not rely on the agent reconstructing the set of remaining customers from its recall memory — truncation and session boundaries make this unreliable.

What happens if the Keybrake proxy is unreachable when the billing tool runs?

If the proxy is unreachable, stripe.charges.create() throws a network exception (connection refused or timeout). The tool raises, the Letta agent records a tool error in message history, and the agent step fails. The agent will likely retry the tool call in the next step. Because no Stripe charge was created — the proxy was unreachable before forwarding the request — this retry is safe. Monitor proxy health via its /health endpoint from your infrastructure. If the proxy is down for an extended period, the agent's max_steps budget will be exhausted retrying the tool without progress; set up an alert on the proxy health endpoint so you catch outages before they burn agent step budgets.

Can Letta's tool_rules prevent the agent from calling charge_customer more than once per customer per session?

Letta's ToolRule system can constrain which tools are available based on agent state. For example, an InitToolRule can make is_already_billed the first tool called, and a TerminalToolRule can end the step sequence after charge_customer returns a successful charge ID. However, tool rules operate within a single agent step sequence and do not persist across session boundaries or across multiple agent invocations. They are a useful guardrail for single-session billing flows, but cannot replace the database pre-flight check for resumed sessions or multi-session billing runs.

Should each Letta worker agent get its own vault key, or should vault keys be shared across a worker pool?

Each worker agent should get its own vault key, scoped to the specific customer and billing period it is processing. A shared vault key across a worker pool cannot enforce per-customer spend caps: if the pool-level key has a $500 daily cap and a data error causes one worker to attempt a $400 charge, only $100 of cap remains for the other workers in the pool. Per-customer vault keys — each capped at that customer's expected charge plus 10% — ensure that a data error or runaway agent affects at most one customer's billing, and the impact is bounded by that customer's cap, not by how much budget the pool happens to have left.

Further reading