Azure Durable Functions Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Azure Durable Functions is Microsoft's stateful serverless orchestration framework for coordinating activity functions, sub-orchestrations, and external events into long-running workflows. AI agent teams use Durable Functions to build billing pipelines with automatic retry via RetryOptions, parallel fan-out via Task.WhenAll, and scheduled recurrence via eternal orchestrations and TimerTrigger. When those workflows include Stripe billing — per-customer subscription renewals fanned out with context.task_all(), monthly billing driven by a ContinueAsNew eternal orchestration, or usage-based charges invoked by an AI agent activity — Durable Functions introduces three specific failure modes that neither Azure Key Vault nor Stripe restricted keys prevent on their own.
This post covers all three failure modes with Python activity function code, and the two-layer governance pattern — content-hash idempotency keys plus per-fan-out vault keys via a spend-cap proxy — that eliminates all three without restructuring your orchestration graph.
Failure mode 1: RetryOptions re-invokes the activity function from line 1 after a downstream failure that follows a successful Stripe charge
Durable Functions' RetryOptions class configures automatic retry for activity function invocations. When an activity function raises an unhandled exception, the Durable Task Framework catches it, applies the backoff policy from RetryOptions, and re-invokes the activity. This is correct behavior for transient failures — a Cosmos DB connection reset, a Service Bus timeout, a temporary availability blip — where re-running the activity from the beginning is safe. The failure mode occurs when the activity function calls stripe.charges.create(), that call succeeds and returns a charge object, and then a subsequent operation within the same activity invocation raises an exception. The activity raises; the orchestrator sees the error, consults RetryOptions, and re-invokes the activity function. The re-invocation starts from the top of the function body. stripe.charges.create() fires again without an idempotency key, and Stripe creates a second charge for the same customer.
With max_number_of_attempts=3 and an exponential backoff, a Cosmos DB write that intermittently times out after a successful Stripe charge can produce up to three charges before the retry budget is exhausted. The orchestration history shows three activity attempts each marked as failed (because the Cosmos DB write never succeeded), but Stripe's dashboard shows two or three charges in the succeeded state. The mismatch is invisible until a customer disputes a duplicate payment — at which point the Stripe charge IDs are scattered across separate activity replay entries in the execution history, making forensic recovery difficult.
# activities.py — UNSAFE: no idempotency key, downstream failure triggers duplicate charge
import azure.functions as func
import azure.durable_functions as df
import stripe
import os
activity_app = df.Blueprint()
@activity_app.activity_trigger(input_name="payload")
def charge_customer(payload: dict) -> dict:
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
customer_id = payload["customerId"]
amount_cents = payload["amountCents"]
billing_period = payload["billingPeriod"]
# UNSAFE: no idempotency key.
# If cosmos_client.upsert_item() raises below, the orchestrator
# retries this activity. On retry, stripe.Charge.create() fires
# again → ch_B created for the same customer in the same period.
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Billing {billing_period}",
)
# Cosmos DB write: if this raises (RequestRateTooLargeException,
# ServiceUnavailable), the activity throws → RetryOptions fires →
# stripe.Charge.create() runs again on the next attempt.
cosmos_client.upsert_item({
"id": f"{customer_id}_{billing_period}",
"customerId": customer_id,
"billingPeriod": billing_period,
"chargeId": charge["id"],
"status": "succeeded",
})
return {"chargeId": charge["id"]}
The fix is a content-hash idempotency key derived from the billing inputs — customer ID, amount, and billing period — combined with a workflow-specific salt. This key is computed before the Stripe call and is identical across every activity re-invocation with the same inputs. When the orchestrator retries after a Cosmos DB failure, stripe.Charge.create() fires with the same idempotency key, and Stripe returns the original charge object without creating a new charge. The Cosmos DB write is then safe to retry.
Additionally, distinguish permanent Stripe errors (card declined, invalid customer) from transient errors by raising a custom exception type. Configure the orchestrator's retry logic to skip retrying permanent billing errors by checking the exception type in a try/except at the orchestration level, or by returning a sentinel error payload from the activity instead of raising — Durable Functions re-raises any exception from an activity as a TaskFailedError in the orchestrator, so the orchestrator can inspect the inner exception message and route accordingly.
# activities.py — SAFE: content-hash idempotency key + permanent-error sentinel
import azure.durable_functions as df
import stripe
import os
import hashlib
activity_app = df.Blueprint()
def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:adf-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
@activity_app.activity_trigger(input_name="payload")
def charge_customer(payload: dict) -> dict:
customer_id = payload["customerId"]
amount_cents = payload["amountCents"]
billing_period = payload["billingPeriod"]
vault_key = payload["vaultKey"] # per-item vault key from IssueVaultKeys activity
# Idempotency key is stable across every activity re-invocation with same inputs
idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
# Point the Stripe SDK at the Keybrake proxy using the per-item vault key
stripe_client = stripe.Stripe(
api_key=vault_key,
base_url="https://proxy.keybrake.com/stripe",
)
try:
charge = stripe_client.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Billing {billing_period}",
idempotency_key=idempotency_key,
)
except stripe.CardError as e:
# Permanent — retrying will not help; return sentinel so orchestrator routes to failure state
return {"error": "permanent", "code": e.code, "message": e.user_message}
except stripe.InvalidRequestError as e:
return {"error": "permanent", "code": "invalid_request", "message": str(e)}
# Transient exceptions (APIConnectionError, RateLimitError without cap header)
# are allowed to propagate — RetryOptions will re-invoke with the same idempotency key
cosmos_client.upsert_item({
"id": f"{customer_id}_{billing_period}",
"customerId": customer_id,
"billingPeriod": billing_period,
"chargeId": charge.id,
"status": "succeeded",
})
return {"chargeId": charge.id}
In the orchestrator, check the returned payload for the "error": "permanent" sentinel and route to a failure sub-orchestration immediately, without waiting for RetryOptions to exhaust its budget:
# orchestrator.py
import azure.durable_functions as df
def orchestrator_function(context: df.DurableOrchestrationContext):
payload = context.get_input()
customer_id = payload["customerId"]
amount_cents = payload["amountCents"]
billing_period = payload["billingPeriod"]
vault_key = payload["vaultKey"]
retry_options = df.RetryOptions(
first_retry_interval_in_milliseconds=2000,
max_number_of_attempts=3,
)
result = yield context.call_activity_with_retry(
"charge_customer",
retry_options,
{
"customerId": customer_id,
"amountCents": amount_cents,
"billingPeriod": billing_period,
"vaultKey": vault_key,
},
)
# Sentinel check: permanent Stripe errors should not consume retry budget
if isinstance(result, dict) and result.get("error") == "permanent":
yield context.call_activity("record_billing_failure", {
"customerId": customer_id,
"billingPeriod": billing_period,
"reason": result,
})
return {"status": "failed", "reason": result}
return {"status": "succeeded", "chargeId": result["chargeId"]}
main = df.Orchestrator.create(orchestrator_function)
Failure mode 2: Task.WhenAll fan-out shares one unrestricted STRIPE_SECRET_KEY across all concurrent activity executions with no per-item spend cap
Durable Functions' fan-out pattern uses context.task_all() (Python) or Task.WhenAll (.NET) to dispatch a list of activity tasks simultaneously. Each task runs in an independent activity execution — potentially on different worker instances — but all share the same application setting STRIPE_SECRET_KEY. There is no per-execution dollar cap in Durable Functions: you can set MaxConcurrentActivityFunctions in host.json to control how many activities run simultaneously, but this controls concurrency, not per-activity spending authority. A data bug that passes amount_cents: 49999 (intended: 4999) to all 500 customers dispatches $24,999.50 in Stripe charges across the fan-out before any result returns to the orchestrator. Because all activities use the same unrestricted key, the first oversized charge does not block or abort the others — every activity function in the fan-out executes to completion independently, and only then does the orchestrator collect the results.
# orchestrator.py — UNSAFE: fan-out with shared unrestricted key, no per-item cap
import azure.durable_functions as df
def orchestrator_function(context: df.DurableOrchestrationContext):
payload = context.get_input()
customers = payload["customers"]
billing_period = payload["billingPeriod"]
# UNSAFE: all concurrent activity executions share STRIPE_SECRET_KEY.
# A unit calculation error in the input data charges all N customers
# the wrong amount simultaneously — no per-item cap exists.
tasks = [
context.call_activity("charge_customer", {
"customerId": c["customerId"],
"amountCents": c["amountCents"],
"billingPeriod": billing_period,
})
for c in customers
]
results = yield context.task_all(tasks)
return results
main = df.Orchestrator.create(orchestrator_function)
The fix is to add an issue_vault_keys activity before the fan-out. This activity receives the customer list, issues one vault key per customer from the Keybrake proxy — each capped at the customer's expected charge amount plus a 10% buffer, scoped to POST /v1/charges, with a TTL that covers the fan-out duration — and returns the enriched customer list with each item carrying its own vault key. The fan-out then distributes this enriched list. Each activity invocation receives a unique vault key in its input payload and uses that key to initialize the Stripe client, not the environment variable. A data bug that passes an oversized amount is caught by the per-item vault key cap on the first Stripe API call — the proxy returns a 429 with the x-keybrake-cap-hit header before the charge is created. Because each activity has its own key, the cap hit for one customer does not affect others in the same fan-out.
# activities.py — issue_vault_keys activity runs before the fan-out
import azure.durable_functions as df
import os
import json
import urllib.request
activity_app = df.Blueprint()
@activity_app.activity_trigger(input_name="payload")
def issue_vault_keys(payload: dict) -> list:
customers = payload["customers"]
billing_period = payload["billingPeriod"]
keybrake_api_key = os.environ["KEYBRAKE_API_KEY"]
proxy_url = os.environ.get("KEYBRAKE_PROXY_URL", "https://proxy.keybrake.com")
enriched = []
for customer in customers:
customer_id = customer["customerId"]
amount_cents = customer["amountCents"]
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": 3600,
"label": f"adf/{billing_period}/{customer_id}",
}).encode()
req = urllib.request.Request(
f"{proxy_url}/vault/keys",
data=body,
headers={
"Authorization": f"Bearer {keybrake_api_key}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req) as resp:
vault_data = json.loads(resp.read())
enriched.append({
**customer,
"vaultKey": vault_data["key"],
})
return enriched
# orchestrator.py — SAFE: IssueVaultKeys activity runs before the fan-out
import azure.durable_functions as df
def orchestrator_function(context: df.DurableOrchestrationContext):
payload = context.get_input()
billing_period = payload["billingPeriod"]
# Step 1: issue one vault key per customer before dispatching the fan-out
enriched_customers = yield context.call_activity("issue_vault_keys", {
"customers": payload["customers"],
"billingPeriod": billing_period,
})
# Step 2: fan-out — each activity now uses its own per-item vault key
retry_options = df.RetryOptions(
first_retry_interval_in_milliseconds=2000,
max_number_of_attempts=3,
)
tasks = [
context.call_activity_with_retry("charge_customer", retry_options, c)
for c in enriched_customers
]
results = yield context.task_all(tasks)
return results
main = df.Orchestrator.create(orchestrator_function)
Failure mode 3: ContinueAsNew eternal orchestration or TimerTrigger without an exclusivity guard fires two overlapping billing runs for the same period
Durable Functions supports two common patterns for recurring billing. The first is an eternal orchestration: after completing a billing cycle, the orchestrator calls context.continue_as_new() to restart with fresh state and a new billing period, scheduling a durable timer via context.create_timer() to wait until the next billing date. The second is a TimerTrigger function that starts a new orchestration instance on a cron schedule. Both patterns introduce the same failure mode: two billing runs executing simultaneously for the same billing period.
In the eternal orchestration pattern, a zero-downtime slot swap (e.g., Azure App Service deployment swap, or a Functions host restart during a rolling update) can leave two orchestration instances running simultaneously if the task hub is not drained before the swap. Both instances wake from their durable timers on the same schedule, both call the fan-out billing logic, and both successfully charge customers — because each instance generates the same idempotency keys from the same customer inputs, the Stripe idempotency key prevents a double charge at the Stripe layer, but two audit records are created in your Cosmos DB, one from each instance.
In the TimerTrigger pattern, a Functions host restart during the timer window can cause the timer to fire twice: once from the outgoing host (which processed the timer before it was killed) and once from the incoming host (which rehydrated the timer state and re-fired it). Again, idempotency keys at the Stripe layer prevent the double charge, but your orchestration history shows two successful billing runs for 2026-07, and the audit log contains two billing_completed events for the same period.
# orchestrator.py — UNSAFE: no instance deduplication or singleton guard
import azure.durable_functions as df
from datetime import timedelta
def orchestrator_function(context: df.DurableOrchestrationContext):
payload = context.get_input()
billing_period = payload["billingPeriod"]
# UNSAFE: no guard against a second instance running for the same billing_period.
# A deployment swap or host restart can launch two instances simultaneously.
# Both reach charge_customers and generate identical work — Stripe idempotency
# keys prevent double charges but create two audit records.
yield context.call_activity("charge_customers", {"billingPeriod": billing_period})
# Schedule next run
next_period = next_billing_period(billing_period)
yield context.create_timer(next_billing_date(next_period))
context.continue_as_new({"billingPeriod": next_period})
main = df.Orchestrator.create(orchestrator_function)
The fix is a two-part guard. First, use a deterministic instance ID based on the billing period — f"billing-{billing_period}" — when starting or restarting the orchestration. When the Durable Functions runtime receives a StartNew request with an instance ID that already exists in a non-terminal state, it rejects the new request rather than starting a duplicate instance. This prevents the TimerTrigger double-fire case.
Second, add a pre-flight activity that checks whether a completed billing record already exists in Cosmos DB for this period before dispatching the fan-out. This prevents the post-swap duplicate-instance case, where two orchestration instances slip through before the first has written its completion record. The pre-flight check uses a vault key scoped to GET /v1/charges (audit-only, zero billing authority) so it cannot create charges even if it runs redundantly.
# orchestrator.py — SAFE: deterministic instance ID + pre-flight guard
import azure.durable_functions as df
from datetime import timedelta
def orchestrator_function(context: df.DurableOrchestrationContext):
payload = context.get_input()
billing_period = payload["billingPeriod"]
# Pre-flight: check whether this billing period is already completed
already_billed = yield context.call_activity("check_period_completed", {
"billingPeriod": billing_period,
})
if already_billed:
# Idempotent exit — schedule next period and restart without charging
next_period = next_billing_period(billing_period)
yield context.create_timer(next_billing_date(next_period))
context.continue_as_new({"billingPeriod": next_period})
return {"status": "skipped", "reason": "already_billed", "billingPeriod": billing_period}
# Issue vault keys then fan-out
enriched_customers = yield context.call_activity("issue_vault_keys", {
"customers": (yield context.call_activity("get_customers", {})),
"billingPeriod": billing_period,
})
retry_options = df.RetryOptions(2000, 3)
tasks = [
context.call_activity_with_retry("charge_customer", retry_options, c)
for c in enriched_customers
]
results = yield context.task_all(tasks)
yield context.call_activity("record_billing_completed", {
"billingPeriod": billing_period,
"chargeCount": len(results),
})
# Schedule next run
next_period = next_billing_period(billing_period)
yield context.create_timer(next_billing_date(next_period))
context.continue_as_new({"billingPeriod": next_period})
main = df.Orchestrator.create(orchestrator_function)
# Starter function: use deterministic instance ID to prevent duplicate instances
import azure.durable_functions as df
import azure.functions as func
starter_app = df.Blueprint()
@starter_app.route(route="billing/start")
@starter_app.durable_client_input(client_name="client")
async def start_billing(req: func.HttpRequest, client: df.DurableOrchestrationClient):
billing_period = req.params.get("period", current_billing_period())
# Deterministic instance ID: at most one non-terminal instance per billing period
instance_id = f"billing-{billing_period}"
existing = await client.get_status(instance_id)
if existing and existing.runtime_status in ("Running", "Pending"):
return func.HttpResponse(
f"Billing for {billing_period} already in progress (instance {instance_id})",
status_code=409,
)
await client.start_new("billing_orchestrator", instance_id, {"billingPeriod": billing_period})
return client.create_check_status_response(req, instance_id)
Approach comparison
| Approach | Retry double-charge | Fan-out cap | Duplicate run guard | Audit trail | Overhead |
|---|---|---|---|---|---|
| No changes (baseline) | No protection | No cap | No guard | Sparse | Minimal |
| Stripe idempotency keys only | Protects Stripe layer | No cap | Duplicate audit records remain | Incomplete | Low |
| Stripe restricted key (single) | No retry protection | No cap | No guard | None | Low |
| Azure Key Vault references | No retry protection | No cap | No guard | None | Medium |
| Content-hash idempotency keys only | Fully protected | No cap | Protects Stripe layer only | Stripe only | Low |
| Content-hash idempotency + per-item vault keys (Keybrake) | Fully protected | Per-item cap | Full guard (deterministic ID + pre-flight) | Full proxy log | One extra activity |
Gap analysis: four additional Durable Functions billing risks
1. SubOrchestrator retry re-invokes the child orchestration including its billing fan-out
When an orchestrator calls context.call_sub_orchestrator() with a RetryOptions, a transient failure in the sub-orchestration (such as a storage write error after the billing fan-out completes) causes the parent to re-invoke the entire child orchestration. If the child orchestration does not use a deterministic instance ID and a pre-flight completion check, it will execute its billing fan-out a second time. The per-item vault keys cap each duplicate charge, but the pre-flight check is the correct long-term fix — the child should return early if the billing period is already recorded as complete.
2. context.call_http() direct Stripe calls bypass vault key governance
Durable Functions supports making HTTP calls directly from the orchestrator using context.call_http(). Teams sometimes use this for lightweight Stripe operations (fetching a customer object, retrieving a subscription status) to avoid the overhead of a separate activity function. These calls use a hardcoded Authorization header with the full STRIPE_SECRET_KEY value — there is no vault key in play, and no proxy layer recording the call. If someone later moves a billing call (e.g., POST /v1/charges) to context.call_http() for convenience, it bypasses all governance controls. Enforce a team convention: all Stripe write calls must go through activity functions that receive a vault key, never through context.call_http() in the orchestrator body.
3. Entity functions sharing a Stripe client across concurrent signals
Durable entity functions (the @df.entity_trigger pattern) are a natural fit for customer billing state — you can model each customer as an entity that tracks its current billing status and idempotency key. However, entity functions process signals one at a time per entity instance, with state serialized to storage between signals. If an entity function holds a Stripe client as an instance variable rather than creating a new client per signal, and the entity's storage lease transfers between worker instances (e.g., after a scale-out), the new worker instance will not have the in-memory Stripe client — but it will reconstruct it from environment variables, using the unrestricted STRIPE_SECRET_KEY. Issue vault keys per signal, not per entity lifetime.
4. context.create_timer() cancellation race during slot swap
In an eternal orchestration waiting on context.create_timer(), a deployment slot swap can cancel the timer on the source slot and replay it on the destination slot. If the source slot's orchestration was mid-fan-out when the swap occurred (the timer had already fired but billing was not yet recorded as complete), the destination slot may replay the entire orchestration from the last checkpoint — which may be before the fan-out started, causing the fan-out to execute again. The deterministic instance ID prevents a second orchestration from starting, but does not prevent replay within the same instance. The pre-flight completion check in the orchestrator body is the correct defense: if billing for the period is already recorded, the orchestration skips the fan-out and advances to the timer for the next period.
Enforcement tests
import pytest
import hashlib
from unittest.mock import patch, MagicMock
def billing_idempotency_key(customer_id, amount_cents, billing_period):
raw = f"{customer_id}:{amount_cents}:{billing_period}:adf-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def test_idempotency_key_is_stable_across_retries():
key1 = billing_idempotency_key("cus_ABC", 4999, "2026-07")
key2 = billing_idempotency_key("cus_ABC", 4999, "2026-07")
assert key1 == key2, "Idempotency key must not change between activity invocations"
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, "Different customers must produce different idempotency keys"
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, "Different billing periods must produce different idempotency keys"
def test_permanent_error_returns_sentinel_not_raises():
"""Activity must return a sentinel dict for permanent Stripe errors, not raise."""
import stripe
with patch("stripe.Stripe") as MockStripe:
mock_client = MagicMock()
MockStripe.return_value = mock_client
mock_client.charges.create.side_effect = stripe.CardError(
"Your card was declined.", None, "card_declined"
)
# Import the activity after patching
from activities import charge_customer
result = charge_customer({
"customerId": "cus_ABC",
"amountCents": 4999,
"billingPeriod": "2026-07",
"vaultKey": "kb_test_vault_key",
})
assert result.get("error") == "permanent", \
"Card declines must return {'error': 'permanent'} so the orchestrator skips retry"
def test_vault_key_prefix_required():
"""Activity must reject vault keys that don't carry the kb_ prefix."""
with pytest.raises(Exception, match="invalid vault key"):
from activities import charge_customer
charge_customer({
"customerId": "cus_ABC",
"amountCents": 4999,
"billingPeriod": "2026-07",
"vaultKey": "sk_live_actual_stripe_key", # raw Stripe key — must be rejected
})
Frequently asked questions
Can I use the Durable Functions instance_id as the Stripe idempotency key?
No. The orchestration instance ID is stable across replays of the same orchestration, but it is not unique across billing periods or customers. A billing orchestration that uses the instance ID as its Stripe idempotency key will produce the same key for two different billing periods if the orchestration is restarted with the same instance ID (e.g., because you use a deterministic instance ID pattern like "billing-{billing_period}" and you restart after a bug fix). Use a content-hash of the billing inputs (customer_id:amount_cents:billing_period:adf-billing) as the idempotency key — it is both stable across retries for the same inputs and unique across different billing events.
How long should vault key TTLs be for a large fan-out?
Set the TTL to cover the expected fan-out duration plus a 50% buffer. If you are billing 1,000 customers with MaxConcurrentActivityFunctions: 50 in host.json, the fan-out runs 20 batches of 50 concurrent activities. If each activity takes up to 10 seconds, the fan-out takes up to 200 seconds. A TTL of 600 seconds (10 minutes) is a safe default. Do not set open-ended TTLs — a vault key that does not expire can be used outside the intended billing window if it leaks through a log or an error message.
Does the Keybrake proxy work with Azure Private Endpoints?
Yes. The proxy endpoint proxy.keybrake.com is a standard HTTPS service reachable from any Azure region. If your Functions app runs in a VNet with outbound traffic routed through Azure Firewall, add proxy.keybrake.com:443 to your firewall allow-list. If you use Private DNS Zones with forced tunneling, ensure DNS resolution for proxy.keybrake.com falls back to the public DNS path rather than your private resolver.
What happens if the issue_vault_keys activity fails partway through issuing keys for a large customer list?
Because issue_vault_keys is a Durable Functions activity, RetryOptions will re-invoke it from the beginning. The vault keys already issued in the previous attempt are still valid — they are short-lived tokens stored in the Keybrake proxy, not local state in the activity function. The re-invocation will re-issue keys for customers that already received one in the previous attempt; those earlier keys become orphaned (unused, expired at their TTL). This is safe: orphaned keys cannot create charges because they are only used inside the fan-out activities, which have not started yet. To make the issue_vault_keys activity safe to retry without accumulating orphaned keys, pass a label that includes the orchestration instance ID — the proxy deduplicates key requests by label within the TTL window.
How does the proxy handle context.call_http() calls that slip through to Stripe directly?
The proxy cannot intercept context.call_http() calls made from within the orchestrator body — those go directly to api.stripe.com using the raw key. This is why the governance convention matters: all Stripe calls must go through activity functions that receive a vault key. To enforce this at the infrastructure level, you can revoke the STRIPE_SECRET_KEY environment variable entirely from the Functions app setting and replace it with a KEYBRAKE_API_KEY. Any code path that still references os.environ["STRIPE_SECRET_KEY"] will raise a KeyError at runtime, surfacing the violation immediately.
Does Keybrake support Durable Functions' .NET and JavaScript/TypeScript SDKs, not just Python?
Yes. The Keybrake proxy is language-agnostic — it is a plain HTTPS reverse proxy. In .NET, set StripeConfiguration.ApiBase = "https://proxy.keybrake.com/stripe" and initialize the StripeClient with the vault key. In JavaScript/TypeScript, pass host: "proxy.keybrake.com/stripe" to the Stripe constructor and supply the vault key as the first argument. The content-hash idempotency key pattern is the same across all SDKs — compute SHA-256(customer_id:amount_cents:billing_period:adf-billing) and pass the first 32 hex characters as the idempotency key header.
Summary
Azure Durable Functions introduces three Stripe billing risks that are invisible to both Azure Key Vault and Stripe's own restricted key feature: RetryOptions re-invoking an activity from line 1 after a downstream failure that follows a successful charge; Task.WhenAll fan-out distributing billing across activities that share one unrestricted Stripe key with no per-item dollar cap; and ContinueAsNew eternal orchestrations or TimerTrigger cron functions launching overlapping billing runs for the same period when deployment swaps or host restarts occur.
All three are closed by the same two-layer pattern: content-hash idempotency keys (stable across retries, unique across billing events) plus per-item vault keys issued by a pre-fan-out activity, each capped at the expected charge amount and scoped to the billing endpoint. Add a deterministic orchestration instance ID and a pre-flight completion check for the duplicate-run case. The result is a billing pipeline where every failure path — retry, fan-out data bug, or overlapping run — produces at most one charge per customer per billing period, with a full audit trail in the proxy log.
See also: AWS Step Functions Stripe integration, Temporal Stripe integration, Dagster Stripe integration, and idempotency patterns for AI agent billing.
Add spend caps and audit logs to your Durable Functions billing pipeline
Keybrake proxies Stripe calls from your Azure activity functions — per-item vault keys, spend caps, and a full audit log, without changing your orchestration graph.