Temporal · AI agents · API key security

Temporal AI agent API key: scoping activity calls to Stripe and Twilio

Temporal gives you durable, reliable workflow execution — which is exactly what makes uncapped API keys dangerous. An activity that retries automatically can charge a customer twice. A workflow that runs for 30 days can accumulate spend you'd never allow in a short-lived script. This page covers what Temporal's native tooling doesn't provide for AI agents calling vendor APIs, and the vault-key pattern that fills those gaps.

TL;DR

Temporal's workflow history is excellent for debugging — but it doesn't tell you the dollar amount of every Stripe charge your agent made, and it won't stop activity 47 in a long-running workflow from exceeding a per-run budget. A vault key proxy sits between your Temporal activities and the vendor: issue one vault key per workflow ID, enforce a per-workflow spend cap, and get a structured per-call audit log with workflow context attached. If a workflow goes sideways, revoke the vault key without rotating the real Stripe key your other workflows depend on.

How Temporal AI agents call vendor APIs

In a Temporal workflow, the actual API calls happen inside activities — ordinary functions decorated with @activity.defn (Python) or implemented as plain async functions (TypeScript/Go). The workflow orchestrates which activities run and in what order; the activities do the work.

A billing workflow built on Temporal might look like:

# Python / temporalio SDK
@activity.defn
async def charge_customer(customer_id: str, amount_cents: int) -> dict:
    stripe.api_key = os.environ["STRIPE_SECRET_KEY"]  # real key, full access
    return stripe.PaymentIntent.create(
        amount=amount_cents,
        currency="usd",
        customer=customer_id,
    )

@workflow.defn
class BillingWorkflow:
    @workflow.run
    async def run(self, params: BillingParams) -> None:
        for invoice in params.invoices:
            await workflow.execute_activity(
                charge_customer,
                args=[invoice.customer_id, invoice.amount_cents],
                start_to_close_timeout=timedelta(seconds=30),
                retry_policy=RetryPolicy(maximum_attempts=3),
            )

This is solid Temporal code. The retry policy, durable execution, and workflow history are all working correctly. The problem is the API key: STRIPE_SECRET_KEY is a long-lived, full-access key with no per-workflow cap and no way to revoke access for just this workflow run without affecting everything else using that key.

Three gaps Temporal's native tooling doesn't fill for vendor API calls

GapWhat happens in practiceTemporal's answer
No per-workflow spend cap A billing workflow with a bug in the invoice list charges customers multiple times. Temporal will faithfully retry each activity up to maximum_attempts. The cap on real-money damage is whatever Stripe allows on your account — not what you intended for this workflow run. None. Workflow history shows you what happened; it doesn't stop it while it's happening.
No per-workflow revoke You notice a workflow is misbehaving at 2am. You can signal or terminate the Temporal workflow — but the activity that's currently in flight may have already made the Stripe call. And rotating the real Stripe key to stop it breaks every other workflow on your cluster. Workflow termination and cancellation exist, but they don't cancel in-flight vendor API calls or revoke credential access for already-started activities.
No per-call cost audit with workflow context Temporal workflow history shows activity inputs/outputs, but doesn't parse or aggregate the dollar amounts from Stripe responses — and doesn't attach the workflow ID and run ID to each Stripe charge in a queryable way. Workflow history (event log). No cost parsing, no cross-referencing with Stripe charge amounts by workflow run.

The retry risk: why Temporal makes this worse than a script

Temporal's automatic activity retries are a feature — until a vendor API call is the activity that's retrying. Consider what happens when a Stripe charge activity fails with a 500 (Stripe's server error) on the first attempt:

  1. Temporal schedules a retry (correct behavior).
  2. The activity runs again and succeeds — but if the first attempt actually went through on Stripe's side before returning 500, you've now created two charges.
  3. Stripe's idempotency keys solve the duplicate-charge problem if you pass the same key on retry — but many teams don't wire this up correctly, and even when they do, the idempotency key has to be stable across retries, which requires passing it through from the workflow context.

This is a known problem. The vault key proxy adds a second layer: even if a retry would double-charge, the per-workflow spend cap catches it. If the cap is $500 and the workflow has already successfully processed $400 of charges, a retry-triggered double-charge will hit the cap and return a 429 instead of completing. Your error handling catches the 429, you investigate, and you don't have a refund problem.

Scoping vault keys per workflow run in Temporal

The vault key is issued once per workflow run — in the workflow code before the first activity, or in a dedicated setup_activity. It travels through the workflow via the activity context or a shared parameter:

# Python / temporalio SDK
import httpx

@activity.defn
async def issue_vault_key(workflow_id: str, budget_usd: float) -> str:
    async with httpx.AsyncClient() as client:
        r = await client.post(
            "https://proxy.keybrake.com/vault/keys",
            headers={"Authorization": f"Bearer {os.environ['KEYBRAKE_API_KEY']}"},
            json={
                "vendor": "stripe",
                "daily_usd_cap": budget_usd,
                "allowed_endpoints": ["POST /v1/payment_intents", "GET /v1/customers/*"],
                "expires_in": "8h",
                "agent_run_label": f"temporal-billing/{workflow_id}",
            },
        )
        return r.json()["vault_key"]

@activity.defn
async def charge_customer(customer_id: str, amount_cents: int, vault_key: str) -> dict:
    stripe.api_key = vault_key           # scoped key, not the real secret
    stripe.api_base = "https://proxy.keybrake.com/stripe"
    return stripe.PaymentIntent.create(
        amount=amount_cents,
        currency="usd",
        customer=customer_id,
        idempotency_key=f"{activity.info().workflow_id}-{customer_id}-{amount_cents}",
    )

@workflow.defn
class BillingWorkflow:
    @workflow.run
    async def run(self, params: BillingParams) -> None:
        vault_key = await workflow.execute_activity(
            issue_vault_key,
            args=[workflow.info().workflow_id, params.budget_usd],
            start_to_close_timeout=timedelta(seconds=10),
            retry_policy=RetryPolicy(maximum_attempts=2),
        )
        for invoice in params.invoices:
            await workflow.execute_activity(
                charge_customer,
                args=[invoice.customer_id, invoice.amount_cents, vault_key],
                start_to_close_timeout=timedelta(seconds=30),
                retry_policy=RetryPolicy(maximum_attempts=3),
            )

The vault key is now per-workflow: each workflow run gets its own scoped key with its own dollar cap. The proxy logs every call with agent_run_label: "temporal-billing/{workflow_id}", so you can query the Keybrake audit log filtered by workflow ID and see the exact charges made by that run — not just Temporal's event log, but the actual amounts from Stripe's responses.

Long-running workflows: the compounding risk

Temporal is designed for workflows that run for minutes, days, or months. A subscription renewal workflow that fires every 30 days, a customer onboarding workflow with a 14-day trial check-in, or an AI agent workflow that waits for external events and then charges — all of these accumulate Stripe calls over time that no one is actively watching.

The per-workflow vault key approach handles this naturally: you set the daily_usd_cap at workflow start time based on the expected spend, and the proxy enforces it every day the workflow runs. If the workflow is supposed to charge at most $1,000/day and it starts approaching that (whether due to a bug, unexpected data volume, or a malicious input), the cap fires and the workflow receives a 429 that your error handling can route to a human-review queue or a Temporal signal.

How Keybrake fits

Keybrake is the proxy layer between your Temporal activities and Stripe, Twilio, or Resend. You swap stripe.api_key for the vault key and set stripe.api_base to https://proxy.keybrake.com/stripe. The real Stripe secret stays on the Keybrake side, never in your activity environment. Each workflow run gets its own vault key with its own spend cap, endpoint allowlist, and expiry. The audit log stores every call with the workflow ID label you set at key-issue time — queryable via the Keybrake dashboard or REST API.

Get early access

Related questions

Does the vault key approach break Temporal's idempotency story?

No — and if anything, it adds a second safety layer. Temporal idempotency (via idempotency keys passed to Stripe) prevents duplicate charges on retries. The vault key spend cap is an independent guard: even if an idempotency key is missing or incorrect, the cap catches the duplicate spend before it completes. The two mechanisms are complementary. The vault key doesn't interfere with Stripe's idempotency key processing — it's just a proxy layer that enforces the cap and logs the call.

What happens to the vault key if the Temporal workflow is terminated mid-run?

The vault key expires naturally at its expires_in time. If you want to revoke it immediately on workflow termination, you can call the Keybrake revoke endpoint (DELETE /vault/keys/{key_id}) in a workflow cleanup signal handler or in a Temporal activity you trigger on cancellation. This ensures that even if there are in-flight activities that haven't seen the termination signal yet, their vault key stops working immediately.

Can I use a single vault key across multiple activities in the same workflow?

Yes — this is the recommended pattern. Issue one vault key in the first activity (or at workflow start) and pass it as a parameter to all subsequent activities that need to call the vendor. The vault key's daily cap and endpoint allowlist apply across all uses of that key, so the per-workflow total spend is what gets capped. Don't issue a new vault key for each activity call — that would give each activity its own independent cap rather than a shared per-workflow cap.

Further reading