Argo Workflows Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Argo Workflows is the dominant Kubernetes-native workflow orchestrator for production AI agent pipelines, ML training jobs, and automated billing runs. Its execution model — retry failed steps, fan out over lists with withItems, schedule recurring runs with CronWorkflow — is designed for fault-tolerant automation at scale. Those same behaviors create specific billing hazards when Stripe API calls live inside an Argo step: a container step with retryStrategy re-runs the entire billing script from line 1 if any exit occurs after stripe.charges.create() already succeeded; withItems fan-out shares one unrestricted Stripe key across N parallel container instances with no per-item spend cap; and a CronWorkflow without concurrencyPolicy: Forbid allows two overlapping billing runs to charge the same customers simultaneously.
This post covers three Argo Workflows-specific Stripe billing failure modes, the Python and YAML code that triggers each one, and the two-layer governance pattern — content-hash idempotency keys and per-item vault keys via a spend-cap proxy — that eliminates all three without changing your workflow's core logic.
Failure mode 1: retryStrategy re-executes the billing container from line 1 after stripe.charges.create() already succeeded
Argo Workflows retries a step by re-running its container from the beginning. When a step template includes a retryStrategy block, any non-zero container exit code triggers a retry — Argo kills the original container and starts a fresh one. There is no mechanism to resume a container mid-execution: if stripe.charges.create() returned a successful charge ID on line 15 of your billing script and a subsequent database write raised an exception that caused the script to exit non-zero on line 23, the retry starts a brand new container and calls stripe.charges.create() again with the same arguments and no idempotency key.
# billing_step.py — UNSAFE: Argo retryStrategy re-fires stripe.charges.create() on write error
import stripe
import os
import sys
stripe.api_key = os.environ["STRIPE_SECRET_KEY"] # unrestricted live key
def run(customer_id: str, amount_cents: int, billing_period: str):
# Argo retry restarts the container here — no idempotency key, no guard
charge = stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
)
# Transient DB error → container exits non-zero → Argo retries → ch_B created
write_to_database(customer_id, charge.id, billing_period, amount_cents)
print(f"charged {customer_id}: {charge.id}")
if __name__ == "__main__":
run(
customer_id=os.environ["CUSTOMER_ID"],
amount_cents=int(os.environ["AMOUNT_CENTS"]),
billing_period=os.environ["BILLING_PERIOD"],
)
# workflow.yaml — UNSAFE: retryStrategy fires billing container again on any non-zero exit
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: billing-
spec:
entrypoint: charge-customer
templates:
- name: charge-customer
retryStrategy:
limit: "3"
retryPolicy: "Always"
container:
image: myrepo/billing:latest
command: [python, billing_step.py]
env:
- name: STRIPE_SECRET_KEY
valueFrom:
secretKeyRef:
name: stripe-credentials
key: secret-key
- name: CUSTOMER_ID
value: "cus_abc123"
- name: AMOUNT_CENTS
value: "4999"
- name: BILLING_PERIOD
value: "2026-07"
On the retry, Stripe has no record of a previous idempotency key for this call — none was sent — so it treats the request as a new charge and creates ch_B for the same customer, same amount, same billing period. With retryStrategy.limit: "3", a single database outage can produce up to four charges per customer: one from the first attempt and one per retry. Argo's workflow logs show each container restart as expected fault-tolerance behavior; the duplicate charges are invisible until a customer disputes a payment or a reconciliation job compares your database billing records to Stripe's charges export.
The fix is to derive a content-hash idempotency key from the billing inputs — customer_id, amount_cents, and billing_period — and pass it with every stripe.charges.create() call. Because Argo passes the same environment variables to every container retry for a given step, the key is identical on every attempt. Stripe's idempotency layer returns the original ch_A on all subsequent calls without creating a new charge.
# billing_step.py — SAFE: content-hash idempotency key survives all Argo container retries
import stripe
import hashlib
import os
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:argo-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def run(customer_id: str, amount_cents: int, billing_period: str):
idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
# Same key on every Argo retry — Stripe returns ch_A without creating ch_B, ch_C, ch_D
charge = stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
idempotency_key=idempotency_key,
)
write_to_database(customer_id, charge.id, billing_period, amount_cents)
print(f"charged {customer_id}: {charge.id}")
if __name__ == "__main__":
run(
customer_id=os.environ["CUSTOMER_ID"],
amount_cents=int(os.environ["AMOUNT_CENTS"]),
billing_period=os.environ["BILLING_PERIOD"],
)
Two additional improvements pair well with this: scope the Stripe key to POST /v1/charges only, so a retry cannot accidentally call refund or subscription endpoints regardless of script behavior. And add a pre-flight check at the top of the billing script that queries the audit log for an existing charge for this customer and billing period before calling Stripe — if a charge already exists, return its ID immediately and exit zero without calling Stripe at all. This is a defense-in-depth layer on top of Stripe's idempotency, not a replacement for it.
Failure mode 2: withItems fan-out runs N concurrent container instances sharing one unrestricted Stripe key with no per-item spend cap
Argo's withItems and withParam directives expand a step into N parallel instances, one per item in the list. All N containers inherit the same environment variables from the step template — including STRIPE_SECRET_KEY from the Kubernetes secret reference. There is no per-item spend cap: if a billing amount calculation has a bug — for example, passing amount_dollars where amount_cents is expected, producing charges 100× larger than intended — all N containers call stripe.charges.create() with the wrong amount simultaneously. By the time the first error surfaces, potentially hundreds of customers have been overcharged with no circuit breaker to stop the remaining items.
# workflow.yaml — UNSAFE: withItems fan-out shares one Stripe key, no per-item spend cap
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: batch-billing-
spec:
entrypoint: charge-all-customers
arguments:
parameters:
- name: customers
value: |
[
{"customer_id": "cus_001", "amount_cents": 4999, "billing_period": "2026-07"},
{"customer_id": "cus_002", "amount_cents": 9999, "billing_period": "2026-07"},
{"customer_id": "cus_003", "amount_cents": 4999, "billing_period": "2026-07"}
]
templates:
- name: charge-all-customers
steps:
- - name: charge-customer
template: charge-one-customer
arguments:
parameters:
- name: customer_id
value: "{{item.customer_id}}"
- name: amount_cents
value: "{{item.amount_cents}}"
- name: billing_period
value: "{{item.billing_period}}"
withParam: "{{workflow.parameters.customers}}"
- name: charge-one-customer
inputs:
parameters:
- name: customer_id
- name: amount_cents
- name: billing_period
container:
image: myrepo/billing:latest
command: [python, billing_step.py]
env:
- name: STRIPE_SECRET_KEY
valueFrom:
secretKeyRef:
name: stripe-credentials
key: secret-key # same key injected into all N parallel containers
- name: CUSTOMER_ID
value: "{{inputs.parameters.customer_id}}"
- name: AMOUNT_CENTS
value: "{{inputs.parameters.amount_cents}}"
- name: BILLING_PERIOD
value: "{{inputs.parameters.billing_period}}"
The dangerous scenario is a unit bug in the upstream system that generates the customer list: if amount_cents is computed as amount_dollars * 1 instead of amount_dollars * 100, all N containers call stripe.charges.create(amount=49) instead of amount=4999 — off by 100×. Because all containers start simultaneously and there is no cross-item coordination, every customer in the batch receives the wrong charge before any human can intervene. A spend cap set on the Stripe key itself (via a restricted key) does not help because the key is shared across all items and the cumulative spend may still be within a reasonable-looking total even though each individual charge is wrong.
The fix is to issue a per-item vault key before the fan-out and pass it as a workflow parameter to each container instance. Each vault key is scoped to POST /v1/charges and capped at the individual customer's expected charge amount plus a 10% buffer. A unit bug that produces a 100× charge exhausts the vault key's cap on the first call and the proxy blocks all subsequent calls from that container — including any retries — before a second customer is charged.
# issue_vault_keys.py — run as a preceding Argo step before the fan-out
import httpx
import json
import os
import hashlib
KEYBRAKE_ADMIN_KEY = os.environ["KEYBRAKE_ADMIN_KEY"]
KEYBRAKE_PROXY_URL = "https://proxy.keybrake.com"
def issue_vault_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
resp = httpx.post(
f"{KEYBRAKE_PROXY_URL}/keys",
headers={"Authorization": f"Bearer {KEYBRAKE_ADMIN_KEY}"},
json={
"label": f"argo-{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"]
def main():
customers = json.loads(os.environ["CUSTOMERS_JSON"])
keyed = []
for c in customers:
vault_key = issue_vault_key(
customer_id=c["customer_id"],
amount_cents=c["amount_cents"],
billing_period=c["billing_period"],
)
keyed.append({**c, "vault_key": vault_key})
# Write to Argo output parameter for the next step to consume
with open("/tmp/customers_with_keys.json", "w") as f:
json.dump(keyed, f)
print(json.dumps(keyed))
if __name__ == "__main__":
main()
# billing_step.py — SAFE: uses per-item vault key passed from the preceding step
import stripe
import hashlib
import os
KEYBRAKE_PROXY_URL = os.environ.get("KEYBRAKE_PROXY_URL", "https://proxy.keybrake.com")
# vault_key injected per-item by Argo — capped at this customer's amount × 1.10
stripe.api_key = os.environ["VAULT_KEY"]
stripe.api_base = f"{KEYBRAKE_PROXY_URL}/stripe"
def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:argo-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def run(customer_id: str, amount_cents: int, billing_period: str):
idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
charge = stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
idempotency_key=idempotency_key,
)
write_to_database(customer_id, charge.id, billing_period, amount_cents)
print(f"charged {customer_id}: {charge.id}")
if __name__ == "__main__":
run(
customer_id=os.environ["CUSTOMER_ID"],
amount_cents=int(os.environ["AMOUNT_CENTS"]),
billing_period=os.environ["BILLING_PERIOD"],
)
Failure mode 3: CronWorkflow without concurrencyPolicy: Forbid fires two overlapping billing runs
Argo's CronWorkflow creates a new Workflow object on each scheduled trigger. By default, concurrencyPolicy is Allow, which means if the previous billing workflow is still running when the next cron trigger fires — because billing took longer than expected due to Stripe rate limiting, slow database writes, or a large customer list — Argo creates a second independent workflow in parallel. Both workflows iterate over the same customer list. Both reach the billing step. Both call stripe.charges.create() for every customer, creating duplicate charges for the entire overlap period. The two workflows appear in the Argo UI as separate runs with no visible signal that they are charging the same customers simultaneously.
# cronworkflow.yaml — UNSAFE: concurrencyPolicy: Allow lets two billing runs overlap
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
name: monthly-billing
spec:
schedule: "0 0 1 * *" # 00:00 UTC on the 1st of each month
timezone: "UTC"
# concurrencyPolicy defaults to Allow — second run starts if first is still running
workflowSpec:
entrypoint: run-billing
templates:
- name: run-billing
steps:
- - name: fetch-customers
template: fetch-customers-template
- - name: charge-customers
template: charge-one-customer
withParam: "{{steps.fetch-customers.outputs.result}}"
arguments:
parameters:
- name: customer_json
value: "{{item}}"
- name: charge-one-customer
inputs:
parameters:
- name: customer_json
container:
image: myrepo/billing:latest
command: [python, billing_step.py]
env:
- name: STRIPE_SECRET_KEY
valueFrom:
secretKeyRef:
name: stripe-credentials
key: secret-key
- name: CUSTOMER_JSON
value: "{{inputs.parameters.customer_json}}"
The overlap scenario is more common than it appears. Monthly billing workflows that process thousands of customers, wait on Stripe rate limit delays, and write to large databases can take 15–30 minutes to complete. A cron schedule set to 0 0 1 * * fires once per month, which seems safe — but a single infrastructure hiccup (a transient Kubernetes node failure that triggers step retries, a Stripe API timeout that backs off for several minutes) can extend runtime past 24 hours. If a second trigger fires the next day while the first is still running because retryStrategy is still working through failures, both runs bill the same customers for the same month.
The immediate fix is concurrencyPolicy: Forbid, which causes Argo to skip the new trigger entirely if a workflow from the same CronWorkflow is already running. Pair this with an idempotency key derived from the billing period so that even if Forbid is ever removed or changed, a duplicate run cannot create a second charge for the same customer and month.
# cronworkflow.yaml — SAFE: concurrencyPolicy Forbid + per-item vault keys + idempotency keys
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
name: monthly-billing
spec:
schedule: "0 0 1 * *"
timezone: "UTC"
concurrencyPolicy: "Forbid" # skip trigger if previous run is still active
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
workflowSpec:
entrypoint: run-billing
templates:
- name: run-billing
steps:
- - name: issue-vault-keys
template: issue-keys-template
- - name: charge-customers
template: charge-one-customer
withParam: "{{steps.issue-vault-keys.outputs.result}}"
arguments:
parameters:
- name: customer_json
value: "{{item}}"
- name: charge-one-customer
inputs:
parameters:
- name: customer_json
container:
image: myrepo/billing:latest
command: [python, billing_step_safe.py]
env:
- name: CUSTOMER_JSON
value: "{{inputs.parameters.customer_json}}"
# vault_key and proxy_url come from the customer_json parameter —
# each item in the list has its own scoped key issued in the preceding step
Approach comparison
| Approach | retryStrategy safe | withItems fan-out safe | CronWorkflow overlap safe | Spend cap | Audit log | One-click revoke |
|---|---|---|---|---|---|---|
| No idempotency key (default) | No | No | No | No | No | No |
| Content-hash idempotency key only | Yes | Yes | Yes | No | No | No |
concurrencyPolicy: Forbid only |
No | No | Yes (prevents overlap) | No | No | No |
| Idempotency key + per-item vault key via Keybrake proxy | Yes | Yes | Yes | Yes — per item | Yes — every call | Yes — instant |
Gap analysis: four more Argo Workflows billing risks
1. DAG-level retryStrategy re-runs the entire DAG including billing steps that already completed
When retryStrategy is set on a DAG template (not on an individual step), any step failure triggers a full DAG re-execution starting from the first node. Steps that completed successfully in the first attempt — including billing steps — are re-executed in the retry run. Argo does not skip successfully completed DAG steps on retry by default; it treats the DAG as a single unit. Without idempotency keys, a transient failure in a non-billing step (a metrics reporting step, a notification step) causes every billing step in the DAG to run a second time. Idempotency keys close this at the Stripe layer, but also consider restructuring DAGs so billing steps are terminal nodes with no downstream steps that can fail and trigger a DAG-level retry of the billing node.
2. activeDeadlineSeconds timeout kills the workflow and marks billing steps as failed even if the container exited zero
Argo's activeDeadlineSeconds enforces a wall-clock deadline on the entire workflow. When the deadline expires, Argo sends SIGTERM to all running pods and marks the workflow as timed out. A billing container that had already exited zero (success) but whose result had not yet been fully recorded by the Argo controller at the time of the deadline kill is sometimes marked as failed in the workflow status. If the workflow is re-submitted as a result of the timeout, the billing step re-runs. With idempotency keys, the re-run returns the existing charge. Without them, a second charge is created. Never re-submit a timed-out billing workflow without first querying the audit log to confirm which customers were already charged.
3. Argo's exit handler template fires even after a billing step succeeded, and can issue additional Stripe charges as a compensating action
Argo's onExit handler runs regardless of workflow outcome — success, failure, or timeout. Exit handlers are commonly used for cleanup and notification, but teams sometimes add a compensating Stripe charge in the exit handler: a partial refund if the workflow failed, or a penalty charge for SLA violations. If the billing step succeeded and created ch_A, and the exit handler fires because a downstream notification step failed, the exit handler may create an additional charge or refund against the same customer. Scope exit handler vault keys strictly to the specific endpoint and amount range appropriate for the compensating action — a separate vault key scoped to POST /v1/refunds with a cap equal to the individual charge amount, not a copy of the main billing key that allows POST /v1/charges.
4. withSequence generates position-derived IDs that are not stable across workflow re-submissions with reordered input
Argo's withSequence directive generates a numeric sequence (0, 1, 2, …, N) that can be used to index into a customer list passed as a workflow parameter. A pattern sometimes seen in billing workflows uses the sequence index as part of an idempotency key: f"billing-{index}-{billing_period}". This is incorrect because the sequence index is a position in the input list at workflow submission time, not a stable property of the customer. If the same workflow is re-submitted with a reordered customer list — due to a database query returning rows in a different order — customer cus_abc may appear at index 3 in one run and index 7 in a re-run, producing different idempotency keys and a second charge. Always derive idempotency keys from customer data (customer_id, amount_cents, billing_period), never from list position.
Enforcement with pytest
# test_argo_billing.py
import hashlib
import json
import pytest
from unittest.mock import patch, MagicMock
def billing_idempotency_key(customer_id: str, amount_cents: int, billing_period: str) -> str:
raw = f"{customer_id}:{amount_cents}:{billing_period}:argo-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def test_idempotency_key_stable_across_container_restarts():
"""Same billing inputs always produce the same key regardless of retry count."""
key_1 = billing_idempotency_key("cus_abc", 4999, "2026-07")
key_2 = billing_idempotency_key("cus_abc", 4999, "2026-07")
key_3 = billing_idempotency_key("cus_abc", 4999, "2026-07")
assert key_1 == key_2 == key_3
def test_idempotency_key_distinct_per_billing_period():
"""Different billing periods produce different keys — no cross-period dedup."""
key_july = billing_idempotency_key("cus_abc", 4999, "2026-07")
key_august = billing_idempotency_key("cus_abc", 4999, "2026-08")
assert key_july != key_august
def test_idempotency_key_distinct_per_customer():
"""Different customers in the same withItems fan-out get independent keys."""
key_a = billing_idempotency_key("cus_abc", 4999, "2026-07")
key_b = billing_idempotency_key("cus_xyz", 4999, "2026-07")
assert key_a != key_b
def test_vault_key_rejects_non_charge_endpoint():
"""Per-item vault key scoped to POST /v1/charges cannot call POST /v1/refunds."""
with patch("stripe.Refund.create") as mock_refund:
mock_refund.side_effect = Exception(
"403 Forbidden: vault key not authorized for POST /v1/refunds"
)
with pytest.raises(Exception, match="not authorized"):
import stripe
stripe.Refund.create(charge="ch_test_abc")
def test_withsequence_index_not_used_as_idempotency_key():
"""Idempotency key must not include list position — only stable customer data."""
customers = [
{"customer_id": "cus_abc", "amount_cents": 4999, "billing_period": "2026-07"},
{"customer_id": "cus_xyz", "amount_cents": 9999, "billing_period": "2026-07"},
]
# Simulate re-submission with reordered list
customers_reordered = list(reversed(customers))
keys_original = [billing_idempotency_key(c["customer_id"], c["amount_cents"], c["billing_period"]) for c in customers]
keys_reordered = [billing_idempotency_key(c["customer_id"], c["amount_cents"], c["billing_period"]) for c in customers_reordered]
# Each customer's key must be identical regardless of list order
assert set(keys_original) == set(keys_reordered)
FAQ
Is the Argo workflow.uid or step node ID safe to use as an idempotency key component?
No. workflow.uid is unique per workflow submission — a new workflow created by a re-submission or a CronWorkflow trigger has a different UID, which produces a different idempotency key and allows a second charge for the same customer and billing period. Argo step node IDs are internal execution identifiers tied to the workflow graph, not to the underlying business data. Use content-hash keys derived from your billing inputs — customer_id, amount_cents, billing_period — which are stable across workflow re-submissions, retries, and CronWorkflow duplicate triggers as long as the input data is the same.
How do per-item vault keys work with Argo's Kubernetes secret management?
Kubernetes secrets are the right place to store the Keybrake admin key used to issue per-item vault keys in the pre-fan-out step. The per-item vault keys themselves are short-lived (1-hour TTL) and are passed directly as Argo workflow parameters to each container instance — they do not need to be stored in Kubernetes secrets. Pass them via withParam items (each item JSON object includes a vault_key field), which Argo injects into the container as an environment variable. Argo stores workflow parameters in its internal etcd state, so the vault key is visible in the Argo UI workflow detail view — use a short enough TTL that an expired key displayed in the UI cannot be reused by an attacker.
Should vault keys be issued in an init container or in a separate Argo step before the billing fan-out?
A separate Argo step is preferable for two reasons. First, init container output is not accessible to Argo's parameter substitution system — you cannot pass a value computed in an init container to a sibling container's environment variable via Argo's {{steps.step-name.outputs.result}} mechanism. Second, a separate step appears in the Argo workflow graph with its own status, logs, and retry behavior, making it easier to audit whether vault key issuance succeeded before the billing fan-out began. Use an init container only if you need to set up filesystem state (certificates, configuration files) that the billing container reads at startup — not for runtime values passed between steps.
What happens if the Keybrake proxy is unreachable when an Argo billing container calls stripe.charges.create()?
The Stripe call fails with a connection error and the container exits non-zero. Argo's retryStrategy retries the container. Because the idempotency key is derived from stable billing inputs, the retry uses the same key. If the proxy recovers before the retry budget is exhausted, the retry succeeds — and if the charge was created on a prior attempt before the proxy went down, Stripe returns the existing ch_A rather than creating ch_B. If the proxy does not recover within the retry budget, the step fails and the workflow fails. No charge is created or duplicated during the outage window. Configure your Argo retryStrategy with exponential backoff (backoff.duration: 30s, backoff.factor: 2) to give the proxy time to recover before each retry.
How do workflow.parameters interact with idempotency keys when a CronWorkflow re-submits with the same parameters?
CronWorkflow triggers pass the same workflowSpec.arguments.parameters values on every trigger unless the spec is updated between runs. If your billing period parameter is a static value like 2026-07 rather than a dynamically computed value like {{= sprig.now() | sprig.dateModify "-1 month" | sprig.date "2006-01" }}, two consecutive CronWorkflow triggers in the same billing month produce workflows with identical parameters. Content-hash idempotency keys derived from these parameters are also identical, so Stripe returns the existing charge for each customer on the duplicate run. This is the correct behavior — idempotency keys function as a billing deduplication layer even when concurrencyPolicy: Forbid is missing or temporarily overridden.
Can Argo's built-in memoize feature cache the vault key issuance step to avoid re-issuing on retries?
Argo's memoize caches step outputs keyed by the step's input parameter values and stores results in a Kubernetes ConfigMap or an external cache. Caching vault key issuance is counterproductive: a cached vault key may be expired (TTL elapsed since the first run), may have exhausted its spend cap (if the first run completed), or may have been revoked manually. Never cache vault key issuance results — always issue a fresh key at the start of each billing workflow run. The vault key issuance step is fast (< 200ms) and does not benefit from caching. Memoize is appropriate for expensive, pure computation steps (model inference, data aggregation) but not for side-effectful operations like key issuance.
Issue scoped vault keys before your Argo billing fan-out
Keybrake issues short-lived per-item vault keys your Argo containers can use as Stripe API keys. Each key is scoped to specific endpoints, capped at a daily USD limit sized for that customer's expected charge, and auto-expires after your workflow run window. Every proxied call is logged to a queryable audit table. One-click revoke stops a runaway billing workflow without touching your Stripe account settings or rotating secrets in your cluster.
Related: Apache Beam · Dagster · Prefect · Airflow · Flyte · Ray · Kestra · Stripe restricted key permissions reference