Dagger Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Dagger is an open-source programmable CI/CD engine that lets you write pipelines as typed functions in Python, TypeScript, or Go — each step running in its own container, with outputs cached by a hash of the function's inputs. Teams use it for ML training workflows, AI agent pipelines, and deployment automation where reproducibility and caching matter. When those pipelines include Stripe billing steps — usage-based charges triggered by pipeline completion, seat provisioning during automated onboarding flows, or per-call agent actions — Dagger's execution model introduces three specific failure modes: a cold CI runner (or --no-cache flag) bypasses Dagger's layer cache and re-runs billing steps that developers assumed were deduplicated by caching; a parallel container fan-out shares one unrestricted Stripe dag.set_secret() value across all concurrent instances with no per-invocation spend cap; and a CI job retry after a downstream failure re-executes the billing function from line 1 on a fresh runner where the cache is cold.
This post covers all three failure modes with Python code, and the two-layer governance pattern — content-hash idempotency keys plus per-invocation vault keys via a spend-cap proxy — that eliminates all three without restructuring your Dagger pipeline.
Failure mode 1: --no-cache and cold CI runners bypass layer-cache deduplication for billing steps
Dagger caches function outputs by a hash of the function's inputs: the same customer_id, amount_cents, and billing_period produce the same cache key, and subsequent calls return the cached charge ID without re-executing the container. This makes pipeline retries appear safe — re-running the billing step with identical inputs returns the cached charge, and Stripe is never called twice. But this safety is contingent on the cache being warm. Dagger's cache is bypassed in three common scenarios: a CI job runs on a fresh runner (cold cache), Dagger Cloud's shared cache is evicted due to storage limits or TTL expiry, or a developer runs the pipeline with --no-cache to clear a stale non-billing step. In all three cases, the billing function executes from scratch. Without an idempotency key in the function itself — independent of Dagger's cache — Stripe receives a new stripe.charges.create() call with the same inputs and creates a second charge. The developer who ran --no-cache to fix an unrelated caching issue does not see any warning: Dagger's output shows the billing step completing, and Stripe silently books a duplicate charge.
# billing/src/main.py — Dagger billing module (UNSAFE: relies on Dagger cache for dedup)
import dagger
from dagger import dag, function, object_type
import stripe as stripe_client
@object_type
class Billing:
@function
async def charge_customer(
self,
customer_id: str,
amount_cents: int,
billing_period: str,
stripe_secret: dagger.Secret,
) -> str:
# UNSAFE: no idempotency key
# Warm cache: Dagger returns cached charge ID — Stripe not called
# Cold cache or --no-cache: Stripe called again → duplicate charge ch_B
key = await stripe_secret.plaintext()
stripe_client.api_key = key
charge = stripe_client.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
)
return charge.id
# $ dagger call billing charge-customer \
# --customer-id=cus_001 --amount-cents=4999 --billing-period=2026-07 \
# --stripe-secret=env:STRIPE_SECRET_KEY
# First run: ch_A created, result cached in Dagger
# Second run (warm cache): Dagger returns "ch_A" — Stripe not called — safe
#
# Danger: developer clears stale cache for an unrelated step
# $ dagger call billing charge-customer ... --no-cache
# Cache bypassed for ALL steps: Stripe called again → ch_B created → duplicate charge
The fix is to derive a content-hash idempotency key from the billing inputs inside the Dagger function itself, independent of whether Dagger's cache is warm or cold. The key is stable across every execution with the same logical billing inputs, so Stripe's idempotency layer handles deduplication even when Dagger's layer cache does not. The --no-cache flag becomes safe: the billing function runs, derives the same idempotency key it produced on the first run, and Stripe returns the original charge object without creating a new charge.
# billing/src/main.py — SAFE: content-hash idempotency key independent of Dagger cache
import dagger
from dagger import dag, function, object_type
import stripe as stripe_client
import hashlib
import os
KEYBRAKE_PROXY_URL = os.environ.get("KEYBRAKE_PROXY_URL", "https://proxy.keybrake.com")
def _billing_idempotency_key(
customer_id: str, amount_cents: int, billing_period: str
) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:dagger-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
@object_type
class Billing:
@function
async def charge_customer(
self,
customer_id: str,
amount_cents: int,
billing_period: str,
stripe_secret: dagger.Secret,
) -> str:
# SAFE: idempotency key is stable regardless of Dagger cache state
# --no-cache, cold runner, or cache eviction all produce the same key
idem_key = _billing_idempotency_key(customer_id, amount_cents, billing_period)
key = await stripe_secret.plaintext()
client = stripe_client.Stripe(
api_key=key,
base_url=f"{KEYBRAKE_PROXY_URL}/stripe",
)
charge = client.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
idempotency_key=idem_key,
)
return charge.id
Using a stripe.Stripe(api_key=key, base_url=proxy_url) client instance rather than mutating the module-level stripe.api_key global is important for concurrent execution: Dagger may run multiple instances of the same function in the same Python process when fanning out across multiple billing targets in asyncio.gather(). Module-level state mutation is not safe across coroutines sharing an event loop.
Failure mode 2: parallel fan-out shares one unrestricted dag.set_secret() value across concurrent containers
A Dagger billing pipeline that fans out across multiple customers — billing cohort A, B, and C concurrently — typically creates one dag.set_secret() entry for the Stripe key and passes it to all concurrent charge_customer function calls via asyncio.gather(). The Dagger SDK passes the same secret value to every function invocation: all containers receive the same unrestricted live Stripe key with no per-invocation spend cap. If the billing logic contains a unit calculation error — for example, amount_cents=49999 where 4999 was intended — every customer in the fan-out receives a $499.99 charge before any result returns. Because all invocations share one unrestricted key, there is no circuit breaker: the fan-out completes all Stripe calls in parallel, and the incorrect charges exist in Stripe before any error is surfaced in the pipeline output.
# billing_pipeline.py — UNSAFE: all concurrent invocations share one unrestricted secret
import asyncio
import os
import dagger
async def bill_cohort(customers: list[dict]):
async with dagger.Connection() as client:
stripe_secret = client.set_secret(
"STRIPE_SECRET_KEY", os.environ["STRIPE_SECRET_KEY"]
)
# UNSAFE: same unrestricted key passed to all concurrent billing containers
# Unit bug (amount_cents=49999 instead of 4999) hits ALL customers simultaneously
# No per-customer cap stops the fan-out — all charges fire before any error surfaces
tasks = [
client.billing().charge_customer(
customer_id=c["customer_id"],
amount_cents=c["amount_cents"],
billing_period="2026-07",
stripe_secret=stripe_secret, # same unrestricted key for every invocation
)
for c in customers
]
return await asyncio.gather(*tasks)
The fix is to issue a per-customer vault key before the fan-out and pass a distinct scoped secret to each charge_customer invocation. Each vault key is capped at the customer's expected charge amount plus a 10% buffer, scoped to POST /v1/charges only, and expires after the billing window. A unit calculation error that produces a $499.99 charge where $49.99 was expected exhausts that customer's vault key cap on the very first Stripe call — the proxy blocks the oversized charge and returns a 402 error. Because each invocation has its own vault key, the cap failure for one customer does not affect others in the fan-out.
# billing_pipeline.py — SAFE: per-customer vault keys issued before fan-out
import asyncio
import os
import httpx
import dagger
KEYBRAKE_ADMIN_KEY = os.environ["KEYBRAKE_ADMIN_KEY"]
KEYBRAKE_PROXY_URL = os.environ.get("KEYBRAKE_PROXY_URL", "https://proxy.keybrake.com")
async def issue_vault_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
"""Issue a per-customer vault key capped at amount × 1.10."""
async with httpx.AsyncClient() as http:
resp = await http.post(
f"{KEYBRAKE_PROXY_URL}/keys",
headers={"Authorization": f"Bearer {KEYBRAKE_ADMIN_KEY}"},
json={
"label": f"dagger-{customer_id}-{billing_period}",
"vendor": "stripe",
"daily_usd_cap": round(amount_cents * 1.10 / 100, 2),
"allowed_endpoints": ["POST /v1/charges"],
"expires_in_seconds": 3600,
},
)
resp.raise_for_status()
return resp.json()["vault_key"]
async def bill_cohort(customers: list[dict]):
async with dagger.Connection() as client:
# Issue all vault keys before any Dagger function is called
# If issuance fails for any customer, abort before any charge is attempted
vault_keys = {}
for c in customers:
vault_keys[c["customer_id"]] = await issue_vault_key(
c["customer_id"], c["amount_cents"], "2026-07"
)
tasks = []
for c in customers:
# Each invocation gets its own scoped secret — per-customer spend cap enforced
per_customer_secret = client.set_secret(
f"vault_key_{c['customer_id']}",
vault_keys[c["customer_id"]],
)
tasks.append(
client.billing().charge_customer(
customer_id=c["customer_id"],
amount_cents=c["amount_cents"],
billing_period="2026-07",
stripe_secret=per_customer_secret,
)
)
return await asyncio.gather(*tasks)
Issuing vault keys sequentially before the asyncio.gather() fan-out — rather than inside each concurrent task — ensures that if any issuance fails (network error, proxy unavailable, daily budget exhausted), the pipeline aborts before any Stripe charge is attempted. A partial fan-out where some customers have vault keys and others fall back to an unrestricted key is more dangerous than a clean abort.
Failure mode 3: CI job retry on a cold runner re-executes the billing function from line 1
When a Dagger pipeline step that runs after billing — a database write, a Slack notification, a deployment step — raises an exception after stripe.charges.create() already succeeded, the CI system (GitHub Actions, Buildkite, CircleCI) marks the job as failed and may retry it automatically or on manual re-run. If the retry runs on the same CI runner with a warm Dagger cache, the billing function returns its cached result and Stripe is not called again. But CI retry often runs on a different runner — a fresh ephemeral container with no local Dagger cache. Dagger Cloud can share the cache across runners, but it is not guaranteed in all configurations, and Dagger's cache entry for the billing function may have expired between the first run and the retry. On a cold runner, the billing function executes from scratch: the container starts, reads the Stripe key from the mounted secret, and calls stripe.charges.create() a second time. Without an idempotency key, Stripe creates a second charge. The CI job may show "billing step: OK" on the retry because the second charge succeeds, masking the fact that the customer was charged twice.
# .github/workflows/billing.yml — CI job retry creates cold-runner risk
name: Monthly Billing
on:
schedule:
- cron: "0 3 1 * *" # first of month at 03:00 UTC
workflow_dispatch:
jobs:
bill-customers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dagger/dagger-action@v0.12.0
- name: Run billing pipeline
run: |
dagger call billing charge-all-customers \
--stripe-secret=env:STRIPE_SECRET_KEY
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
# GitHub Actions re-run failed jobs puts this step on a fresh runner
# Dagger cache on the new runner: COLD — billing function runs from scratch
# Without idempotency key: Stripe creates duplicate charge on re-run
The content-hash idempotency key added in failure mode 1 closes this scenario as well. A cold runner re-executes the billing function, derives the same idempotency key from the same billing inputs, and Stripe returns the original charge object without creating a new charge. The pipeline completes cleanly, the downstream step runs with the original charge ID, and the CI job succeeds without creating any duplicate charges. The idempotency key is the only safety layer that works regardless of Dagger cache state, CI runner identity, or cache TTL — making it the mandatory fix for all three failure modes.
# The same _billing_idempotency_key() function from failure mode 1
# is all that is needed to close the cold-runner retry scenario.
# Dagger's layer cache is a latency optimization; the idempotency key
# is the correctness guarantee. Never conflate the two.
# Verify the charge was created exactly once before proceeding downstream:
charge = client.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
idempotency_key=idem_key,
)
# charge.id is stable across all retry scenarios — same ch_xxx every time
assert charge.id.startswith("ch_"), f"Unexpected charge response: {charge}"
return charge.id
Governance approach comparison
| Approach | Cache-bypass safety | Fan-out cap | Scope limit | Audit trail | Dagger integration |
|---|---|---|---|---|---|
Raw stripe.api_key, no idempotency |
None — cold runner or --no-cache double-charges |
None — unrestricted key | None — full API access | None beyond Stripe dashboard | Default; unsafe |
| Content-hash idempotency key only | Full — cache state irrelevant | None — still unrestricted key | None | Stripe idempotency log | Derive key inside @function |
| Stripe restricted key (dashboard) | Partial — idempotency key still required | No per-invocation cap | Endpoint-level only | Stripe restricted key log | Pass as dag.set_secret() |
| Per-invocation vault keys (Keybrake) | Full with idempotency key | Per-customer USD cap at proxy | Endpoint + amount per invocation | Keybrake audit log with pipeline context | Issue keys before asyncio.gather() |
| Vault keys + content-hash idempotency | Full — both layers independently safe | Per-customer cap + unit-bug protection | Tightest — endpoint + amount + TTL | Both Stripe and Keybrake logs | Recommended for all Dagger billing pipelines |
Gap analysis: four Dagger billing edge cases the main patterns don't cover
1. Concurrent Dagger Cloud cache writes from simultaneous CI runs create a race window. Two CI jobs triggered simultaneously (e.g., a push to main and a manual workflow_dispatch at the same instant) both start with a cold cache and both begin executing the billing function before either has written its result to Dagger Cloud's shared cache. Dagger's cache is written at function completion, not at function start: there is no in-flight deduplication for concurrent callers. Both billing containers reach Stripe before either caches its result. Without an idempotency key, both create separate charges. The content-hash idempotency key closes this race: both containers send the same idempotency key to Stripe, and Stripe's idempotency layer handles the race on its side — one call creates the charge, the other receives the original charge object. Add a concurrency group in your CI configuration (concurrency: billing-${{ github.ref }}) to serialize billing jobs and eliminate the race at the source.
2. withExec() billing scripts read STRIPE_SECRET_KEY as an environment variable instead of dag.set_secret(), exposing keys in pipeline output. When billing logic runs inside a container.withExec(["python", "bill.py"]) shell step rather than a typed Dagger function, developers sometimes pass the Stripe key via container.withEnvVariable("STRIPE_SECRET_KEY", key) rather than mounting it as a Dagger secret. Environment variables set via withEnvVariable() are not automatically redacted in Dagger's --progress=plain output: the key value appears verbatim in CI logs that may be retained for days or weeks. Additionally, withEnvVariable() does not scope the key per-container for parallel fan-outs — all containers in a withExec() batch inherit the same value. Always use dag.set_secret() and mount secrets via container.withSecretVariable() for any credential passed to a Dagger container, including Stripe keys and Keybrake vault keys.
3. Dagger module version drift during a billing run produces different idempotency keys on retry. When a billing module is updated between a first run and its CI retry — for example, a developer pushes a new module version that changes the idempotency key derivation logic (different salt, different hash length, different field ordering) — the retry produces a different idempotency key than the original run. Stripe's idempotency layer no longer recognizes the retry as a duplicate: it creates a new charge. This scenario is rare in a healthy workflow but becomes a real risk when billing modules are actively developed during a billing run or when the billing module version is not pinned. Pin Dagger module versions in billing pipelines with the module's content digest (dagger install billing@sha256:abc123...) and enforce the pin in CI with a pre-run assertion that the resolved module digest matches the expected value.
4. Dagger function timeout during a billing call creates an ambiguous state that upstream callers treat as a failure. When a Dagger function call exceeds its configured timeout — either the Dagger client's default or an explicit ctx, cancel = context.WithTimeout(ctx, 30*time.Second) in Go callers — the function call returns a timeout error to the caller. But the Stripe HTTP request may have already been submitted before the timeout fired: the charge was created, Stripe returned a 200 response, but the Dagger function container was still processing the response (writing to the database, constructing the return value) when the timeout killed it. The caller receives a timeout error and treats the billing step as failed, potentially retrying. Without an idempotency key in the billing function, the retry creates a second charge. The content-hash idempotency key is the correct fix here — not increasing the timeout. The root cause is the missing idempotency guarantee, not the timeout threshold.
Enforcement: five pytest tests for Dagger billing pipelines
import hashlib
import pytest
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch, call
def make_idem_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:dagger-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def test_idempotency_key_stable_across_cache_scenarios():
"""Same billing inputs always produce the same idempotency key."""
key1 = make_idem_key("cus_001", 4999, "2026-07")
key2 = make_idem_key("cus_001", 4999, "2026-07")
assert key1 == key2
assert len(key1) == 32
def test_idempotency_key_distinct_per_customer_and_period():
"""Different customers and periods produce distinct idempotency keys."""
key_a = make_idem_key("cus_001", 4999, "2026-07")
key_b = make_idem_key("cus_002", 4999, "2026-07")
key_c = make_idem_key("cus_001", 4999, "2026-08")
assert key_a != key_b
assert key_a != key_c
assert key_b != key_c
def test_no_cache_scenario_sends_same_idempotency_key():
"""Billing function sends same idempotency key on warm-cache and cold-cache runs."""
# Simulate warm cache: billing function body executes
key_warm = make_idem_key("cus_001", 4999, "2026-07")
# Simulate cold cache / --no-cache: billing function body executes again
key_cold = make_idem_key("cus_001", 4999, "2026-07")
# Both runs send the same key — Stripe returns original charge on second call
assert key_warm == key_cold
def test_per_customer_vault_key_cap_is_amount_plus_ten_percent():
"""Vault key issued per customer caps at amount × 1.10."""
import httpx
async def _run():
with patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock) as mock_post:
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"vault_key": "vk_live_test_abc"}
mock_post.return_value = mock_response
from billing_pipeline import issue_vault_key
await issue_vault_key("cus_001", 4999, "2026-07")
call_json = mock_post.call_args.kwargs["json"]
# cap = $49.99 × 1.10 = $54.989 ≈ $54.99
assert call_json["daily_usd_cap"] == pytest.approx(4999 * 1.10 / 100, rel=0.01)
assert call_json["allowed_endpoints"] == ["POST /v1/charges"]
asyncio.run(_run())
def test_vault_keys_issued_before_fan_out_starts():
"""All vault keys are issued sequentially before asyncio.gather() fan-out."""
issuance_order = []
gather_order = []
customers = [
{"customer_id": "cus_001", "amount_cents": 4999},
{"customer_id": "cus_002", "amount_cents": 9999},
]
async def mock_issue_vault_key(customer_id, amount_cents, billing_period):
issuance_order.append(customer_id)
return f"vk_test_{customer_id}"
async def mock_charge(client_obj, customer_id, **kwargs):
gather_order.append(customer_id)
return f"ch_{customer_id}"
# Verify that all issuance_order entries precede gather_order entries
# This is enforced structurally: vault_keys dict is built before tasks list
async def _run():
for c in customers:
await mock_issue_vault_key(c["customer_id"], c["amount_cents"], "2026-07")
for c in customers:
await mock_charge(None, c["customer_id"])
asyncio.run(_run())
# Every issuance must precede every gather call
assert len(issuance_order) == len(customers)
assert set(issuance_order) == {c["customer_id"] for c in customers}
assert issuance_order != gather_order or len(customers) == 0
Frequently asked questions
Does Dagger's layer cache make billing pipelines safe to re-run without idempotency keys?
No. Dagger's layer cache is an optimization that makes re-runs faster; it is not a correctness guarantee for billing. The cache is bypassed when the CI runner is cold, when Dagger Cloud's cache expires, when --no-cache is passed, or when the function's input hash changes (even slightly). A content-hash idempotency key derived inside the billing function is the only guarantee that works regardless of cache state. Think of the cache as "usually prevents a redundant Stripe call" and the idempotency key as "always prevents a duplicate charge" — both are needed, and the idempotency key is the one you can't skip.
Should I use dag.set_secret() or dag.cache_volume() for billing deduplication?
dag.set_secret() is the correct way to pass the Stripe key or vault key to a Dagger function — it redacts the value from pipeline output and scopes it to the container. dag.cache_volume() is a shared filesystem mount for caching build artifacts, not a transactional deduplication store: it has no atomicity guarantees, and two concurrent Dagger runs can both read a cache volume as "customer not yet billed" and both proceed to call Stripe simultaneously. Never use a cache volume or any Dagger-internal artifact as a billing dedup mechanism. Use Stripe's idempotency key (Stripe-side guarantee) and your own database with INSERT OR IGNORE (application-side guarantee).
What happens if the Keybrake proxy is unreachable when a Dagger billing function executes?
The stripe.Stripe(base_url=proxy_url) client will fail with a connection error because the proxy is the configured base URL for all Stripe calls. The billing function raises, the Dagger step fails, and the pipeline is marked as failed. No charge is created. This is the correct behavior: billing should not fall through to the real Stripe API when the governance proxy is unavailable. Do not configure a fallback to api.stripe.com — that fallback bypasses the spend cap and defeats the entire governance layer. A failing pipeline that bills no customers is always better than a succeeding pipeline that bills all customers with an unrestricted key.
How do per-customer vault keys interact with Dagger's secret deduplication?
Dagger deduplicates secrets by name within a pipeline run: two calls to client.set_secret("STRIPE_KEY", value) with the same name but different values produce an error. Use distinct secret names per customer — client.set_secret(f"vault_key_{customer_id}", vault_key) — to avoid this collision. Each secret is independently scoped to its invocation's container and does not leak to other containers in the fan-out. If you have more than ~100 customers in a single fan-out, consider batching them into groups and issuing one vault key per batch with a cap set to the batch's total expected billing amount.
Can I pin the Dagger billing module version to prevent idempotency key drift on retries?
Yes, and you should. Run dagger install billing@sha256:<digest> to pin the billing module to a specific content digest in your dagger.json. Add a pre-run CI check that verifies the resolved module digest matches the expected value before executing any billing steps. When upgrading the billing module, treat the version upgrade as a separate deployment that runs outside active billing windows — never upgrade a billing module mid-run or between a first run and a pending retry. The content-hash idempotency key derivation logic must be identical between the original run and any retry for Stripe's idempotency cache to recognize them as the same logical operation.
How should I handle Dagger function timeouts in billing pipelines?
Increase timeouts conservatively, not aggressively. A billing function that times out may have already submitted the Stripe charge — the HTTP request completed, Stripe charged the customer, but the function's container was killed before returning. The caller receives a timeout error and may retry. With a content-hash idempotency key, the retry is safe: Stripe returns the original charge without creating a new one. Without an idempotency key, a timeout + retry creates a duplicate charge. Set billing function timeouts to at least 3× the expected p99 Stripe API response time (typically 3–5 seconds, so 15+ seconds is reasonable), and always treat a timeout as "charge state unknown, retry with same idempotency key" rather than "charge definitely failed, create a new one".
Keybrake: per-invocation vault keys for Dagger billing pipelines
Issue a scoped vault key for each customer before your asyncio.gather() fan-out. Each key is capped at the customer's expected charge amount, expires after the pipeline window, and is logged to an immutable audit trail. One line to swap stripe.Stripe(base_url=...) — no Dagger module changes required.