Agent Governance

Amazon Bedrock Agents Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

Amazon Bedrock Agents makes it straightforward to build Stripe-capable agents using action groups backed by Lambda functions. The risk surfaces in three specific places: the Bedrock Agents runtime retrying a failed Lambda invocation with identical action arguments (creating a duplicate Stripe charge if it already completed before the timeout); session context accumulation causing the agent to replay billing operations on ambiguous follow-up turns in the same sessionId; and multi-agent supervisor–collaborator setups producing double charges when the supervisor doesn't receive confirmation from the billing sub-agent.

This post covers all three failure modes specific to Amazon Bedrock Agents and the two-layer governance pattern that closes each one: a restricted Stripe API key as a first layer, and per-run vault keys via a spend-cap proxy as a second.

The standard Amazon Bedrock Agents Stripe pattern

The standard pattern for a Stripe-capable Bedrock agent defines an action group with an OpenAPI schema, backs it with a Lambda function, and invokes the agent via the bedrock-agent-runtime client. The Lambda receives an apiPath and parameters, executes the Stripe call, and returns a response body the agent uses to continue the conversation:

# Lambda function for the Stripe action group
import json, os, stripe

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]  # sk_live_...

def lambda_handler(event, context):
    action_group = event.get("actionGroup")
    api_path     = event.get("apiPath")
    params       = {p["name"]: p["value"]
                    for p in event.get("parameters", [])}

    if api_path == "/charge":
        charge = stripe.Charge.create(
            amount=int(params["amount_cents"]),
            currency="usd",
            customer=params["customer_id"],
            description=f"Subscription {params['billing_period']}",
            # No idempotency_key — retry scenario creates second charge
        )
        body = json.dumps({"charge_id": charge.id, "status": charge.status})
    else:
        body = json.dumps({"error": "unknown_path"})

    return {
        "messageVersion": "1.0",
        "response": {
            "actionGroup": action_group,
            "apiPath":     api_path,
            "httpMethod":  event.get("httpMethod", "POST"),
            "httpStatusCode": 200,
            "responseBody": {"application/json": {"body": body}},
        }
    }


# Caller: invoke the agent via bedrock-agent-runtime
import boto3, uuid

bedrock_runtime = boto3.client("bedrock-agent-runtime", region_name="us-east-1")

response = bedrock_runtime.invoke_agent(
    agentId="AGENTID1234",
    agentAliasId="TSTALIASID",
    sessionId=str(uuid.uuid4()),
    inputText="Charge customer cus_Abc123 $29 for the June plan",
)

# Stream the response
for event in response["completion"]:
    if "chunk" in event:
        print(event["chunk"]["bytes"].decode())

This works correctly in the happy path. Three distinct failure modes emerge when the Bedrock Agents runtime retries a Lambda invocation, when a resumed session contains prior billing context, or when multi-agent collaboration produces a duplicate action call from the supervisor.

Failure mode 1: Lambda retry on invocation failure fires duplicate charge

Amazon Bedrock Agents calls your action group Lambda function synchronously. If the Lambda times out, throws an unhandled exception, or returns an error response, the Bedrock Agents runtime may retry the invocation with identical parameters. The Stripe charge may have already completed before the Lambda returned an error — for example, the Lambda successfully created the charge but then hit an unexpected exception in the JSON serialization of the response, or the Lambda returned before the 30-second agent timeout but after the Stripe call. The retry fires a second stripe.Charge.create() with no idempotency key:

import json, os, stripe
from botocore.exceptions import ClientError

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

def lambda_handler(event, context):
    params = {p["name"]: p["value"] for p in event.get("parameters", [])}

    # stripe.Charge.create() may succeed here — charge fires against Stripe
    charge = stripe.Charge.create(
        amount=int(params["amount_cents"]),
        currency="usd",
        customer=params["customer_id"],
        description=f"Subscription {params['billing_period']}",
    )

    # If an unhandled exception occurs after the Stripe call (or the Lambda
    # times out after Stripe responded but before Lambda returned), the Bedrock
    # Agents runtime sees an invocation failure and retries the action.
    # Second Lambda call fires stripe.Charge.create() again — duplicate charge.
    result = process_charge_result(charge)   # imagined post-processing
    return build_response(event, result)

What breaks: The Bedrock Agents runtime retries a Lambda invocation it considers failed — timeout, unhandled exception, or a non-2xx httpStatusCode in the response body. The second invocation carries identical parameters (same customer_id, amount_cents, billing_period). Inside the Lambda, stripe.Charge.create() runs again with no idempotency key. Stripe creates a second charge. The customer is double-billed for the same period. This is indistinguishable from a normal successful charge in the Stripe Dashboard without explicit idempotency key tracking.

The fix: compute a content-hash idempotency key from the action parameters before the Stripe call. The key is identical across all retries because the parameters are identical — Stripe deduplicates them and returns the original charge object:

import hashlib, json, os, stripe

PROXY_URL  = "https://proxy.keybrake.com"
VAULT_KEY  = os.environ["KEYBRAKE_VAULT_KEY_BILLING"]  # POST /v1/charges only

def lambda_handler(event, context):
    params = {p["name"]: p["value"] for p in event.get("parameters", [])}
    action_group = event.get("actionGroup")
    api_path     = event.get("apiPath")

    customer_id    = params["customer_id"]
    amount_cents   = int(params["amount_cents"])
    billing_period = params["billing_period"]

    idempotency_key = hashlib.sha256(
        f"{customer_id}:{amount_cents}:{billing_period}".encode()
    ).hexdigest()[:32]

    stripe_client = stripe.StripeClient(
        api_key=VAULT_KEY,
        base_url=PROXY_URL + "/stripe/",
    )

    try:
        charge = stripe_client.charges.create(params={
            "amount":          amount_cents,
            "currency":        "usd",
            "customer":        customer_id,
            "description":     f"Subscription {billing_period}",
            "idempotency_key": idempotency_key,
        })
        body = json.dumps({"charge_id": charge.id, "status": charge.status})
        status_code = 200
    except stripe.StripeError as e:
        body = json.dumps({"error": str(e), "idempotency_key": idempotency_key})
        status_code = 400

    return {
        "messageVersion": "1.0",
        "response": {
            "actionGroup":    action_group,
            "apiPath":        api_path,
            "httpMethod":     event.get("httpMethod", "POST"),
            "httpStatusCode": status_code,
            "responseBody":   {"application/json": {"body": body}},
        }
    }

What this fixes: The idempotency key is derived from (customer_id, amount_cents, billing_period) — the same action parameters the Bedrock Agents runtime passes on every retry invocation. Whether the Lambda runs once, twice, or five times for the same billing operation, Stripe deduplicates all requests with the same key and returns the original charge object. The vault key routes the call through the spend-cap proxy, enforcing the daily cap and writing an audit log entry. Returning the StripeError as a 400 JSON response (rather than a 500 or unhandled exception) signals to the Bedrock Agents runtime that the error is final — the model can reason about it and escalate rather than triggering another retry cycle.

Failure mode 2: Session context replay on follow-up turns

Amazon Bedrock Agents tracks conversation state via a sessionId. Multiple invoke_agent() calls with the same sessionId give the agent access to the prior conversation turns, including completed action group calls and their results. On ambiguous follow-up messages in the same session, the agent may interpret prior completed billing operations as instructions to re-bill:

import boto3, uuid

bedrock_runtime = boto3.client("bedrock-agent-runtime", region_name="us-east-1")
session_id = "billing-session-cus-abc123-june"  # Reused across turns

# Turn 1: original billing instruction
resp1 = bedrock_runtime.invoke_agent(
    agentId="AGENTID1234",
    agentAliasId="TSTALIASID",
    sessionId=session_id,
    inputText="Charge customer cus_Abc123 $29 for the June plan",
)
# Agent called /charge action — Stripe charge ch_xxx created. Session records this.

# Turn 2 (same session): customer service follow-up
resp2 = bedrock_runtime.invoke_agent(
    agentId="AGENTID1234",
    agentAliasId="TSTALIASID",
    sessionId=session_id,
    inputText="The customer says they didn't receive a receipt — can you sort this out?",
)
# Agent has context of prior /charge call in session history.
# "Sort this out" is ambiguous — agent may re-call /charge with the same parameters.
# Lambda receives identical action group invocation. Second Stripe charge fires.

What breaks: The Bedrock Agents session context contains the full exchange from Turn 1: the user's billing instruction, the agent's decision to call the /charge action, the Lambda's response with charge_id, and the agent's confirmation message. Turn 2's "sort this out" is ambiguous — it could mean "look up the charge and resend the receipt" or "re-run the billing because it may have failed." The agent, seeing the prior /charge call succeeded in context, may decide the right action is to call /charge again. The Lambda runs again with identical parameters. No idempotency key → second charge. Customer is billed twice in June.

Three controls work together: idempotency keys make the Lambda safe regardless of re-execution; a separate read-only /charge-status action steers the agent toward status checks instead of re-billing; and a billing vault key that allows only POST /v1/charges combined with an audit vault key that allows only GET /v1/charges enforces the separation at the proxy layer regardless of which action the model calls:

import hashlib, json, os, stripe

PROXY_URL   = "https://proxy.keybrake.com"
BILLING_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"]  # POST /v1/charges only
AUDIT_KEY   = os.environ["KEYBRAKE_VAULT_KEY_AUDIT"]    # GET /v1/charges only

def lambda_handler(event, context):
    params   = {p["name"]: p["value"] for p in event.get("parameters", [])}
    api_path = event.get("apiPath")

    if api_path == "/charge":
        idempotency_key = hashlib.sha256(
            f"{params['customer_id']}:{params['amount_cents']}:{params['billing_period']}".encode()
        ).hexdigest()[:32]

        stripe_client = stripe.StripeClient(
            api_key=BILLING_KEY, base_url=PROXY_URL + "/stripe/"
        )
        try:
            charge = stripe_client.charges.create(params={
                "amount":          int(params["amount_cents"]),
                "currency":        "usd",
                "customer":        params["customer_id"],
                "description":     f"Subscription {params['billing_period']}",
                "idempotency_key": idempotency_key,
            })
            body = json.dumps({"charge_id": charge.id, "status": charge.status})
        except stripe.StripeError as e:
            body = json.dumps({"error": str(e)})

    elif api_path == "/charge-status":
        # Read-only: look up a charge by ID. Routes through audit vault key.
        audit_client = stripe.StripeClient(
            api_key=AUDIT_KEY, base_url=PROXY_URL + "/stripe/"
        )
        try:
            charge = audit_client.charges.retrieve(params["charge_id"])
            body = json.dumps({
                "charge_id":   charge.id,
                "status":      charge.status,
                "amount":      charge.amount,
                "description": charge.description,
            })
        except stripe.StripeError as e:
            body = json.dumps({"error": str(e)})

    else:
        body = json.dumps({"error": "unknown_path"})

    return {
        "messageVersion": "1.0",
        "response": {
            "actionGroup":    event.get("actionGroup"),
            "apiPath":        api_path,
            "httpMethod":     event.get("httpMethod", "POST"),
            "httpStatusCode": 200,
            "responseBody":   {"application/json": {"body": body}},
        }
    }

What this fixes: When Turn 2 arrives with a receipt-inquiry message, the agent calls /charge-status (read-only, routed through the audit vault key) to look up the charge rather than calling /charge again. If the agent mistakenly calls /charge using the audit vault key, the proxy rejects the POST with 403. When /charge is correctly called with the billing vault key, the content-hash idempotency key collapses any duplicate invocations to one charge at Stripe. The two vault keys enforce intent separation at the proxy layer independent of what the model decides to call.

Failure mode 3: Multi-agent supervisor fires duplicate charge on collaborator failure

Amazon Bedrock Agents multi-agent collaboration allows a supervisor agent to orchestrate collaborator sub-agents via agent aliases. The supervisor delegates tasks to specialist sub-agents (e.g., a billing sub-agent with the /charge action group). If the supervisor doesn't receive a successful response from the billing collaborator — due to timeout, invocation failure, or an ambiguous collaborator response — it may attempt to execute the billing directly through its own action group, assuming the collaborator failed:

import boto3

bedrock_runtime = boto3.client("bedrock-agent-runtime", region_name="us-east-1")

# Supervisor agent: has both a billing collaborator AND its own /charge action group
# (common pattern when supervisor "backs up" collaborators with direct capability)
response = bedrock_runtime.invoke_agent(
    agentId="SUPERVISOR_AGENT_ID",    # Supervisor agent
    agentAliasId="SUPERVISOR_ALIAS",
    sessionId="billing-run-001",
    inputText="Process the June subscription for cus_Abc123 at $29",
)

# Supervisor delegates to BILLING_COLLABORATOR_AGENT_ID for /charge action.
# If the collaborator returns an ambiguous response or times out, the supervisor
# may call its own backup /charge action group — second Lambda invocation fires.
# Both the collaborator's Lambda and the supervisor's Lambda call stripe.Charge.create()
# with the same parameters. No idempotency key → two charges appear in Stripe.

What breaks: When a supervisor agent has a collaborator sub-agent for billing AND its own action group that includes billing capability (common in "resilient" multi-agent designs), a timeout or non-conclusive response from the collaborator can lead the supervisor to attempt the billing itself. The collaborator's Lambda already fired the Stripe charge. The supervisor's Lambda fires it again with identical parameters and no idempotency key. Stripe creates a second charge. The customer is double-billed. The supervisor's audit trail shows a "successful" charge from both agents, making reconciliation difficult.

The architectural fix is principle-of-least-privilege at the vault key level: only the billing collaborator sub-agent has a vault key that allows POST /v1/charges. The supervisor agent has only an audit vault key. If the supervisor tries to charge directly, the proxy rejects it with 403. The idempotency key in the collaborator's Lambda is the backstop for cases where the supervisor retries the collaborator with the same task:

import hashlib, json, os, stripe

PROXY_URL = "https://proxy.keybrake.com"

# Billing collaborator Lambda — has BILLING vault key (POST /v1/charges only)
BILLING_KEY = os.environ["KEYBRAKE_VAULT_KEY_BILLING"]

# Supervisor Lambda — has AUDIT vault key only (GET /v1/charges only)
# If supervisor tries to call POST /v1/charges, proxy returns 403
AUDIT_KEY = os.environ["KEYBRAKE_VAULT_KEY_AUDIT"]


def billing_collaborator_handler(event, context):
    """Lambda for the billing sub-agent. Only this function can create charges."""
    params = {p["name"]: p["value"] for p in event.get("parameters", [])}

    idempotency_key = hashlib.sha256(
        f"{params['customer_id']}:{params['amount_cents']}:{params['billing_period']}".encode()
    ).hexdigest()[:32]

    stripe_client = stripe.StripeClient(
        api_key=BILLING_KEY,
        base_url=PROXY_URL + "/stripe/",
    )
    try:
        charge = stripe_client.charges.create(params={
            "amount":          int(params["amount_cents"]),
            "currency":        "usd",
            "customer":        params["customer_id"],
            "description":     f"Subscription {params['billing_period']}",
            "idempotency_key": idempotency_key,
        })
        body = json.dumps({
            "charge_id":       charge.id,
            "status":          charge.status,
            "idempotency_key": idempotency_key,
        })
    except stripe.StripeError as e:
        body = json.dumps({"error": str(e), "idempotency_key": idempotency_key})

    return {
        "messageVersion": "1.0",
        "response": {
            "actionGroup":    event.get("actionGroup"),
            "apiPath":        "/charge",
            "httpMethod":     "POST",
            "httpStatusCode": 200,
            "responseBody":   {"application/json": {"body": body}},
        }
    }


def supervisor_lookup_handler(event, context):
    """Supervisor Lambda — audit key only. POST /v1/charges rejected by proxy."""
    params = {p["name"]: p["value"] for p in event.get("parameters", [])}

    audit_client = stripe.StripeClient(
        api_key=AUDIT_KEY,
        base_url=PROXY_URL + "/stripe/",
    )
    try:
        charge = audit_client.charges.retrieve(params["charge_id"])
        body = json.dumps({"charge_id": charge.id, "status": charge.status})
    except stripe.StripeError as e:
        body = json.dumps({"error": str(e)})

    return {
        "messageVersion": "1.0",
        "response": {
            "actionGroup":    event.get("actionGroup"),
            "apiPath":        "/charge-status",
            "httpMethod":     "GET",
            "httpStatusCode": 200,
            "responseBody":   {"application/json": {"body": body}},
        }
    }

What this fixes: The billing collaborator is the only Lambda with a billing vault key that allows POST /v1/charges. If the supervisor tries to charge directly — whether through deliberate design or unexpected orchestration — the proxy rejects the POST with 403. The collaborator's idempotency key protects against the supervisor retrying the collaborator with the same task: the second invoke_agent call to the collaborator produces an identical Lambda invocation, and the content-hash idempotency key makes Stripe return the original charge. The supervisor's audit key lets it verify the charge status without creating a new one.

One-line proxy override

The Keybrake proxy is compatible with the Stripe Python SDK's StripeClient interface. Switching a Bedrock Agents Lambda from direct Stripe calls to the proxy requires changing one line in your action group function:

# Before — direct to Stripe
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
charge = stripe.Charge.create(amount=2900, currency="usd", customer="cus_Abc123")

# After — routes through Keybrake proxy, enforces spend cap, writes audit log
from stripe import StripeClient
stripe_client = StripeClient(
    api_key=os.environ["KEYBRAKE_VAULT_KEY"],
    base_url="https://proxy.keybrake.com/stripe/",
)
charge = stripe_client.charges.create(
    params={"amount": 2900, "currency": "usd", "customer": "cus_Abc123"}
)

No changes to the Bedrock Agents configuration — agent definition, action group schema, Lambda ARN, or invoke_agent() calls — are needed. Only the Stripe call inside the Lambda changes.

Comparison: raw key vs restricted key vs vault key

Property Raw sk_live_ key Restricted Stripe key Vault key (Keybrake proxy)
Endpoint allowlist No — full API access Partial — Stripe-enforced resource set Yes — per-role allowlist, proxy-enforced
Daily spend cap No No Yes — configurable per vault key
Per-agent isolation No — all Lambda functions share one key No — still shared across action groups Yes — one vault key per Lambda or per-agent role
Lambda retry guard No — retry re-invokes Lambda, duplicate charge No — application-level problem Yes — with idempotency key in Lambda handler
Session context guard No — session history can re-trigger billing No — application-level problem Partial — read-only audit key prevents re-billing on lookup calls
Audit trail Stripe Dashboard only Stripe Dashboard only Yes — per-call log at proxy layer, queryable
Kill switch Rotate key (affects all Lambdas) Rotate key (affects all Lambdas) Revoke vault key (scoped to one Lambda or agent role)

pytest enforcement suite

import pytest, hashlib, json, os, stripe

PROXY_URL   = "https://proxy.keybrake.com"
BILLING_KEY = os.environ.get("KEYBRAKE_VAULT_KEY_BILLING", "")
AUDIT_KEY   = os.environ.get("KEYBRAKE_VAULT_KEY_AUDIT", "")

def test_billing_vault_key_not_live():
    """Billing vault key must never be a raw Stripe live key."""
    assert not BILLING_KEY.startswith("sk_live_"), (
        "KEYBRAKE_VAULT_KEY_BILLING must be a vault key, not sk_live_"
    )

def test_stripe_client_uses_proxy():
    """StripeClient must route through the Keybrake proxy, not api.stripe.com."""
    client = stripe.StripeClient(api_key=BILLING_KEY, base_url=PROXY_URL + "/stripe/")
    assert PROXY_URL in client.base_url, (
        "StripeClient base_url must point at proxy.keybrake.com"
    )

def test_idempotency_key_is_deterministic():
    """Same (customer, amount, period) must produce the same idempotency key."""
    def make_key(customer_id, amount_cents, billing_period):
        return hashlib.sha256(
            f"{customer_id}:{amount_cents}:{billing_period}".encode()
        ).hexdigest()[:32]

    key1 = make_key("cus_Abc123", 2900, "2026-06")
    key2 = make_key("cus_Abc123", 2900, "2026-06")
    assert key1 == key2, "Idempotency key must be deterministic for same inputs"

def test_different_periods_get_different_keys():
    """Same customer, same amount, different billing period must get different keys."""
    def make_key(customer_id, amount_cents, billing_period):
        return hashlib.sha256(
            f"{customer_id}:{amount_cents}:{billing_period}".encode()
        ).hexdigest()[:32]

    key_june = make_key("cus_Abc123", 2900, "2026-06")
    key_july = make_key("cus_Abc123", 2900, "2026-07")
    assert key_june != key_july, "Different billing periods must produce different idempotency keys"

def test_audit_key_cannot_create_charges(monkeypatch):
    """Audit vault key must be rejected for POST /v1/charges — proxy returns 403."""
    import httpx

    def mock_request(*args, **kwargs):
        return httpx.Response(403, json={"error": "vault_key_not_authorized"})

    monkeypatch.setattr(httpx, "request", mock_request)
    client = stripe.StripeClient(api_key=AUDIT_KEY, base_url=PROXY_URL + "/stripe/")
    with pytest.raises(stripe.PermissionError):
        client.charges.create(
            params={"amount": 2900, "currency": "usd", "customer": "cus_test"}
        )

Gap analysis

Lambda concurrency and duplicate invocations. Bedrock Agents invokes action group Lambdas synchronously (RequestResponse invocation type). If the same agent processes two concurrent invoke_agent() calls with the same sessionId and the same billing instruction (e.g., a retry from the caller's side while the first invocation is still running), two Lambda executions may race to call stripe.Charge.create(). The content-hash idempotency key handles this: Stripe's idempotency key lock ensures only one charge is created even if both Lambdas reach Stripe within the idempotency window (30 minutes for Stripe). Use the same idempotency key derivation scheme across all Lambdas in the same action group.

Knowledge base retrieval inflating billing context. When a Bedrock agent uses a knowledge base alongside an action group, the retrieved documents may contain billing-related context — for example, a knowledge base of past billing records might return a summary like "cus_Abc123 was charged $29 in May." The agent may interpret this retrieved context as an incomplete billing operation and call the /charge action for the current billing period. This is distinct from session context replay but has the same fix: a read-only /charge-status action gives the model a way to verify the current state without creating a new charge, and the billing vault key's endpoint allowlist prevents accidental charges from knowledge-base-informed conclusions.

Agent alias versioning and action group schema changes. Bedrock Agents supports multiple agent aliases pointing to different versions. If a new agent version changes the action group schema (e.g., renames billing_period to period), and the caller uses an old alias, the Lambda may receive parameters in the old schema format. If the idempotency key derivation includes the field name as part of the hash input, schema changes will produce different idempotency keys for the same billing operation — causing the same charge to be created twice (once per schema version). Use stable, normalized identifiers (customer ID + amount + period value, not field names) as idempotency key inputs.

Bedrock Agents session state TTL. Bedrock Agents session context expires after the configured idle TTL (default: 600 seconds). A session that times out before a follow-up message arrives starts fresh — the agent has no memory of the prior billing turn. This means the follow-up message gets an agent with no context of the prior charge, which may actually prevent the session-replay billing failure. However, it also means customer service lookups in a new session won't have the prior charge ID in context. Store charge IDs in your own database keyed to customer ID and billing period — don't rely on Bedrock Agents session context as a source of truth for billing history.

Cross-region agent invocation and eventual consistency. When invoking Bedrock Agents across AWS regions (e.g., a global billing system with US-East and EU-West agent deployments), two independent invoke_agent() calls for the same customer may both succeed at the agent level but race at the Stripe level. The content-hash idempotency key closes this race: Stripe's idempotency mechanism is globally consistent and will deduplicate requests with the same key regardless of which AWS region the Lambda ran in. Ensure the idempotency key derivation schema (field order, encoding) is identical across all regional Lambda deployments.

Frequently asked questions

Does Amazon Bedrock Agents automatically deduplicate action group invocations?

No. Bedrock Agents orchestrates which action groups to invoke and passes the parameters, but it has no awareness of what happens inside your Lambda — including whether a Stripe charge was already created. Idempotency key management is entirely the responsibility of the Lambda function. The Bedrock Agents runtime retries at the invocation level (not the Stripe level), so the deduplication must live in your code, not in the agent configuration.

When should I use a new sessionId vs reuse an existing one?

Use a new sessionId per billing operation or per customer interaction session. Reusing a sessionId across billing periods gives the agent access to prior billing history, which is the source of session-context replay failures. If you need the agent to have context of prior interactions, pass a summarized system prompt with the relevant facts (e.g., "cus_Abc123 was charged $29 for May, charge ID ch_xxx") rather than reusing the session where the original charge occurred. The agent can then call /charge-status to verify the prior charge rather than re-billing.

How does the Lambda retry differ from the invoke_agent() caller retry?

The Bedrock Agents runtime retries the Lambda invocation internally when the Lambda times out or returns an error. This is independent of any retry logic the invoke_agent() caller applies. A caller retry (e.g., your billing service retrying the invoke_agent() call because the agent didn't respond in time) creates a new agent run with the same input — the agent will call the Lambda again. Both retry layers produce identical action parameters in the Lambda. The content-hash idempotency key handles both: the key is derived from parameters, not from a request-time UUID, so it's stable across Lambda retries and invoke_agent() retries.

Can I use the same Lambda for both the billing collaborator and the supervisor?

You can, but you shouldn't. Using one Lambda with a single Stripe key for both supervisor and collaborator roles means a supervisor attempting to charge directly (the failure mode described above) can succeed. Separate Lambdas with separate vault keys — billing key for the collaborator only, audit key for the supervisor — enforce role separation at the key level. The proxy rejects any POST to /v1/charges from the supervisor's audit key with 403, catching the multi-agent duplication failure at the infrastructure level rather than relying on agent orchestration to prevent it.

Does this pattern work with the Converse API and Bedrock tool use?

Yes. The same idempotency key pattern applies when using the Bedrock Converse API with toolConfig directly (rather than through Bedrock Agents). In that case, your tool handler code replaces the action group Lambda, and the idempotency key logic is identical: derive from tool input parameters, not from a per-call UUID. The StripeClient(api_key=VAULT_KEY, base_url=PROXY_URL+"/stripe/") one-liner works identically in both contexts.

What vault key policy should I configure for a Bedrock Agents billing Lambda?

For the billing Lambda vault key: allowed endpoints = POST /stripe/v1/charges only; daily USD cap = your max expected billing volume per billing agent (e.g., $10,000 for an agent processing up to 344 × $29 charges per day); expires_at = end of the billing cycle (e.g., midnight UTC on the last day of the month). For the audit vault key (used in the supervisor Lambda or /charge-status action): allowed endpoints = GET /stripe/v1/charges/* only; no spend cap needed. Issue one billing vault key per billing agent alias — not per AWS account. If a Lambda misbehaves (runaway loop, stuck retry), revoke its vault key from the Keybrake dashboard without affecting any other agent or billing cycle.

Vault keys for Amazon Bedrock Agents Stripe workflows

Keybrake issues scoped vault keys for Stripe — per-Lambda endpoint allowlists, daily spend caps, and a per-call audit log. One line change in your action group Lambda from stripe.api_key to stripe.StripeClient(api_key=VAULT_KEY, base_url=PROXY_URL+"/stripe/"). Proxy is live now.