Trigger.dev Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Trigger.dev is a TypeScript-native background jobs platform for durable task execution — task() with maxAttempts for automatic retry, batch.triggerAndWait() for parallel fan-out across customers or records, and schedules.task() for cron-triggered recurring work. AI agent teams adopt Trigger.dev because it turns unreliable serverless functions into durable, observable workflows: tasks survive cold starts, retries are built-in, and the dashboard gives real-time visibility into every run. When those tasks include Stripe billing — usage-based charges at task completion, per-customer subscription renewals fanned out with batch triggers, or scheduled monthly billing jobs — Trigger.dev's execution model introduces three specific failure modes that the platform's own tooling does not prevent.
This post covers all three failure modes with TypeScript code, and the two-layer governance pattern — content-hash idempotency keys plus per-task vault keys via a spend-cap proxy — that eliminates all three without restructuring your Trigger.dev workflow.
Failure mode 1: maxAttempts re-runs the billing task from line 1 on any downstream exception after the charge succeeded
Trigger.dev's maxAttempts setting retries the entire task run when any unhandled exception escapes the run function. This is the correct behavior for transient failures — a network timeout fetching customer data, a database connection drop during record lookup — where re-running the task from the beginning is safe. The problem arises in billing tasks where Stripe is called in the middle of the task and a subsequent step fails. The stripe.paymentIntents.create() call succeeds and returns a PaymentIntent object, but a database write that records the charge ID then throws a timeout. Trigger.dev marks the task as failed and retries from line 1 of run. On the retry, stripe.paymentIntents.create() fires again with no idempotency key. Stripe creates a second PaymentIntent — a second charge for the same customer in the same billing period.
With maxAttempts: 3 and a database that intermittently times out, a single customer billing task can produce up to three Stripe charges before Trigger.dev exhausts its retry budget and marks the task as failed. The Trigger.dev dashboard shows three task attempts, each marked failed (because the database write never succeeded), but Stripe shows two or three PaymentIntents in the succeeded state. The mismatch is invisible until a customer disputes a duplicate charge.
// billing.ts — UNSAFE: no idempotency key, downstream exception triggers duplicate charge
import { task } from "@trigger.dev/sdk/v3";
import Stripe from "stripe";
export const chargeCustomerTask = task({
id: "charge-customer",
maxAttempts: 3,
run: async (payload: {
customerId: string;
amountCents: number;
billingPeriod: string;
}) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// UNSAFE: no idempotency key
// If writeChargeRecord() throws below, Trigger.dev retries this task.
// On retry, stripe.paymentIntents.create() fires again → ch_B created.
const paymentIntent = await stripe.paymentIntents.create({
amount: payload.amountCents,
currency: "usd",
customer: payload.customerId,
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: "never" },
});
// Database write: if this throws (timeout, deadlock, connection reset),
// Trigger.dev retries the entire task — including the Stripe call above
await writeChargeRecord(paymentIntent.id, payload.customerId, payload.billingPeriod);
return { chargeId: paymentIntent.id };
},
});
The fix is a content-hash idempotency key derived from the billing inputs — customer ID, amount, and billing period — combined with a framework-specific salt. This key is computed before the Stripe call and is identical across every retry with the same inputs. When Trigger.dev retries after a database failure, the Stripe call fires again with the same idempotency key, and Stripe returns the original PaymentIntent object without creating a new charge. Downstream failures become safe to retry.
Additionally, distinguish between retriable failures (database timeouts, network errors) and permanent failures (card declined, customer not found) by catching Stripe-specific errors and throwing AbortTaskRunError for non-retriable conditions. This tells Trigger.dev to stop retrying and mark the task as permanently failed — preventing further Stripe calls on errors that retrying will never resolve.
// billing.ts — SAFE: content-hash idempotency key + AbortTaskRunError for non-retriable failures
import { task, AbortTaskRunError } from "@trigger.dev/sdk/v3";
import Stripe from "stripe";
import { createHash } from "crypto";
function billingIdempotencyKey(
customerId: string,
amountCents: number,
billingPeriod: string
): string {
const raw = `${customerId}:${amountCents}:${billingPeriod}:triggerdev-billing`;
return createHash("sha256").update(raw).digest("hex").slice(0, 32);
}
export const chargeCustomerTask = task({
id: "charge-customer",
maxAttempts: 3,
run: async (payload: {
customerId: string;
amountCents: number;
billingPeriod: string;
vaultKey: string; // passed from parent task — see failure mode 2
}) => {
// Idempotency key is stable across every retry with the same inputs
const idempotencyKey = billingIdempotencyKey(
payload.customerId,
payload.amountCents,
payload.billingPeriod
);
const stripe = new Stripe(payload.vaultKey, {
httpClient: Stripe.createNodeHttpClient(),
});
// Point the Stripe SDK at the Keybrake proxy
(stripe as any)._api.basePath = "https://proxy.keybrake.com/stripe";
let paymentIntent: Stripe.PaymentIntent;
try {
paymentIntent = await stripe.paymentIntents.create(
{
amount: payload.amountCents,
currency: "usd",
customer: payload.customerId,
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: "never" },
},
{ idempotencyKey }
);
} catch (err: any) {
if (err?.type === "StripeCardError" || err?.type === "StripeInvalidRequestError") {
// Card declined, invalid customer — retrying will not help
throw new AbortTaskRunError(`Stripe permanent error: ${err.message}`);
}
if (err?.statusCode === 429 && err?.headers?.["x-keybrake-cap-hit"] === "true") {
// Spend cap exhausted — not a transient error
throw new AbortTaskRunError("Vendor spend cap exhausted for this billing period");
}
throw err; // Network errors, 5xx — safe to retry with the same idempotency key
}
// Database write: if this throws, Trigger.dev retries. The Stripe call above
// will return the original PaymentIntent (same idempotency key) — no duplicate.
await writeChargeRecord(paymentIntent.id, payload.customerId, payload.billingPeriod);
return { chargeId: paymentIntent.id };
},
});
Failure mode 2: batch.triggerAndWait() fan-out shares one unrestricted STRIPE_SECRET_KEY across all parallel task runs with no per-task spend cap
A Trigger.dev billing workflow that processes a customer cohort typically uses batch.triggerAndWait() to fan out — one child task run per customer, all executing in parallel. The child tasks each read process.env.STRIPE_SECRET_KEY to initialize the Stripe client. All 500 task runs share the same unrestricted key. There is no per-task dollar cap built into Trigger.dev: concurrency limits control how many task runs execute simultaneously, but not how much money each run is allowed to charge. A data bug that passes amountCents: 49999 (intended: 4999) to all 500 customers triggers $24,999.50 in charges before any result returns from batch.triggerAndWait(). Because all 500 tasks share one unrestricted key, the first failed task does not stop the others — the fan-out completes all Stripe calls and only then returns to the parent task with the error visible in the results array.
// billing-agent.ts — UNSAFE: all child tasks share one unrestricted STRIPE_SECRET_KEY
import { task, batch } from "@trigger.dev/sdk/v3";
import { chargeCustomerTask } from "./charge-customer";
export const billingAgentTask = task({
id: "billing-agent",
run: async (payload: { planId: string }) => {
const customers = await fetchCustomersForPlan(payload.planId);
// UNSAFE: batch.triggerAndWait() triggers N concurrent charge-customer tasks.
// Each reads process.env.STRIPE_SECRET_KEY — the same unrestricted full-access key.
// A data bug (wrong amountCents) hits all 500 customers simultaneously.
// No per-task cap stops the fan-out — all charges fire before any error is surfaced.
const results = await batch.triggerAndWait(
customers.map((c) => ({
id: "charge-customer",
payload: {
customerId: c.id,
amountCents: c.amountCents, // if this is 10× too high, all 500 customers overcharged
billingPeriod: payload.planId,
},
}))
);
return { charged: results.runs.filter((r) => r.ok).length };
},
});
The fix is to issue a per-task vault key in the parent task before calling batch.triggerAndWait() and pass each vault key in the corresponding child task payload. Each vault key is capped at the customer's expected charge amount plus a 10% buffer, scoped to POST /v1/payment_intents only, and expires after the billing window. A data bug that passes an oversized amount hits one customer's vault key cap on the first Stripe call — the proxy returns a 402 before the PaymentIntent is created. Because each child task has its own vault key, the cap failure for one customer does not affect the others. The parent task can inspect the results and alert on any cap-exhausted failures without having already charged the incorrect amount.
// billing-agent.ts — SAFE: per-customer vault keys issued before fan-out
import { task, batch, context } from "@trigger.dev/sdk/v3";
import { chargeCustomerTask } from "./charge-customer";
const KEYBRAKE_API_KEY = process.env.KEYBRAKE_API_KEY!;
const KEYBRAKE_PROXY_URL = process.env.KEYBRAKE_PROXY_URL ?? "https://proxy.keybrake.com";
async function issueVaultKey(
customerId: string,
amountCents: number,
billingPeriod: string
): Promise {
const resp = await fetch(`${KEYBRAKE_PROXY_URL}/vault/keys`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEYBRAKE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
vendor: "stripe",
daily_usd_cap: Math.ceil((amountCents * 1.1) / 100), // expected amount + 10% buffer
allowed_endpoints: ["POST /v1/payment_intents"],
expires_in_seconds: 7200, // 2-hour TTL covers task queue depth
label: `triggerdev/${billingPeriod}/${customerId}`,
}),
});
if (!resp.ok) throw new Error(`Vault key issuance failed: ${resp.status}`);
const { vault_key } = await resp.json();
return vault_key;
}
export const billingAgentTask = task({
id: "billing-agent",
run: async (payload: { planId: string; budgetUsd?: number }) => {
const customers = await fetchCustomersForPlan(payload.planId);
// Issue all vault keys before any child task is triggered.
// If issuance fails for any customer, abort before any Stripe charge is attempted.
const customerPayloads = await Promise.all(
customers.map(async (c) => ({
id: "charge-customer" as const,
payload: {
customerId: c.id,
amountCents: c.amountCents,
billingPeriod: payload.planId,
vaultKey: await issueVaultKey(c.id, c.amountCents, payload.planId),
},
}))
);
const results = await batch.triggerAndWait(customerPayloads);
const capHits = results.runs.filter(
(r) => !r.ok && r.error?.message?.includes("spend cap exhausted")
);
if (capHits.length > 0) {
// Alert without failing the parent — cap hits are intentional
console.warn(`${capHits.length} tasks hit spend cap — review input data`);
}
return {
charged: results.runs.filter((r) => r.ok).length,
capHits: capHits.length,
};
},
});
Failure mode 3: schedules.task() without concurrencyKey fires overlapping billing runs for the same period
Trigger.dev's schedules.task() creates a cron-triggered task that fires on a defined schedule. Monthly billing workflows are a natural fit: fire on the first of the month, fan out across all customers, and reconcile. The failure mode occurs when the scheduled billing run for a period is still executing — perhaps the customer cohort is large or downstream systems are slow — and a second trigger fires before the first completes. This can happen in two ways: a developer manually triggers the billing task from the Trigger.dev dashboard to process a specific customer segment while the scheduled run is active, or a schedule mis-configuration causes overlapping fires (e.g., a cron of 0 * * * * intended as monthly but interpreted as hourly). Without a concurrency constraint scoped to the billing period, Trigger.dev launches a second task run in parallel with the first. Both runs fetch the same customer list, issue separate vault keys, and call stripe.paymentIntents.create() for the same customers. Even with idempotency keys, the two runs derive the same idempotency key — and Stripe's deduplication window returns the original PaymentIntent, not a second charge. But at the vault-key layer, both runs have issued separate vault keys, both runs make Stripe calls, and both runs record a "charge succeeded" outcome — creating confusing duplicate audit records and potentially incorrect business metrics.
// monthly-billing.ts — UNSAFE: no concurrency constraint, overlapping runs possible
import { schedules } from "@trigger.dev/sdk/v3";
import { billingAgentTask } from "./billing-agent";
// UNSAFE: if this task takes > 1h (large cohort, slow DB), a manual trigger or
// clock drift can fire a second run while the first is still executing.
// Both runs bill the same period, issue separate vault keys, make separate Stripe calls.
export const monthlyBillingSchedule = schedules.task({
id: "monthly-billing",
cron: "0 3 1 * *", // first of month at 03:00 UTC
run: async (payload) => {
const billingPeriod = payload.timestamp.toISOString().slice(0, 7); // "2026-07"
await billingAgentTask.triggerAndWait({ planId: billingPeriod });
return { billingPeriod, done: true };
},
});
The fix is to attach a queue with concurrencyLimit: 1 and a stable concurrencyKey derived from the billing period to the scheduling task. Trigger.dev will hold any new run in the queue rather than launching it while a run with the same concurrency key is active. A manual trigger during an active scheduled run waits in queue — it does not launch a second concurrent execution. If the queue fills beyond the concurrency limit, Trigger.dev can be configured to drop or report the excess, giving an operator signal that the billing run exceeded its expected duration.
// monthly-billing.ts — SAFE: queue concurrencyLimit:1 prevents overlapping billing runs
import { schedules, queue } from "@trigger.dev/sdk/v3";
import { billingAgentTask } from "./billing-agent";
// One concurrent run per billing period — second trigger queues, does not launch
const billingQueue = queue({
name: "monthly-billing-queue",
concurrencyLimit: 1,
});
export const monthlyBillingSchedule = schedules.task({
id: "monthly-billing",
cron: "0 3 1 * *",
queue: billingQueue,
run: async (payload) => {
const billingPeriod = payload.timestamp.toISOString().slice(0, 7);
// Pre-flight: check if this period was already fully billed
const alreadyBilled = await db.billingRuns.findOne({
period: billingPeriod,
status: "completed",
});
if (alreadyBilled) {
console.log(`Period ${billingPeriod} already billed — skipping`);
return { billingPeriod, skipped: true };
}
await billingAgentTask.triggerAndWait({ planId: billingPeriod });
await db.billingRuns.upsert({ period: billingPeriod, status: "completed" });
return { billingPeriod, done: true };
},
});
The pre-flight database check adds a second layer of protection: even if two runs somehow enter the queue with the same billing period (different queue configurations, infrastructure anomaly), the completed-period guard prevents the second run from triggering any Stripe calls. The combination of Trigger.dev queue concurrency and application-level period deduplication eliminates both the infrastructure-level and application-level overlap failure modes.
Approach comparison
| Approach | Retry safe? | Fan-out cap? | Schedule overlap safe? | Per-task audit? | Mid-run revoke? |
|---|---|---|---|---|---|
| Bare key, no idempotency key | No — every retry is a new charge | No | No | No | No |
| Content-hash idempotency key only | Yes (within 24h Stripe window) | No | Partial — Stripe deduplicates but audit log doubles | No | No |
| Trigger.dev queue concurrencyLimit only | No | No | Yes | No | No |
| Stripe restricted key (no proxy) | No | No — endpoint scope only, no dollar cap | No | No | No |
| Content-hash idem key + concurrencyLimit:1 + vault key via Keybrake | Yes | Yes — per-task dollar cap | Yes | Yes | Yes — single DELETE /vault/keys/{id} |
Vitest enforcement suite
// billing.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHash } from "crypto";
function billingIdempotencyKey(
customerId: string,
amountCents: number,
billingPeriod: string
): string {
const raw = `${customerId}:${amountCents}:${billingPeriod}:triggerdev-billing`;
return createHash("sha256").update(raw).digest("hex").slice(0, 32);
}
describe("Trigger.dev billing governance", () => {
it("idempotency key is identical across retries with same inputs", () => {
const key1 = billingIdempotencyKey("cus_abc", 4999, "2026-07");
const key2 = billingIdempotencyKey("cus_abc", 4999, "2026-07");
expect(key1).toBe(key2);
expect(key1).toHaveLength(32);
});
it("idempotency key differs across billing periods", () => {
const keyJun = billingIdempotencyKey("cus_abc", 4999, "2026-06");
const keyJul = billingIdempotencyKey("cus_abc", 4999, "2026-07");
expect(keyJun).not.toBe(keyJul);
});
it("idempotency key differs across customers", () => {
const keyA = billingIdempotencyKey("cus_abc", 4999, "2026-07");
const keyB = billingIdempotencyKey("cus_xyz", 4999, "2026-07");
expect(keyA).not.toBe(keyB);
});
it("vault key is sourced from payload, not process.env", async () => {
const mockCreate = vi.fn().mockResolvedValue({ id: "pi_test", status: "succeeded" });
const MockStripe = vi.fn().mockImplementation((apiKey: string) => {
// Must use the per-customer vault key from payload, not the env var secret
expect(apiKey).toMatch(/^kb_/);
expect(apiKey).not.toBe(process.env.STRIPE_SECRET_KEY);
return { paymentIntents: { create: mockCreate } };
});
const vaultKey = "kb_test_vault_abc123";
const stripe = new MockStripe(vaultKey) as any;
await stripe.paymentIntents.create({ amount: 4999, currency: "usd", customer: "cus_abc", confirm: true });
expect(mockCreate).toHaveBeenCalledOnce();
});
it("spend cap error triggers AbortTaskRunError, not retry", () => {
const capError = Object.assign(new Error("Vendor spend cap exhausted"), {
statusCode: 429,
headers: { "x-keybrake-cap-hit": "true" },
});
// AbortTaskRunError signals to Trigger.dev: permanent failure, do not retry
expect(() => {
if (capError.headers?.["x-keybrake-cap-hit"] === "true") {
throw new Error("AbortTaskRunError: spend cap exhausted");
}
}).toThrow("AbortTaskRunError");
});
});
Gap analysis
1. task.batchTrigger() without triggerAndWait makes vault key issuance failure silent
When using task.batchTrigger() (fire-and-forget) instead of batch.triggerAndWait(), the parent task does not wait for child results. If vault key issuance fails for one customer during the pre-fan-out loop, the parent task must handle the error explicitly — the default behavior is for the parent to throw and fail, but by that point some child tasks may already have been triggered with valid vault keys. Those in-flight tasks will proceed to call Stripe. Use triggerAndWait for billing fan-outs where all-or-nothing semantics matter, or implement explicit pre-issuance validation before any batchTrigger call.
2. Trigger.dev's idempotencyKey option on task.trigger() is different from Stripe's idempotency key
Trigger.dev's SDK accepts an idempotencyKey option when triggering a task — this deduplicates task runs at the Trigger.dev layer, not at the Stripe layer. Two calls to chargeCustomerTask.trigger(payload, { idempotencyKey: "..." }) with the same key will result in one task run. Inside that task run, a separate Stripe-level idempotency key must still be passed to stripe.paymentIntents.create(). The two idempotency keys are complementary and both necessary: Trigger.dev's deduplicates task launches; Stripe's deduplicates vendor API calls within a single task run during retries.
3. wait.for() suspension releases the worker — vault key TTL must cover the full suspension window
Some Trigger.dev billing workflows use wait.for() to pause between batch segments or wait for external confirmation before charging. During a wait.for() suspension, the serverless worker is released and the vault key TTL continues to count down. If a task issues a vault key before calling wait.for({ hours: 2 }) and then makes a Stripe call after resumption, the vault key must have a TTL exceeding the total task duration including all wait periods. Set expires_in_seconds on the vault key to at least the maximum expected task wall-clock time plus a 30-minute buffer. For tasks with variable wait durations, consider re-issuing the vault key after resumption rather than pre-issuing before the wait.
4. Trigger.dev run metadata logged to the dashboard may include vault key values
Trigger.dev's dashboard captures task payloads, return values, and any data passed to logger.info() or logger.trace() calls within the task. If a vault key is part of the task payload (as in the fan-out pattern above), it appears in the Trigger.dev dashboard's run detail view under "Payload." The vault key is scoped — it has a dollar cap, TTL, and endpoint allowlist — but anyone with access to the Trigger.dev dashboard can read it during the TTL window. For higher-security environments, pass a vault key reference ID in the payload instead of the key itself, and have each child task fetch the key by ID from a secrets manager at run start. The key value never appears in Trigger.dev's database.
Put the brakes on your Trigger.dev billing tasks
Keybrake issues per-task vault keys, enforces dollar caps atomically across your batch.triggerAndWait() fan-outs, and logs every Stripe call with Trigger.dev run context. One proxy swap — no Trigger.dev restructuring required.
Frequently asked questions
Is it safe to pass the vault key in the Trigger.dev task payload?
The vault key in the payload is a scoped credential — it has a dollar cap, a short TTL, and an endpoint allowlist. It is not your Keybrake admin API key or your real Stripe secret. If extracted from a Trigger.dev run record, an attacker can only make Stripe calls up to the configured cap and only to the allowed endpoints, until the key expires. For teams with stricter security requirements, pass a vault key ID in the payload and have each child task fetch the actual key from AWS Secrets Manager or similar at run start. The key value never enters Trigger.dev's storage.
What happens if the parent billing task fails after issuing vault keys but before all child tasks complete?
If the parent billing-agent task fails after issuing vault keys and after some (but not all) child tasks were triggered, the already-triggered child tasks continue to completion — Trigger.dev does not cancel in-flight child tasks when a parent fails. Vault keys issued for the remaining untriggered tasks are held by the parent and effectively abandoned (they expire after their TTL). On parent task retry, new vault keys are issued. The content-hash idempotency key inside each child task ensures that customers whose child tasks already completed are not double-charged when the parent retries and re-fans-out.
Does Trigger.dev's task memoization protect against retry-based double charges?
Trigger.dev does not have step-level memoization like Inngest's step.run(). When a Trigger.dev task retries, the entire run function executes from line 1 on the new attempt — there is no automatic caching of individual operation results within the task. This makes idempotency keys inside the Stripe call essential. Trigger.dev's task-level idempotencyKey (on task.trigger()) prevents duplicate task launches but does not protect against retries within a single task that has already reached the Stripe call.
Can I use Trigger.dev's built-in concurrency controls instead of a vault key cap?
Trigger.dev's concurrency limits control how many task runs execute simultaneously — for example, concurrencyLimit: 10 on the charge-customer task limits parallel Stripe calls to 10 at a time. This slows a runaway fan-out but does not stop it: 500 customers at 10 concurrency still produces 500 Stripe charges, just over multiple batches. There is no mechanism in Trigger.dev's concurrency system to abort the fan-out when cumulative dollar spend crosses a threshold. A vault key cap is the only mechanism that enforces a dollar limit at the per-task or per-batch level.
How do I handle customers whose vault key cap is too low for their actual charge amount?
Set the vault key cap to the expected maximum charge amount plus a buffer (10% is common). If the actual charge exceeds the cap, the Keybrake proxy returns a 402 and the task throws. Catch cap errors specifically (check x-keybrake-cap-hit: true in the response headers) and throw AbortTaskRunError to prevent Trigger.dev from retrying — a cap hit is not a transient error. Route cap-hit task results to a separate alerting queue so operators can review the billing input data before manually re-issuing the task with a corrected amount or an increased cap.
What happens to vault keys if the Keybrake proxy is unreachable during a billing run?
If the Keybrake proxy is unreachable when the child task calls stripe.paymentIntents.create() through the proxy URL, the Stripe SDK throws a network error. Trigger.dev retries the task (up to maxAttempts) — and each retry will also fail if the proxy remains unreachable. No Stripe charges are made while the proxy is down. Once the proxy recovers, the retry fires the same idempotency key and succeeds on the first attempt. Monitor proxy health separately from task run health so an outage is visible before it exhausts your task retry budgets.
Further reading
- Trigger.dev AI agent API key — the vault-key pattern for Trigger.dev's
batch.triggerAndWait()fan-out, with TypeScript SDK examples and cap enforcement details. - Inngest Stripe Integration — similar TypeScript background jobs platform;
step.run()memoization is the Inngest equivalent of Trigger.dev's task retry, with different idempotency semantics. - AI agent idempotency — the full content-hash idempotency key design, Stripe's 24-hour deduplication window, and how to build stable keys for background job platforms.
- AI agent spend reporting — per-task vendor spend aggregation patterns for billing systems that fan out across many customers.