Airbyte Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Airbyte is an open-source ELT platform that syncs data from hundreds of sources to data warehouses and operational databases. Teams wire Airbyte into Stripe billing pipelines to trigger charges based on usage records synced from product databases, event stores, or third-party APIs: Airbyte lands usage rows in a billing staging table, a downstream dbt model or Lambda function reads those rows and calls stripe.charges.create() for each customer, and the result is recorded back to the warehouse. Three Airbyte-specific behaviors introduce Stripe double-charge failure modes that Airbyte’s sync health dashboards, job logs, and row-count metrics do not surface as billing risk.
This post covers those three failure modes with pyairbyte and Python code, content-hash idempotency keys, per-sync vault keys via a spend-cap proxy, and pre-flight database checks — the two-layer governance pattern that eliminates all three without replacing Airbyte’s sync architecture.
Failure mode 1: incremental sync cursor state saved only on job success — retry from last cursor re-emits billed records
Airbyte’s incremental sync uses a cursor field — typically a updated_at timestamp or an auto-incrementing integer — to track how far through the source dataset the connector has read. After each successful sync job, Airbyte saves the highest cursor value seen in that run to the connection’s state object. On the next sync, the connector queries the source with WHERE updated_at > {saved_cursor}, emitting only new or updated records.
The cursor state is saved at job completion, not incrementally during the sync. If a sync job emits 1,200 records to the destination, succeeds at writing all 1,200 to the staging table, but then fails during Airbyte’s normalization phase (which runs after the raw data is written but before the job is marked successful), Airbyte does not save the cursor state from that run. The next sync retries from the previously saved cursor and re-emits all 1,200 records a second time.
If a billing function reads from the staging table after each sync and calls stripe.charges.create() for each new row, the retry delivers 1,200 rows that the billing function treats as new — because Airbyte’s raw table uses an _airbyte_raw_id UUID generated per-emit, not a stable business key. Each retry creates a fresh UUID, so deduplication logic based on _airbyte_raw_id does not protect against this failure mode.
# UNSAFE: billing trigger that reads Airbyte's staging table and charges per row
# If Airbyte retries the sync after a normalization failure, the same rows
# are re-delivered with new _airbyte_raw_id values and charged a second time.
import stripe, os, psycopg2
stripe.api_key = os.environ["STRIPE_SECRET_KEY"] # UNSAFE: unrestricted, shared
def run_billing_after_sync():
conn = psycopg2.connect(os.environ["DATABASE_URL"])
cur = conn.cursor()
# Read new rows from Airbyte's staging table since last billing run
# DANGER: uses _airbyte_raw_id as dedup key -- new UUID on each Airbyte retry
cur.execute("""
SELECT customer_id, amount_cents, billing_period, _airbyte_raw_id
FROM public_raw__stream_usage_events
WHERE _airbyte_extracted_at > %s
AND _airbyte_raw_id NOT IN (SELECT airbyte_raw_id FROM billed_events)
""", (last_billing_run_at,))
rows = cur.fetchall()
for customer_id, amount_cents, billing_period, raw_id in rows:
# Airbyte retry re-emits the same rows with NEW _airbyte_raw_id values.
# This NOT IN check passes for the retried rows -- they have new raw IDs
# but represent the same business events. Stripe creates ch_B.
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=customer_id,
description=f"Billing for {billing_period}",
# no idempotency_key
)
cur.execute(
"INSERT INTO billed_events (airbyte_raw_id, charge_id) VALUES (%s, %s)",
(raw_id, charge["id"])
)
conn.commit()
Airbyte’s sync log shows the retry as a new successful job with its own job ID. The row count in the destination increases by 1,200 (the retried rows land as additional rows in the raw table, not as updates to the existing rows). Airbyte’s normalization pass deduplicates them into the final table using the business key, so the final normalized table shows no duplicates — but the raw table has 2,400 rows for those 1,200 events. A billing trigger that runs against the raw table before normalization completes, or that uses _airbyte_raw_id as its dedup key, fires twice per event.
Failure mode 2: connection reset replays full historical sync, recharging every historical customer
Airbyte connections can be reset via the UI (“Clear data and resync”), the Airbyte API (POST /v1/connections/{connectionId}/reset), or automatically when a connector schema change is detected and the connection is configured to propagate schema changes with a full reset. A reset clears Airbyte’s saved cursor state and the destination’s raw and normalized tables for that connection, then triggers a full historical sync from the source connector’s earliest available record.
For a usage events source with two years of history, a connection reset emits every usage event since the beginning. If the billing trigger fires on each sync (for example, via an Airbyte webhook that calls a billing Lambda), the full-reset sync is indistinguishable from a normal incremental sync to the webhook — except that it delivers all historical records instead of recent ones. The webhook processes all 730 days of usage events and calls stripe.charges.create() for each one.
# DANGEROUS: Airbyte webhook handler that triggers Stripe charges after each sync
# A connection reset causes the webhook to fire once for the full historical sync.
# All historical usage events are re-delivered and charged.
import stripe, os, json
from flask import Flask, request
app = Flask(__name__)
stripe.api_key = os.environ["STRIPE_SECRET_KEY"] # UNSAFE: unrestricted
@app.post("/airbyte-webhook")
def handle_sync_completed():
payload = request.json
if payload.get("data", {}).get("status") != "succeeded":
return {"ok": True}
# Fetch new rows from the billing staging table
new_rows = fetch_new_billing_rows_since_last_run()
# DANGER: no idempotency key, no pre-flight check.
# On a connection reset, new_rows contains all historical records.
for row in new_rows:
charge = stripe.Charge.create(
amount=row["amount_cents"],
currency="usd",
customer=row["stripe_customer_id"],
description=f"Billing for {row['billing_period']}",
)
mark_row_billed(row["id"], charge["id"])
return {"ok": True, "charged": len(new_rows)}
A schema change that triggers a reset is especially dangerous because it happens automatically without an operator action: the source system adds a column, Airbyte detects the schema drift on the next sync, applies the configured propagation policy (“Propagate columns and reset the connection”), and initiates a full resync. The webhook fires, and the billing Lambda receives all historical records. No alert fires for the connection reset itself — Airbyte logs it as a successful schema evolution, not an error. Stripe’s dashboard shows thousands of new charges clustered in a few minutes, separated from the original charges by weeks or months.
Failure mode 3: PyAirbyte cache miss returns full connector history in agent pipeline
PyAirbyte is Airbyte’s Python library for running connectors in-process without a full Airbyte server deployment. Teams use it in agent pipelines and data scripts: source.get_pandas_dataset() runs the connector, caches the results in a local DuckDB file, and returns a dictionary of Pandas DataFrames — one per stream. On subsequent calls, PyAirbyte reads from the local cache rather than re-running the connector, making repeated reads cheap.
The cache is tied to the local filesystem path where the DuckDB file lives. If the cache is absent — because the agent is running in a new container, the temp directory was cleared, or the pipeline is running in a serverless function with no persistent storage — PyAirbyte re-runs the connector from the beginning and returns the full history. An agent pipeline that calls stripe.charges.create() for each row in the returned DataFrame charges every customer from the connector’s origin, not just the customers with new activity since the last billing run.
# UNSAFE: PyAirbyte agent pipeline that charges per-row with no cache state check
# On cache miss (new container, cleared /tmp, serverless environment),
# get_pandas_dataset() returns ALL historical records. Every customer is recharged.
import airbyte as ab
import stripe, os, hashlib
stripe.api_key = os.environ["STRIPE_SECRET_KEY"] # UNSAFE: unrestricted, shared
# Source connector: reads usage events from a Postgres product database
source = ab.get_source(
"source-postgres",
config={
"host": os.environ["PG_HOST"],
"username": os.environ["PG_USER"],
"password": os.environ["PG_PASS"],
"database": "product",
"streams": ["usage_events"],
},
)
# DANGER: if cache is absent, this runs a full connector sync from origin.
# Returns all historical usage_events, not just new ones since last run.
cache = ab.get_default_cache()
result = source.read(cache=cache)
# Iterate over ALL rows in the dataset -- full history on cache miss
for row in result["usage_events"].to_pandas().itertuples():
charge = stripe.Charge.create(
amount=int(row.amount_cents),
currency="usd",
customer=row.stripe_customer_id,
description=f"Billing for {row.billing_period}",
# no idempotency_key -- every cache miss recharges all customers
)
print(f"Charged {row.customer_id}: {charge['id']}")
In serverless environments (AWS Lambda, Google Cloud Run, Modal), every cold start is a cache miss: the function has no local DuckDB file from a previous invocation. A billing agent deployed as a Lambda that runs on a cron schedule charges all historical customers on every invocation because the cache is always empty at cold start. PyAirbyte’s connector read log shows the full sync as normal behavior — there is no warning or error for a cold-start cache miss. The row count returned is orders of magnitude larger than the number of customers who received activity in the billing period, but if the pipeline does not log or check this count before calling Stripe, no alert fires before the charges go through.
The fix: content-hash idempotency key, pre-flight database check, and per-sync vault key
All three failure modes share the same root cause: the billing trigger does not check whether a given (customer_id, billing_period) pair was already billed before calling Stripe. The fix is a two-layer defense: a content-hash idempotency key that makes repeated Stripe calls safe within the 24-hour window, and a pre-flight database check that extends that protection to arbitrarily old historical replays.
# SAFE: Airbyte billing trigger with content-hash idempotency key,
# pre-flight DB check, and per-sync vault key spend cap.
import airbyte as ab
import hashlib, httpx, os, stripe
VAULT_KEY = os.environ["KEYBRAKE_VAULT_KEY"] # per-sync vault key
PROXY_BASE = "https://proxy.keybrake.com/stripe"
stripe.api_key = VAULT_KEY
stripe.api_base = PROXY_BASE
def already_billed(conn, customer_id: str, billing_period: str) -> bool:
cur = conn.cursor()
cur.execute(
"SELECT 1 FROM billing_records WHERE customer_id = %s AND billing_period = %s",
(customer_id, billing_period),
)
return cur.fetchone() is not None
def process_usage_row(conn, row) -> dict:
customer_id = row["customer_id"]
billing_period = row["billing_period"]
amount_cents = int(row["amount_cents"])
# Pre-flight: skip if already billed. Protects against Airbyte retries,
# connection resets, and PyAirbyte cache misses regardless of replay age.
if already_billed(conn, customer_id, billing_period):
return {"status": "skipped", "customer_id": customer_id}
# Content-hash idempotency key: stable across Airbyte retries.
# Must NOT include _airbyte_raw_id or _airbyte_extracted_at --
# those change on every Airbyte retry and sync reset.
raw_key = f"{customer_id}:{amount_cents}:{billing_period}:airbyte-billing"
idempotency_key = hashlib.sha256(raw_key.encode()).hexdigest()[:32]
try:
charge = stripe.Charge.create(
amount=amount_cents,
currency="usd",
customer=row["stripe_customer_id"],
description=f"Billing for {billing_period}",
idempotency_key=idempotency_key,
)
except stripe.error.CardError:
write_declined(conn, customer_id, billing_period)
return {"status": "declined", "customer_id": customer_id}
except stripe.error.InvalidRequestError as e:
write_error(conn, customer_id, billing_period, str(e))
return {"status": "error", "customer_id": customer_id, "detail": str(e)}
# Write billing record BEFORE returning. If the process crashes after
# the Stripe call but before the DB write, the idempotency key protects
# against duplicate charges within 24h; pre-flight protects beyond that.
write_billing_record(conn, customer_id, billing_period, charge["id"], amount_cents)
return {"status": "charged", "customer_id": customer_id, "charge_id": charge["id"]}
def run_billing_pipeline(conn, billing_period: str):
# Read from Airbyte's normalized destination table (not the raw table).
# The normalized table deduplicates on business key so a retry that
# re-emits the same records produces no new rows here.
cur = conn.cursor()
cur.execute("""
SELECT customer_id, stripe_customer_id, amount_cents, billing_period
FROM usage_events_final
WHERE billing_period = %s
""", (billing_period,))
rows = cur.fetchall()
results = [process_usage_row(conn, dict(zip(
["customer_id", "stripe_customer_id", "amount_cents", "billing_period"], r
))) for r in rows]
charged = sum(1 for r in results if r["status"] == "charged")
skipped = sum(1 for r in results if r["status"] == "skipped")
print(f"Billing complete: {charged} charged, {skipped} skipped (already billed)")
return results
Reading from the normalized destination table rather than the raw staging table is an important design decision. Airbyte’s normalization writes to a final table (usage_events_final or equivalent) that deduplicates on the primary key, so a retry that re-emits the same rows produces no additional rows in the normalized table. The pre-flight check is the backstop for cases where the billing trigger runs before normalization completes or where the normalized table cannot be used (for example, when using a connector that does not emit a stable primary key).
Issuing the vault key before the billing run
Issue the vault key once per billing period before the billing loop starts. Query the billing staging table for the expected number of customers and total expected charge amount, then issue a vault key capped at 110% of that total. This ensures that a data error in amount_cents — for example, a dbt model that multiplied amounts by 100 when converting from dollars to cents — hits the cap after the first few customers rather than charging all customers at the inflated amount.
# Issue vault key before billing run
import httpx, os
def issue_billing_vault_key(billing_period: str) -> str:
# Query the Airbyte normalized table for expected totals
expected_total_cents = query_expected_billing_total(billing_period)
resp = httpx.post(
"https://api.keybrake.com/vault-keys",
headers={"Authorization": f"Bearer {os.environ['KEYBRAKE_API_KEY']}"},
json={
"label": f"airbyte-billing-{billing_period}",
"vendor": "stripe",
"spend_cap_usd": round(expected_total_cents / 100 * 1.10, 2),
"allowed_endpoints": ["POST /v1/charges"],
"ttl_seconds": 3600, # 1h: enough for a full billing run with retries
},
timeout=10.0,
)
resp.raise_for_status()
return resp.json()["vault_key"]
# In the billing entry point:
billing_period = os.environ["BILLING_PERIOD"] # e.g. "2026-07"
vault_key = issue_billing_vault_key(billing_period)
os.environ["KEYBRAKE_VAULT_KEY"] = vault_key
# Now run the billing pipeline
Gap analysis: four more Airbyte–Stripe failure modes to address
The three failure modes above cover the most common Airbyte billing incidents. Four additional scenarios are worth addressing before production:
1. Airbyte webhook fires for both the reset sync and the subsequent incremental sync. When a connection reset completes, Airbyte fires its sync-completed webhook for the full-reset job. The next scheduled incremental sync fires a second webhook. If the billing trigger does not deduplicate on (customer_id, billing_period), both webhooks trigger charges: the first for all historical records, the second for the incremental records (which are a subset of those already charged in the reset). A pre-flight check protects against both webhook firings, but only if it checks before every Stripe call rather than once per webhook invocation.
2. Airbyte’s _airbyte_extracted_at field is set to the sync time, not the event time. Teams sometimes filter the staging table with WHERE _airbyte_extracted_at > last_billing_run_at to find rows added since the last billing run. On a connection reset, all historical rows are re-inserted with a fresh _airbyte_extracted_at timestamp (the reset sync time), so this filter returns all historical rows as “new.” The safe filter is WHERE billing_period = current_billing_period using the event’s business timestamp, not Airbyte’s metadata timestamp.
3. Schema evolution that adds a new currency field triggers a full reset and a billing recalculation. If the Airbyte connection’s schema change propagation policy is set to “Propagate all changes and reset the connection,” adding a currency column to the source usage events table causes a full historical resync. If the billing pipeline reads the new column and recalculates charges based on it, the recalculation runs against all historical records at the new (potentially different) amounts. Issue an alert before any schema change on billing-adjacent Airbyte connections and review the propagation policy before enabling automatic propagation.
4. PyAirbyte read() in a parallel agent spawns multiple concurrent billing runs. Agent frameworks that use PyAirbyte often spawn multiple parallel tasks for different streams. If multiple tasks share the same DuckDB cache path, concurrent reads can cause cache corruption or race conditions where both tasks read a consistent-looking dataset but one processes rows the other has already billed. Use a per-task cache path (ab.new_local_cache(cache_dir=f"/tmp/airbyte/{task_id}")) and coordinate billing through a shared database lock rather than relying on the cache state to prevent concurrent billing for the same (customer_id, billing_period).
Approach comparison
| Approach | Retry re-emit protection | Connection reset protection | PyAirbyte cache miss protection | Overhead | Audit log |
|---|---|---|---|---|---|
No idempotency key, _airbyte_raw_id dedup |
None — retry assigns new _airbyte_raw_id; dedup passes and charges again |
None — reset re-delivers all history; all customers recharged | None — cache miss returns full history; all customers charged | Zero | None |
| Stripe idempotency key only (no pre-flight check) | Protected within 24h window | Protected within 24h; older historical records create new charges | Protected within 24h; older historical records create new charges | Low | None |
| Pre-flight DB check only (no idempotency key) | Full — pre-flight skips already-billed rows | Full — pre-flight skips all historical rows already in billing_records | Full — pre-flight skips all rows from cache miss replay | One DB read per row | None |
| Content-hash idempotency key + normalized-table read | Full — normalized table deduplicates; idempotency key protects on raw table | Protected within 24h; pre-flight needed for older records | Protected within 24h; pre-flight needed for older records | One hash per row | None |
| Content-hash key + vault key + pre-flight check + normalized-table read (recommended) | Full — pre-flight skips all already-billed rows regardless of retry age | Full — pre-flight skips all historical rows; vault cap stops data errors | Full — pre-flight skips all rows from cache miss; cap stops amount errors | One DB read + one hash per row + one vault key per billing period | Proxy audit log + billing DB |
pytest enforcement suite
import hashlib, pytest
from unittest.mock import patch, MagicMock
# 1. Content-hash idempotency key excludes Airbyte metadata fields
def test_idempotency_key_excludes_airbyte_metadata():
row = {
"customer_id": "cus_001",
"amount_cents": 4900,
"billing_period": "2026-07",
"_airbyte_raw_id": "abc123", # must NOT be in the key
"_airbyte_extracted_at": "2026-07-01", # must NOT be in the key
}
raw = f"{row['customer_id']}:{row['amount_cents']}:{row['billing_period']}:airbyte-billing"
key = hashlib.sha256(raw.encode()).hexdigest()[:32]
# Simulate Airbyte retry: same business event, new metadata
row_retried = dict(row, _airbyte_raw_id="xyz999", _airbyte_extracted_at="2026-07-02")
raw_retried = (
f"{row_retried['customer_id']}:{row_retried['amount_cents']}"
f":{row_retried['billing_period']}:airbyte-billing"
)
key_retried = hashlib.sha256(raw_retried.encode()).hexdigest()[:32]
assert key == key_retried, "Idempotency key must be stable across Airbyte retry metadata changes"
# 2. Pre-flight check skips Stripe call for already-billed period
def test_preflight_check_skips_stripe_call(mock_db, mock_stripe):
mock_db.already_billed.return_value = True
result = process_usage_row(
conn=mock_db,
row={
"customer_id": "cus_001",
"stripe_customer_id": "cus_stripe_001",
"amount_cents": 4900,
"billing_period": "2026-07",
},
)
assert result["status"] == "skipped"
mock_stripe.Charge.create.assert_not_called()
# 3. Connection reset simulation: pre-flight skips all historical rows
def test_connection_reset_all_rows_skipped(mock_db, mock_stripe):
historical_rows = [
{"customer_id": f"cus_{i}", "stripe_customer_id": f"cus_stripe_{i}",
"amount_cents": 4900, "billing_period": "2026-05"}
for i in range(50)
]
# All 50 customers were already billed in 2026-05
mock_db.already_billed.return_value = True
results = [process_usage_row(mock_db, row) for row in historical_rows]
assert all(r["status"] == "skipped" for r in results)
mock_stripe.Charge.create.assert_not_called()
# 4. Vault key spend cap blocks data error (amount_cents ×100 error)
def test_vault_key_spend_cap_blocks_amount_error(mock_proxy):
# Expected: 50 customers × $49.00 = $2,450. Vault key capped at $2,695 (110%).
# Data error: amount_cents = 490000 ($4,900 per customer instead of $49.00)
# Cap is exhausted after the first customer; all subsequent calls blocked.
mock_proxy.post.side_effect = [
MagicMock(status_code=200, json=lambda: {"id": "ch_001"}),
MagicMock(status_code=402, json=lambda: {"error": "spend_cap_exceeded"}),
] + [MagicMock(status_code=402, json=lambda: {"error": "spend_cap_exceeded"})] * 48
results = process_billing_batch(
rows=[{"customer_id": f"cus_{i}", "amount_cents": 490000,
"billing_period": "2026-07"} for i in range(50)],
vault_key="vk_test_cap",
)
charged = [r for r in results if r["status"] == "charged"]
blocked = [r for r in results if r["status"] == "cap_exceeded"]
assert len(charged) == 1
assert len(blocked) == 49
# 5. PyAirbyte cache miss: pre-flight skips historical rows, charges only new ones
def test_pyairbyte_cache_miss_skips_historical(mock_db, mock_stripe):
# Cache miss returns 200 rows: 180 historical (already billed) + 20 new
def already_billed_side_effect(conn, customer_id, billing_period):
return int(customer_id.split("_")[1]) < 180 # first 180 already billed
mock_db.already_billed.side_effect = already_billed_side_effect
mock_stripe.Charge.create.return_value = {"id": "ch_new"}
rows = [
{"customer_id": f"cus_{i}", "stripe_customer_id": f"cus_s_{i}",
"amount_cents": 4900, "billing_period": "2026-07"}
for i in range(200)
]
results = [process_usage_row(mock_db, row) for row in rows]
charged = [r for r in results if r["status"] == "charged"]
skipped = [r for r in results if r["status"] == "skipped"]
assert len(charged) == 20
assert len(skipped) == 180
Frequently asked questions
Q: Does reading from Airbyte’s normalized final table instead of the raw staging table fully prevent retry duplicates?
A: It prevents duplicates caused by Airbyte retries that re-emit the same rows with new _airbyte_raw_id values, because the normalization pass deduplicates on the business primary key. It does not protect against cases where two rows represent different billing events for the same (customer_id, billing_period) pair (for example, a usage correction event added to the source table) or against cases where normalization has not yet completed when the billing trigger fires. The pre-flight database check is the authoritative guard: read from the normalized table as a first optimization, but always run the pre-flight check before every Stripe call.
Q: Can I use Airbyte’s connection-level webhook payload to distinguish a reset sync from an incremental sync?
A: Airbyte’s webhook payload includes a data.syncType field that may be full_refresh for reset syncs and incremental for standard syncs. You can use this to short-circuit billing on full-refresh syncs and instead alert the team to manually verify before charging. However, this is fragile: some connectors always run in full_refresh mode regardless of configuration, and the sync type field is not available in all webhook payload versions. The pre-flight check is more robust because it checks actual billing history rather than Airbyte’s sync metadata.
Q: How do I handle a PyAirbyte agent running in a serverless function where the cache is always empty?
A: Use an external cache backend instead of the default DuckDB local cache. PyAirbyte supports a MotherDuck cache (cloud-hosted DuckDB) or a custom BigQueryCache / PostgresCache backend that persists across invocations. With a persistent cache, the connector reads incrementally from the last cursor even in fresh Lambda cold starts. If an external cache is not feasible, add an AIRBYTE_BILLING_PERIOD environment variable and filter rows to the current billing period before calling Stripe, then rely on the pre-flight check as the final guard. Never rely on an absent cache to signal “no prior billing has happened.”
Q: How should the vault key TTL be sized for an Airbyte-triggered billing run?
A: Size the TTL to cover the full billing pipeline wall-clock time from vault key issuance to the last expected Stripe call, including Airbyte webhook delivery latency, the billing function startup time, all Stripe calls for the billing period, and retry overhead. For a billing run that processes 5,000 customers at 500ms per Stripe call, that is 2,500 seconds (42 minutes). Set the TTL to 90 minutes. If the billing run takes longer than expected (for example, due to Stripe rate limiting causing exponential backoff), the vault key expires and the proxy returns 401 Unauthorized. Catch stripe.error.AuthenticationError explicitly and log it as a structured error rather than re-raising, which would skip the database write for that customer and leave no record of the failure.
Q: Should I issue one vault key per Airbyte connection sync or one per billing period?
A: One per billing period. An Airbyte connection may sync multiple times per day (to keep the destination fresh), but billing for a given period should only happen once. A vault key scoped to the billing period with a spend cap based on the expected total for that period naturally enforces this: the first billing run exhausts the cap (or charges all customers and leaves headroom), and any subsequent billing run for the same period is blocked at the spend cap and handled by the pre-flight check. Do not issue a new vault key on every Airbyte sync webhook — issue it once when the billing window opens and revoke it when billing closes.
Q: How do I safely test an Airbyte billing pipeline against production customer data without charging anyone?
A: Issue a vault key with "spend_cap_usd": 0. Every Stripe call through that vault key is immediately rejected by the proxy with a 402 spend_cap_exceeded response. Your billing pipeline runs the full path — pre-flight check, idempotency key generation, Stripe call — and each call is blocked at the proxy with a structured error response you can log and verify. Once you have confirmed the correct customers and amounts would be charged, issue a live vault key with the real cap and run the production billing job against Stripe.
Put the brakes on your Airbyte billing pipeline
Keybrake issues per-billing-period vault keys your Airbyte downstream can use against Stripe — with spend caps that stop data errors before they propagate across your full customer base, plus a per-call audit log that shows every charge decision your pipeline made. Drop-in replacement for the Stripe SDK base URL.