GitHub Actions Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
GitHub Actions is the most widely used CI/CD platform for running AI agents, scheduled billing jobs, and automated payment workflows. Its execution model — retry failed jobs, fan out with strategy.matrix, schedule recurring runs with on.schedule — is designed for reliable automation at scale. Those same behaviors create specific billing hazards when Stripe API calls live inside a workflow step: a job re-run re-executes the entire billing job from step 1 if any later step fails after stripe.charges.create() already succeeded; strategy.matrix fans out N parallel runner processes all sharing one unrestricted STRIPE_SECRET_KEY with no per-matrix-entry spend cap; and an on.schedule workflow without a concurrency group allows a manual workflow_dispatch or overlapping trigger to fire a second billing run while the first is still executing.
This post covers three GitHub Actions-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-job vault keys via a spend-cap proxy — that eliminates all three without restructuring your workflow.
Failure mode 1: "Re-run failed jobs" re-executes the billing job from step 1 after stripe.charges.create() already succeeded
GitHub Actions re-runs a job by executing every step in that job again from the first step. When you click "Re-run failed jobs" in the GitHub UI — or when on.workflow_run triggers a retry, or an automated CI system re-triggers a failed workflow — GitHub creates a new job run and starts at step 1 regardless of which step originally failed. There is no mechanism to resume a job mid-execution: if the "Charge customer" step (step 2 in your job) called stripe.charges.create() successfully and returned a charge ID, but the subsequent "Write to database" step (step 3) raised an exception and the job failed, re-running the failed job starts a new runner and executes "Charge customer" again with the same inputs and no idempotency key.
# billing.py — UNSAFE: re-run fires stripe.charges.create() again on write error
import stripe
import os
import sys
stripe.api_key = os.environ["STRIPE_SECRET_KEY"] # unrestricted live key from GitHub secret
def run(customer_id: str, amount_cents: int, billing_period: str):
# Job re-run restarts here — no idempotency key, no guard against duplicate charge
charge = stripe.charges.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Subscription {billing_period}",
)
# Transient DB error → step exits non-zero → job fails → re-run → 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"],
)
# .github/workflows/billing.yml — UNSAFE: job re-run re-runs billing step from line 1
name: Monthly Billing
on:
schedule:
- cron: '0 0 1 * *' # 00:00 UTC on the 1st of each month
workflow_dispatch:
jobs:
charge-customer:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Charge customer # step 2 — succeeds, creates ch_A
run: python billing.py
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
CUSTOMER_ID: cus_abc123
AMOUNT_CENTS: "4999"
BILLING_PERIOD: "2026-07"
- name: Write to database # step 3 — fails, job exits non-zero
run: python write_db.py
env:
CHARGE_ID: ${{ steps.charge.outputs.charge_id }}
On the re-run, Stripe has no record of a previous idempotency key for this call — none was sent — so it treats the second request as a new charge and creates ch_B for the same customer, same amount, same billing period. The GitHub Actions UI shows both runs: the original failure and the re-run success. The duplicate charge is invisible in the workflow view until a customer disputes a payment or a reconciliation job compares your database billing records to Stripe's charges export. With automated retry tooling, a persistent database outage during billing can trigger five or more duplicate charges per customer before the outage is resolved.
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 GitHub Actions passes the same environment variables to every run and re-run of a given job (the values come from the workflow file and secrets, which do not change between runs), 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.py — SAFE: content-hash idempotency key survives all GitHub Actions re-runs
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}:gha-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 re-run — Stripe returns ch_A without creating ch_B, ch_C
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 via a restricted key or vault key, so a re-run 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 your database 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: strategy.matrix runs N parallel jobs sharing one STRIPE_SECRET_KEY with no per-matrix-entry spend cap
GitHub Actions' strategy.matrix expands a job into N parallel runs, one per combination of matrix values. All N runner processes read secrets from the repository's secret store using the same key name — secrets.STRIPE_SECRET_KEY — which resolves to the same unrestricted Stripe live key across all N jobs. There is no per-matrix-entry 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 jobs call stripe.charges.create() with the wrong amount simultaneously. By the time the first error surfaces from Stripe's response, potentially hundreds of customers have been overcharged with no circuit breaker to halt the remaining matrix entries.
# .github/workflows/batch-billing.yml — UNSAFE: matrix shares one unrestricted key, no cap
name: Batch Customer Billing
on:
workflow_dispatch:
jobs:
charge-customers:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- 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"
fail-fast: false # all matrix jobs run to completion even if one fails
steps:
- uses: actions/checkout@v4
- name: Charge customer
run: python billing.py
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} # same key in all N jobs
CUSTOMER_ID: ${{ matrix.customer_id }}
AMOUNT_CENTS: ${{ matrix.amount_cents }}
BILLING_PERIOD: ${{ matrix.billing_period }}
The dangerous scenario is a unit bug in the data pipeline that generates the matrix input: if amount_cents is computed as amount_dollars instead of amount_dollars * 100, all N jobs call stripe.charges.create(amount=49) instead of amount=4999 — off by 100×. All N jobs start simultaneously and there is no cross-job coordination or abort signal: every customer receives the wrong charge amount before any individual job exits and surfaces an error. A spend cap set at the Stripe account level does not help because the cumulative spend across all matrix entries may still look reasonable even though each individual charge amount is wrong.
The fix is to issue a per-matrix-entry vault key before the fan-out and pass it as a matrix input. 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 very first call and the proxy blocks the oversized charge — no other customers are affected. The key-issuance job runs as a prerequisite step and outputs a JSON artifact that the matrix job reads per-entry.
# issue_vault_keys.py — runs as a prerequisite job before the matrix 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"gha-{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 file for GitHub Actions artifact upload
with open("customers_with_keys.json", "w") as f:
json.dump(keyed, f)
if __name__ == "__main__":
main()
# billing_with_vault.py — SAFE: vault key from artifact, capped at customer's amount × 1.10
import stripe
import hashlib
import json
import os
KEYBRAKE_PROXY_URL = os.environ.get("KEYBRAKE_PROXY_URL", "https://proxy.keybrake.com")
# vault_key injected per-matrix-entry — 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}:gha-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"],
)
# .github/workflows/batch-billing.yml — SAFE: vault keys issued before fan-out
name: Batch Customer Billing
on:
workflow_dispatch:
jobs:
issue-vault-keys:
runs-on: ubuntu-latest
outputs:
customers-json: ${{ steps.issue.outputs.customers-json }}
steps:
- uses: actions/checkout@v4
- name: Issue per-customer vault keys
id: issue
run: |
python issue_vault_keys.py
echo "customers-json=$(cat customers_with_keys.json)" >> "$GITHUB_OUTPUT"
env:
KEYBRAKE_ADMIN_KEY: ${{ secrets.KEYBRAKE_ADMIN_KEY }}
CUSTOMERS_JSON: ${{ vars.CUSTOMERS_JSON }}
charge-customers:
needs: issue-vault-keys
runs-on: ubuntu-latest
strategy:
matrix:
include: ${{ fromJson(needs.issue-vault-keys.outputs.customers-json) }}
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Charge customer
run: python billing_with_vault.py
env:
VAULT_KEY: ${{ matrix.vault_key }} # per-customer, capped at amount × 1.10
CUSTOMER_ID: ${{ matrix.customer_id }}
AMOUNT_CENTS: ${{ matrix.amount_cents }}
BILLING_PERIOD: ${{ matrix.billing_period }}
Failure mode 3: on.schedule without a concurrency group fires two overlapping billing runs
By default, GitHub Actions allows multiple workflow runs to execute in parallel. An on.schedule billing workflow with no concurrency block can accumulate concurrent runs in several ways: a developer triggers workflow_dispatch while the monthly schedule run is still executing; a push event triggers the same workflow (if the workflow has multiple triggers) while the schedule run is in progress; or an external automation calls the GitHub API to trigger a run without checking whether one is already active. Both runs iterate over the same customer list. Both reach the billing step. Without an idempotency key, both call stripe.charges.create() for every customer, creating a duplicate charge for the entire overlap period. The two runs appear in the Actions tab as independent workflow runs with no visible signal that they are billing the same customers simultaneously.
# .github/workflows/billing.yml — UNSAFE: no concurrency group, workflow_dispatch can overlap
name: Monthly Billing
on:
schedule:
- cron: '0 0 1 * *' # 00:00 UTC on the 1st of each month
workflow_dispatch: # also triggerable manually — fires even if schedule run is active
# No concurrency block — schedule + dispatch run simultaneously
The overlap scenario is more common than it appears. Monthly billing workflows that iterate over thousands of customers, wait on Stripe rate-limit delays between calls, and write confirmation records to a large database can take 20–60 minutes to complete. A developer who notices a billing anomaly mid-run and manually re-triggers the workflow to "fix" it creates a second run that starts billing the same customers from the beginning. Even with automated tooling, a simple mistake — triggering the workflow from a script that does not check for active runs — produces a complete duplicate billing cycle.
The immediate fix is a concurrency block with cancel-in-progress: false, which causes GitHub Actions to queue any new run of the same workflow group rather than starting it while another is active. Pair this with a content-hash idempotency key so that even if the concurrency group is removed or bypassed, a concurrent run cannot create a second charge for the same customer and billing period.
# .github/workflows/billing.yml — SAFE: concurrency group queues new runs, idempotency key guards charges
name: Monthly Billing
on:
schedule:
- cron: '0 0 1 * *'
workflow_dispatch:
concurrency:
group: billing-${{ github.workflow }}
cancel-in-progress: false # queue new runs; do NOT cancel in-progress billing
jobs:
charge-customers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Issue vault keys
run: python issue_vault_keys.py
env:
KEYBRAKE_ADMIN_KEY: ${{ secrets.KEYBRAKE_ADMIN_KEY }}
CUSTOMERS_JSON: ${{ vars.CUSTOMERS_JSON }}
- name: Charge customers
run: python batch_billing.py
env:
KEYBRAKE_PROXY_URL: https://proxy.keybrake.com
BILLING_PERIOD: "2026-07"
Note the choice of cancel-in-progress: false rather than true. Cancelling an in-progress billing run mid-execution is dangerous: some customers will have been charged and others will not, leaving the billing cycle in a partially-complete state. With cancel-in-progress: false, the new run waits in a queue and starts only after the previous run completes or fails cleanly. If the previous run fails after partially billing customers, the next queued run starts from the beginning — but because each call carries the content-hash idempotency key, customers who were already successfully charged will receive a Stripe idempotency cache hit rather than a duplicate charge, and only the remaining customers will be billed.
Approach comparison
| Approach | Idempotency key | Spend cap | Re-run safe | Matrix safe | Overlap safe |
|---|---|---|---|---|---|
Raw stripe.api_key, no idempotency key |
None | None | No | No | No |
| Idempotency key only | Content-hash | None | Yes | Partial | Yes |
| Restricted Stripe key (POST /v1/charges only) | None | None | No | No | No |
| Concurrency group only | None | None | No | No | Yes |
| Per-job vault key only | None | Per-job | No | Yes | No |
| Idempotency key + per-job vault key + concurrency group | Content-hash | Per-job | Yes | Yes | Yes |
Gap analysis: four additional GitHub Actions billing hazards
1. Dynamic matrix computed from an external API at runtime
Many teams generate the billing matrix at runtime via a script that fetches the current customer list from a database or API rather than hardcoding it in the workflow file. If the prerequisite job that generates the matrix also issues vault keys, a partial failure in key issuance (e.g., the Keybrake API times out for customer N) leaves some matrix entries with keys and others without. The subsequent matrix run starts all jobs in parallel — entries with vault keys route through the proxy, entries without vault keys may fall back to a hardcoded STRIPE_SECRET_KEY secret if the billing script has a fallback. The fix: fail the key-issuance job immediately if any single key fails to issue (sys.exit(1) rather than continue), and ensure the billing script has no fallback to a raw Stripe key — only the vault key value is accepted.
2. fail-fast: true with partial matrix completion
GitHub Actions' strategy.fail-fast defaults to true, which cancels all remaining matrix jobs when any single job fails. If the first matrix job fails partway through and fail-fast: true is set, some customers have been charged and others have not, leaving the billing cycle in a partially-complete state. A subsequent re-run of the entire workflow starts a new matrix with the same input list. Because vault keys from the previous run have expired, new keys are issued for all entries — including customers who were already successfully charged in the first run. The content-hash idempotency key protects those customers on the re-run: Stripe returns the cached charge ID rather than creating a new charge. Always set fail-fast: false for billing matrix jobs so each entry runs to completion or explicit failure independently.
3. continue-on-error: true masking silent charge failures
Adding continue-on-error: true to a billing step causes GitHub Actions to mark that step as successful even when it exits non-zero. This is sometimes added to billing steps to prevent a single customer charge failure from halting the entire job. The unintended consequence: the step that calls stripe.charges.create() raises an exception (network timeout, invalid card, API rate limit), exits non-zero, and the job continues to the next customer — but the exception is silently swallowed. No charge was created for that customer and no downstream alert fires. Use continue-on-error: false (the default) and instead handle expected failures explicitly inside the Python script: catch stripe.error.CardError and write the failure to a separate error log rather than raising, while re-raising all other unexpected exceptions.
4. GitHub Actions caches restoring a stale billing artifact with outdated vault keys
If your workflow uses actions/cache to cache billing artifacts (customer lists, pre-computed amounts, previously issued keys), a stale cache restore may inject vault keys from a previous billing period into the current run. Vault keys issued for billing_period: 2026-06 carry idempotency keys derived from June inputs — when the July run uses those stale keys and calls stripe.charges.create() with July billing amounts, the idempotency key hash does not match the June hash, so Stripe creates new charges correctly. But the vault key's spend cap was set for June's per-customer amounts, which may be lower than July's amounts if prices changed. The July charge may be blocked by the June vault key's cap. Always issue fresh vault keys in the current run with no caching, and use a cache key that includes the billing period (billing-keys-${{ env.BILLING_PERIOD }}) so a period change invalidates the cache automatically.
Pytest enforcement suite
import pytest
import hashlib
# Test 1: idempotency key is stable across all re-runs of the same billing inputs
def test_idempotency_key_stability_across_reruns():
from billing import billing_idempotency_key
key_run1 = billing_idempotency_key("cus_001", 4999, "2026-07")
key_run2 = billing_idempotency_key("cus_001", 4999, "2026-07")
key_run3 = billing_idempotency_key("cus_001", 4999, "2026-07")
assert key_run1 == key_run2 == key_run3
assert len(key_run1) == 32
# Test 2: idempotency keys are distinct per billing period
def test_idempotency_keys_distinct_per_period():
from billing import billing_idempotency_key
key_jul = billing_idempotency_key("cus_001", 4999, "2026-07")
key_aug = billing_idempotency_key("cus_001", 4999, "2026-08")
assert key_jul != key_aug
# Test 3: idempotency keys are distinct per customer
def test_idempotency_keys_distinct_per_customer():
from billing import billing_idempotency_key
key_001 = billing_idempotency_key("cus_001", 4999, "2026-07")
key_002 = billing_idempotency_key("cus_002", 4999, "2026-07")
assert key_001 != key_002
# Test 4: vault key rejects charges above the per-customer cap
def test_vault_key_blocks_oversized_charge(requests_mock):
requests_mock.post(
"https://proxy.keybrake.com/stripe/v1/charges",
status_code=402,
json={"error": {"message": "Spend cap exceeded"}},
)
import stripe
stripe.api_key = "vk_test_per_customer_key"
stripe.api_base = "https://proxy.keybrake.com/stripe"
with pytest.raises(stripe.error.CardError):
stripe.charges.create(amount=999999, currency="usd", customer="cus_001")
# Test 5: concurrency group prevents second run from starting during active billing
def test_concurrency_group_queues_second_run():
# Verified via GitHub Actions API: a run with the same concurrency group
# and cancel-in-progress: false enters "waiting" state, not "in_progress"
# This test documents the expected state machine for CI audits
expected_state_when_duplicate_triggered = "waiting"
assert expected_state_when_duplicate_triggered == "waiting"
FAQ
Can I use the GitHub Actions run ID as an idempotency key instead of a content hash?
No. The run ID changes on every re-run because GitHub creates a new run for each attempt. A re-run has a different github.run_id and github.run_attempt than the original run. Using the run ID as an idempotency key means every re-run sends a unique key to Stripe, which creates a new charge each time. Use a content-hash derived from billing inputs (customer ID, amount, billing period) — these are stable across all runs and re-runs of the same logical billing event.
How do vault keys work alongside GitHub Actions secrets?
Replace the STRIPE_SECRET_KEY secret in your billing job with a VAULT_KEY value issued per-run by the Keybrake proxy. The KEYBRAKE_ADMIN_KEY secret — a single long-lived admin credential — lives in your repository secrets and is used only by the key-issuance job to call POST /keys. The resulting vault keys are short-lived (1-hour TTL by default), scoped to POST /v1/charges only, and carry per-job spend caps. They are passed to the billing job as step outputs or matrix parameters rather than repository secrets, so they are not stored anywhere that persists between runs.
What happens if the Keybrake proxy is unreachable when the billing job runs?
The billing script will receive a network error from stripe.charges.create() because stripe.api_base is set to the proxy URL. The step exits non-zero and the job fails cleanly — no charge is created. The content-hash idempotency key ensures the re-run or next scheduled attempt will not create a duplicate when the proxy is reachable again. Do not add a fallback that points stripe.api_base back to api.stripe.com on proxy failure: that fallback bypasses all spend-cap enforcement and is more dangerous than failing the job.
Does cancel-in-progress: false in the concurrency group cause jobs to pile up indefinitely?
GitHub Actions queues at most one pending run per concurrency group when cancel-in-progress: false is set. If a second run is already queued when a third run is triggered, the second queued run is replaced by the third. In practice this means at most two runs exist simultaneously: one active and one pending. For monthly billing this is safe — the worst case is the current month's run starts after the previous month's run finishes, which is the correct behavior.
Can I run billing inside a reusable workflow called by multiple repositories?
Yes, but the concurrency group must be scoped to the caller workflow, not the reusable workflow. Set the concurrency group in the caller workflow file using group: billing-${{ github.repository }}-${{ github.workflow }} so that different repositories calling the same reusable billing workflow each have independent concurrency groups. Without scoping to github.repository, a billing run in repository A could queue or cancel a billing run in repository B if they share the same concurrency group name.
Does GitHub Actions' built-in job retry (beta) change anything?
GitHub Actions' automatic job retry feature re-runs failed jobs automatically up to a configured limit. The re-run behavior is identical to a manual "Re-run failed jobs" click: a new job run starts from step 1 with the same environment variables. The content-hash idempotency key remains stable across automatic retries for the same reason it is stable across manual re-runs — the billing inputs in the environment do not change. If you enable automatic job retry for a billing job, confirm that your idempotency key implementation is in place before enabling the feature.
Stop re-run double-charges before they hit production
Keybrake issues short-lived vault keys with per-job spend caps and a spend-cap proxy that blocks oversized charges before they reach Stripe. Works with any GitHub Actions workflow — no workflow restructuring required.