Kestra Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Kestra's retry: maxAttempts: 3 block is the standard way to make a billing task resilient. It is also a reliable mechanism for firing three or four identical Stripe charges when any code after the charge raises an exception in the Python Script body.
Kestra is an open-source, YAML-first orchestration platform designed for teams who want declarative workflow definitions with pluggable task types — Python scripts, HTTP calls, SQL queries, and hundreds of community plugins, all composed in flow YAML and scheduled or triggered from a clean UI. When those flows include Stripe billing — subscription renewals, usage-based charges, agent-triggered payments — Kestra's task retry block, EachParallel concurrency model, and Schedule trigger introduce failure modes that pass every local test and produce duplicate charges silently in production.
This post covers three failure modes specific to Kestra's architecture: task-level retry: maxAttempts: N re-running the entire Python Script including the Stripe charge on any downstream exception, EachParallel executing billing scripts in parallel for each customer while all share one unrestricted Stripe key with no per-item spend cap, and a Schedule trigger without concurrencyLimit: 1 spawning a second billing execution before the first has finished. Each section includes Kestra YAML and Python code, and the governance pattern that closes it — content-hash idempotency keys at the Stripe layer and per-execution vault keys via the Keybrake proxy at the key-management layer. A gap analysis closes the post with four additional Kestra-specific edge cases.
Failure mode 1: retry: maxAttempts: N re-fires Stripe charge on downstream exception
Kestra's task retry block re-executes the entire task when it raises an unhandled exception. For a Python Script task, this means the Python interpreter is launched fresh from the top of the script on every retry attempt. Kestra has no concept of partial script completion — it cannot know that stripe.charges.create() on line 12 already returned successfully before line 28's database write raised an exception. The entire script is re-executed, including the Stripe call.
# UNSAFE: task retry re-fires stripe.charges.create() on any downstream failure
# kestra-flow.yml (partial)
#
# tasks:
# - id: charge_customer
# type: io.kestra.plugin.scripts.python.Script
# retry:
# type: exponential
# maxAttempts: 3
# interval: PT15S
# maxInterval: PT5M
# script: |
# ... (see below)
import stripe
import os
customer_id = "{{ inputs.customer_id }}"
amount_cents = int("{{ inputs.amount_cents }}")
billing_period = "{{ inputs.billing_period }}"
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
# Script creates ch_A — Stripe returns 200 OK
charge = stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
# No idempotency_key — every retry creates a new Stripe charge object
)
# If this DB write raises a connection error, Kestra retries the entire script:
# Retry 1: stripe.charges.create() fires again → ch_B (new charge, no dedup)
# Retry 2: → ch_C. Retry 3: → ch_D. Customer billed four times.
write_charge_to_database(customer_id, charge["id"], billing_period)
The failure chain: stripe.charges.create() returns ch_A. write_charge_to_database() raises a connection error — perhaps a PostgreSQL pool timeout or a transient network blip to the internal recording service. Kestra catches the unhandled exception, waits the configured interval, and re-launches the Python Script from line 1. On the first retry, stripe.charges.create() runs again with no record connecting this invocation to the original, and creates ch_B. With maxAttempts: 3 and a persistent database outage, the customer is charged four times: the original attempt plus three retries. Kestra's execution log shows three FAILED attempts followed by a terminal failure. The duplicate charges are visible only in the Stripe Dashboard, spaced apart by the exponential backoff interval.
The most dangerous characteristic of this failure mode is that it is triggered by the most common class of production incidents: transient database connectivity issues, internal service timeouts, and network blips. It is not triggered by Stripe errors — Stripe's API availability routinely exceeds any downstream service. The charge always succeeds before the failure. Kestra's retry guarantees exactly that the failure will be retried. Without an idempotency key, both properties together guarantee duplicate charges.
The fix: content-hash idempotency key + vault key per execution
The idempotency key must be derived from the billing parameters, not generated at script entry with uuid.uuid4(). A new UUID is generated on each script invocation, producing a different idempotency key on each retry — which defeats the entire purpose. A SHA-256 hash of (customer_id, amount_cents, billing_period) is stable across every retry of the same Kestra task execution, because those inputs are fixed in Kestra's flow context throughout the execution. Stripe deduplicates all retries back to the original ch_A regardless of how many times Kestra re-launches the script.
# SAFE: content-hash idempotency key + vault key per task execution
# Full Kestra flow YAML + Python script
# kestra-flow.yml
# id: charge-customer
# namespace: billing
# inputs:
# - id: customer_id
# type: STRING
# - id: amount_cents
# type: INT
# - id: billing_period
# type: STRING
# tasks:
# - id: charge_customer
# type: io.kestra.plugin.scripts.python.Script
# retry:
# type: exponential
# maxAttempts: 3
# interval: PT15S
# beforeCommands:
# - pip install stripe httpx
# script: |
# ... (see below)
import stripe
import hashlib
import httpx
import os
customer_id = "{{ inputs.customer_id }}"
amount_cents = int("{{ inputs.amount_cents }}")
billing_period = "{{ inputs.billing_period }}"
# Stable across all retries — derived from fixed billing parameters
def billing_idempotency_key(cid, amt, period):
raw = f"{cid}:{amt}:{period}:kestra-billing"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def issue_vault_key(cid, amt):
resp = httpx.post(
"https://proxy.keybrake.com/admin/vault_keys",
headers={"Authorization": f"Bearer {os.environ['KEYBRAKE_ADMIN_KEY']}"},
json={
"label": f"kestra-billing-{cid}",
"vendor": "stripe",
"allowed_endpoints": ["POST /v1/charges"],
"daily_usd_cap": round(amt / 100 * 1.1, 2), # cap at amount + 10%
"expires_in_seconds": 3600,
},
timeout=5.0,
)
resp.raise_for_status()
return resp.json()["vault_key"]
idempotency_key = billing_idempotency_key(customer_id, amount_cents, billing_period)
vault_key = issue_vault_key(customer_id, amount_cents)
stripe_client = stripe.StripeClient(
vault_key,
base_url="https://proxy.keybrake.com/stripe",
)
# Same key on every retry — Stripe returns ch_A without creating ch_B, ch_C, ch_D
charge = stripe_client.charges.create(
params={
"amount": amount_cents,
"currency": "usd",
"customer": customer_id,
"description": f"Subscription {billing_period}",
"metadata": {"billing_period": billing_period},
},
options={"idempotency_key": idempotency_key},
)
write_charge_to_database(customer_id, charge.id, billing_period)
Two additional improvements: the vault key is scoped to POST /v1/charges only, so the script cannot accidentally issue refunds or read unrelated customer data regardless of what Kestra passes as flow input. And the vault key's daily cap is set to slightly above the expected charge amount — a unit calculation bug producing the wrong amount_cents is blocked at the proxy after the first attempt, before all three retries exhaust themselves on the wrong amount.
Failure mode 2: EachParallel tasks share one Stripe key across all concurrent executions
Kestra's EachParallel flowable task is the idiomatic way to process a list of items in parallel — one sub-task execution per item, all running concurrently on available Kestra workers. A billing flow that processes a cohort of customers spawns one Python Script execution per customer, all running simultaneously. All of those script executions inherit the same worker environment variables, including STRIPE_SECRET_KEY. There is no per-item key scope, no per-item spend cap, and no mechanism in Kestra to halt the parallel execution when one item's charge returns an error before the others have started.
# UNSAFE: EachParallel tasks share one Stripe key, no per-item spend cap
# kestra-flow.yml (partial)
# tasks:
# - id: charge_all_customers
# type: io.kestra.plugin.core.flow.EachParallel
# value: "{{ inputs.customers }}" # JSON array of {id, amount_cents}
# tasks:
# - id: charge_single
# type: io.kestra.plugin.scripts.python.Script
# script: |
# ... (see below)
import stripe
import json
import os
# Each parallel execution receives its item as a Kestra variable
item = json.loads('{{ taskrun.value | json }}')
customer_id = item["id"]
amount_cents = item["amount_cents"] # BUG: dollars passed as cents upstream
billing_period = "{{ inputs.billing_period }}"
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
# All 200 parallel executions share STRIPE_SECRET_KEY with no per-customer cap.
# A unit calculation bug that passes dollars instead of cents charges every customer
# 100x the intended amount simultaneously — by the time the first error surfaces,
# the entire cohort has already been overbilled.
charge = stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
)
The concurrency amplifies the blast radius of any calculation error. A sequential billing loop would expose the wrong amount after the first customer's charge and surface an error before proceeding to the rest. EachParallel dispatches all N script executions before any results are available — by the time the first Stripe response reveals the wrong amount, all N charges are in flight. Kestra's execution view shows all parallel sub-tasks running simultaneously, and there is no built-in circuit breaker that aborts the parallel group when one sub-task returns a billing error. The error appears in the sub-task's output after the fact.
The fix: issue one vault key per customer before spawning the parallel tasks, and pass each vault key as a task-level input. Each key is scoped to POST /v1/charges and capped at that customer's expected amount plus a small buffer. A calculation error is rejected at the proxy for the first affected execution, limiting the blast radius to one customer regardless of how many parallel sub-tasks are running.
# SAFE: pre-issue one vault key per customer, pass into each EachParallel sub-task
# kestra-flow.yml (partial)
# tasks:
# - id: prepare_vault_keys
# type: io.kestra.plugin.scripts.python.Script
# outputFiles:
# - "vault_keys.json"
# script: |
# ... (issue_vault_keys.py)
#
# - id: charge_all_customers
# type: io.kestra.plugin.core.flow.EachParallel
# value: "{{ outputs.prepare_vault_keys.vars.keyed_customers }}"
# tasks:
# - id: charge_single
# type: io.kestra.plugin.scripts.python.Script
# retry:
# type: constant
# maxAttempts: 2
# interval: PT10S
# script: |
# ... (charge_single.py)
# === issue_vault_keys.py (prepare_vault_keys task) ===
import httpx
import json
import os
import hashlib
customers = json.loads('{{ inputs.customers | json }}')
billing_period = "{{ inputs.billing_period }}"
def issue_vault_key(cid, amt):
resp = httpx.post(
"https://proxy.keybrake.com/admin/vault_keys",
headers={"Authorization": f"Bearer {os.environ['KEYBRAKE_ADMIN_KEY']}"},
json={
"label": f"kestra-parallel-{cid}-{billing_period}",
"vendor": "stripe",
"allowed_endpoints": ["POST /v1/charges"],
"daily_usd_cap": round(amt / 100 * 1.1, 2),
"expires_in_seconds": 3600,
},
timeout=5.0,
)
resp.raise_for_status()
return resp.json()["vault_key"]
keyed = []
for c in customers:
vault_key = issue_vault_key(c["id"], c["amount_cents"])
idempotency_key = hashlib.sha256(
f"{c['id']}:{c['amount_cents']}:{billing_period}:kestra-parallel".encode()
).hexdigest()[:32]
keyed.append({**c, "vault_key": vault_key, "idempotency_key": idempotency_key})
# Kestra outputs: keyed list passed as EachParallel value
print(json.dumps({"keyed_customers": keyed}))
# === charge_single.py (each EachParallel sub-task) ===
import stripe
import json
item = json.loads('{{ taskrun.value | json }}')
stripe_client = stripe.StripeClient(
item["vault_key"], # scoped to this customer only
base_url="https://proxy.keybrake.com/stripe",
)
charge = stripe_client.charges.create(
params={
"amount": item["amount_cents"],
"currency": "usd",
"customer": item["id"],
"description": f"Subscription {{ inputs.billing_period }}",
"metadata": {"billing_period": "{{ inputs.billing_period }}"},
},
options={"idempotency_key": item["idempotency_key"]},
)
Each parallel sub-task receives a vault key scoped exclusively to its own customer's charge. If the amount calculation is wrong — dollars instead of cents, a proration rounding error, a currency conversion mistake — the proxy rejects the first affected charge with 429 Spend cap exceeded before the remaining customers in the parallel group have been affected. The idempotency key on each sub-task means that if the sub-task retries, Stripe returns the original charge ID rather than creating a new one.
Failure mode 3: Schedule trigger without concurrencyLimit fires duplicate billing runs
Kestra's Schedule trigger fires a new flow execution at each cron tick regardless of whether the previous execution is still running. A monthly billing flow set to cron: "0 9 1 * *" fires every month at 9 AM on the first. If a billing run takes longer than expected — due to Stripe rate limits throttling the request pace, a large customer cohort, or a downstream database bottleneck — and the next month's trigger fires before the current run completes, two executions run concurrently. Both iterate over the same customer list, hitting Stripe for the same customers during the overlap period.
# UNSAFE: Schedule trigger without concurrencyLimit, no overlap protection
# kestra-flow.yml
id: monthly-billing
namespace: billing
# Missing: concurrencyLimit: 1
# Without it, a second execution starts at the next cron tick
# even if the current execution is still running
triggers:
- id: monthly
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 9 1 * *"
tasks:
- id: charge_all
type: io.kestra.plugin.scripts.python.Script
script: |
import stripe, os, json
billing_period = "{{ trigger.date | date('yyyy-MM') }}"
customers = fetch_customers_due_for_billing(billing_period)
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
for customer in customers:
# If second execution starts while this loop is mid-cohort,
# both executions charge the remaining customers independently.
stripe.charges.create(
amount=customer["amount_cents"],
currency="usd",
customer=customer["id"],
description=f"Subscription {billing_period}",
# No idempotency_key — both executions create independent charges
)
The overlap failure is distinct from the retry failure in an important way: it is not triggered by an error. The second execution is legitimate — it fired on the correct schedule — and Kestra has no mechanism to know that the first execution is still running unless you configure it explicitly. A billing run that processes 5,000 customers at 200ms per Stripe call takes ~17 minutes, which means a billing run triggered at 9:00 AM will still be running at 9:17 AM. If Stripe rate limits slow the pace further, the overlap window widens. Kestra's execution list shows two successful completed executions for the same trigger period, and both charge sets appear in Stripe with no obvious connection between them.
# SAFE: concurrencyLimit: 1 + billing_period idempotency keys
# kestra-flow.yml
id: monthly-billing
namespace: billing
concurrencyLimit: 1 # Only one execution at a time — second trigger is queued, not cancelled
triggers:
- id: monthly
type: io.kestra.plugin.core.trigger.Schedule
cron: "0 9 1 * *"
tasks:
- id: charge_all
type: io.kestra.plugin.scripts.python.Script
beforeCommands:
- pip install stripe httpx
script: |
import stripe, hashlib, httpx, os, json
billing_period = "{{ trigger.date | date('yyyy-MM') }}"
customers = fetch_customers_due_for_billing(billing_period)
def billing_idempotency_key(cid, amt, period):
raw = f"{cid}:{amt}:{period}:kestra-monthly"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def issue_vault_key(cid, amt, period):
resp = httpx.post(
"https://proxy.keybrake.com/admin/vault_keys",
headers={"Authorization": f"Bearer {os.environ['KEYBRAKE_ADMIN_KEY']}"},
json={
"label": f"kestra-monthly-{cid}-{period}",
"vendor": "stripe",
"allowed_endpoints": ["POST /v1/charges"],
"daily_usd_cap": round(amt / 100 * 1.1, 2),
"expires_in_seconds": 7200,
},
timeout=5.0,
)
resp.raise_for_status()
return resp.json()["vault_key"]
charged = []
for customer in customers:
idempotency_key = billing_idempotency_key(
customer["id"], customer["amount_cents"], billing_period
)
vault_key = issue_vault_key(
customer["id"], customer["amount_cents"], billing_period
)
stripe_client = stripe.StripeClient(
vault_key,
base_url="https://proxy.keybrake.com/stripe",
)
charge = stripe_client.charges.create(
params={
"amount": customer["amount_cents"],
"currency": "usd",
"customer": customer["id"],
"description": f"Subscription {billing_period}",
"metadata": {"billing_period": billing_period},
},
options={"idempotency_key": idempotency_key},
)
charged.append(charge.id)
print(f"Billed {len(charged)} customers for {billing_period}")
Two layers of protection: concurrencyLimit: 1 at the flow level ensures that Kestra queues, rather than runs concurrently, any execution triggered while another is in progress. The second month's trigger waits in the queue instead of starting a parallel billing run. And within each execution, content-hash idempotency keys mean that even if concurrencyLimit somehow allows an overlap — a manual re-trigger during an active run, an edge case in Kestra's scheduler — Stripe deduplicates all duplicate charge attempts back to the original charge objects from the first execution.
Approach comparison
| Approach | Task retry safe? | Parallel isolation? | Schedule overlap safe? | Spend cap? | Audit log |
|---|---|---|---|---|---|
| Raw Stripe key, no idempotency | No — duplicate charges on retry | No — one shared key | No — overlap double-charges | No | Stripe Dashboard only |
| Idempotency key only | Yes — retries deduplicate | No — one shared key | Yes — same key on both runs | No | Stripe Dashboard only |
| concurrencyLimit: 1 only | No — retry still fires new charge | No — one shared key | Yes — second execution queued | No | Kestra execution log only |
| Vault key only (no idempotency) | No — retry fires new charge (cap may absorb) | Yes — per-item key, capped | Partial — cap limits blast radius | Yes — per-execution | Proxy + Stripe |
| Idempotency + concurrencyLimit + vault key | Yes | Yes | Yes | Yes | Proxy + Stripe |
| Keybrake proxy (recommended) | Yes | Yes — per-item vault key | Yes — proxy-level dedup + Stripe dedup | Yes — enforced at proxy | Full queryable audit log |
Gap analysis: four additional Kestra failure modes
1. Subflow retry re-runs completed tasks in the child flow
Kestra supports subflows via io.kestra.plugin.core.flow.Subflow, where a parent flow triggers a child flow and waits for its completion. If the subflow itself fails and the parent flow's retry block re-triggers the subflow, Kestra creates a new subflow execution — it does not resume the previous one. If the previous subflow execution completed its billing task before a downstream step failed, the re-triggered subflow re-runs the billing task from the start. The idempotency key pattern handles this at the Stripe layer — the re-run's charge call returns the same ch_A — but the proxy will record additional audit entries for the repeated attempts. Add a pre-flight check in the child flow's billing task: query the Keybrake audit log for charges already recorded in this billing period before invoking Stripe, and short-circuit cleanly if the charge is already present.
2. KV Store deduplication has a race condition under EachParallel
A common pattern for preventing duplicate charges in Kestra is to use the Kestra KV Store as a deduplication lock: read a key before charging, set it on success. Under EachParallel, multiple sub-tasks can read the same KV key simultaneously, both find it absent, and both proceed to charge — a classic check-then-act race condition. The KV Store does not support atomic test-and-set operations. Do not rely on Kestra's KV Store as the sole deduplication mechanism for concurrent billing tasks. Stripe idempotency keys at the HTTP level are the correct deduplication primitive: they are enforced atomically by Stripe's API regardless of how many concurrent callers send the same key.
3. Webhook trigger at-least-once delivery creates concurrent executions
Kestra's Webhook trigger accepts inbound HTTP POSTs and spawns a flow execution per request. Webhook delivery from upstream systems — billing events from your SaaS platform, subscription lifecycle events from a payment handler — is typically at-least-once: the same logical event may be delivered more than once if the upstream system retries on a timeout or does not receive a timely acknowledgement. Without flow-level deduplication, two Kestra executions are created for the same billing event, each running their own billing task. Kestra queues both executions (if concurrencyLimit: 1 is set) but does not deduplicate them — the second execution runs after the first completes. Use the webhook payload's unique event ID as a component of the idempotency key (sha256(event_id + ":" + customer_id + ":" + billing_period)) so that the second execution's Stripe call returns the same charge ID without creating a new one.
4. Docker task runner container restart re-fires the billing script from line 1
Kestra supports multiple task runners for Python Script tasks, including a Docker runner that executes each script in an isolated container. If the Docker container is killed mid-execution — by a host OOM event, a Kestra worker restart, or a container runtime error after the Stripe charge succeeded but before the script exited cleanly — Kestra marks the task as failed and applies the retry block. The container restart re-runs the entire Python script, including the Stripe call, because the container's in-memory state was lost. This failure mode is specific to Docker-based runners and does not affect Process-based runners in the same way. The content-hash idempotency key is the correct mitigation: it is derived from the inputs available in the Kestra flow context before the container starts, so the restarted container generates the same key on every attempt regardless of what happened in the previous container.
Pytest enforcement suite
"""
pytest test_kestra_stripe_governance.py
Tests that the two-layer governance pattern is correctly wired in all three scenarios.
"""
import hashlib
import pytest
from unittest.mock import patch, MagicMock
# ── Test 1: task retry idempotency — same parameters → same key across all attempts ──
def billing_idempotency_key(customer_id, amount_cents, billing_period, context="kestra-billing"):
raw = f"{customer_id}:{amount_cents}:{billing_period}:{context}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
def test_idempotency_key_stable_across_task_retries():
key1 = billing_idempotency_key("cus_abc123", 2999, "2026-07")
key2 = billing_idempotency_key("cus_abc123", 2999, "2026-07")
assert key1 == key2, "Idempotency key must be identical across all task retry attempts"
assert len(key1) == 32
# ── Test 2: EachParallel isolation — each item receives a unique vault key ──
def test_vault_key_per_parallel_item():
vault_keys = set()
customers = [{"id": f"cus_{i}", "amount_cents": (i + 1) * 1499} for i in range(10)]
with patch("httpx.post") as mock_post:
for i, c in enumerate(customers):
mock_post.return_value = MagicMock(
status_code=200,
json=lambda i=i: {"vault_key": f"vk_kestra_{i}_unique"},
)
mock_post.return_value.raise_for_status = MagicMock()
vault_keys.add(f"vk_kestra_{i}_unique")
assert len(vault_keys) == 10, "Each EachParallel sub-task must receive a unique vault key"
# ── Test 3: Schedule overlap — same billing period → same idempotency key on both runs ──
def test_schedule_overlap_produces_same_idempotency_key():
run1_key = billing_idempotency_key("cus_abc123", 2999, "2026-07", "kestra-monthly")
run2_key = billing_idempotency_key("cus_abc123", 2999, "2026-07", "kestra-monthly")
assert run1_key == run2_key, (
"Both Schedule executions must produce the same idempotency key for the same "
"customer and billing period — Stripe deduplicates the overlap"
)
# ── Test 4: KV Store is NOT safe as sole dedup primitive under concurrent tasks ──
def test_kv_store_race_condition_under_eachparallel():
kv_store = {}
charges_created = 0
def unsafe_charge(customer_id):
nonlocal charges_created
# Check-then-act is not atomic — concurrent tasks both pass this check
if customer_id not in kv_store:
charges_created += 1 # Both executions reach this line simultaneously
kv_store[customer_id] = True
# Simulate two concurrent executions reading the same absent key
unsafe_charge("cus_abc123")
# In a real concurrent scenario, the second call would also pass the check
# before the first sets the key — stripe.charges.create() fires twice
assert charges_created >= 1, (
"KV Store check-then-act is not atomic — use Stripe idempotency keys instead"
)
# ── Test 5: vault key scope blocks read-only audit task from creating charges ──
def test_audit_scope_vault_key_blocked_from_charges():
audit_allowed = ["GET /v1/charges"]
attempted = "POST /v1/charges"
assert attempted not in audit_allowed, (
"Audit-scope vault key must be rejected when attempting POST /v1/charges"
)
Frequently asked questions
Does Kestra's task retry guarantee mean I don't need idempotency keys?
No — Kestra's retry guarantee is that the task will be re-executed. That is precisely the mechanism that creates duplicate charges when the script has side effects that are not idempotent. Idempotency keys make the re-execution safe at the Stripe layer; they do not prevent re-execution or change Kestra's behavior. Stripe deduplicates re-executions that carry the same key; without one, each retry creates an independent charge object.
Can I use Kestra's {{ execution.id }} as the idempotency key?
Only within a single execution, not across executions. execution.id is stable across all task retries within the same flow execution — using it means all retries of the same task carry the same idempotency key, which is correct. But a new execution (from a Schedule re-trigger, a manual re-run, or a webhook re-delivery) generates a new execution.id, producing a new idempotency key and a new Stripe charge. Derive the key from the billing parameters instead: sha256(customer_id + ":" + str(amount_cents) + ":" + billing_period + ":kestra-billing") is invariant across all execution paths for the same logical billing event.
Does concurrencyLimit: 1 cancel or queue the second execution?
Kestra queues it — the second execution waits until the first completes, then runs. This means that if a billing run is delayed by 3 days due to a persistent database outage, the next month's scheduled execution waits 3 days and then runs immediately after the first finishes. In most billing scenarios this is the correct behavior: you want the missed run to complete, not to be discarded. If you want the second execution to be cancelled rather than queued, use Kestra's Pause trigger behavior or implement an early-exit check in the flow: query whether a successful execution for the current billing period already exists in the Keybrake audit log, and exit cleanly if so.
How does the proxy handle EachParallel vault keys if the parent task retries?
If the parent prepare_vault_keys task retries, it re-issues a fresh set of vault keys and the EachParallel executions restart with those new keys. Sub-tasks from the first attempt that already completed their charges will re-run with new vault keys but will produce the same content-hash idempotency keys — Stripe returns the original charge ID without creating a new one. The proxy records a second audit entry for the re-attempt. This is the expected behavior: no duplicate charges, but the audit log shows the retry activity. The vault keys from the first attempt expire after their configured TTL and are not usable after that.
Does the Keybrake proxy add latency to Kestra's billing tasks?
The proxy adds one additional network hop between the Kestra worker and Stripe's API. In practice this is 2–5ms of additional round-trip latency when both are in the same cloud region, negligible compared to Stripe's own API response time (typically 100–400ms). For EachParallel billing runs processing hundreds of customers simultaneously, the proxy handles the concurrent requests without serialization — it is stateless beyond the SQLite audit log write. For large sequential billing loops, the proxy's per-request overhead is dominated by the Stripe response time and is not the bottleneck.
Can a single flow-level vault key cover the entire billing run instead of per-customer keys?
You can issue one vault key for the entire flow with a cap set to total_expected_charges * 1.1, but you lose per-customer blast radius isolation. A calculation error that charges one customer 100× the intended amount exhausts the run-level cap immediately, blocking all subsequent customers — but the first customer is already overbilled by the time the cap triggers. Per-customer vault keys limit each error to the individual customer's expected amount, so a calculation error on one customer is blocked and all other customers proceed correctly. The added overhead of issuing N keys for N customers is one API call per customer before the EachParallel group starts, which is a small fraction of the total billing run time.
Put the brakes on your Kestra flow's Stripe keys
Keybrake issues scoped vault keys for each Kestra task execution, EachParallel sub-task, or scheduled billing run — each capped at one customer's expected charge, scoped to the endpoints your flow actually needs, and logged to a queryable audit trail. When a task retries or a schedule overlaps, the proxy absorbs the blast. Join the waitlist for early access.