Trigger.dev · AI agents · API key security

Trigger.dev AI agent API key: scoping vendor calls in background task runs

Trigger.dev is a developer-friendly background jobs platform for TypeScript and JavaScript — task.trigger() and batch.triggerAndWait() for kicking off work, maxAttempts for automatic retries, wait.for() for delays within tasks, and real-time progress streaming back to the UI. AI agent teams adopt Trigger.dev because it handles the hard parts of reliable async work: durable execution that survives serverless timeouts, batch fan-out for per-customer processing, and retry logic for transient failures — all with a clean TypeScript SDK that integrates naturally with Next.js and other popular frameworks. When those tasks call Stripe, Twilio, or Resend, Trigger.dev's execution model creates vendor spend risks: batch.triggerAndWait() fans out to many parallel task runs each making vendor calls independently, automatic retries with maxAttempts multiply spend on failures, and there is no per-run dollar cap built into the platform. This page covers the vault-key pattern that bounds vendor spend per Trigger.dev task run.

TL;DR

Issue a vault key as the first operation in your parent task, then pass it in the payload to all subtasks triggered via batch.triggerAndWait(). Each parent task run issues one vault key, shared by all its child task runs — the cap accumulates atomically across all parallel executions. Trigger.dev's task memoization on retries means subtasks that completed their vendor calls don't re-issue calls if the parent retries. The real Stripe or Twilio secret stays in Keybrake, never in your task environment variables or Trigger.dev run payloads beyond the scoped vault key.

How Trigger.dev AI agent tasks call vendor APIs

A typical Trigger.dev AI agent task uses batch triggers to process customers in parallel:

import { task, batch } from "@trigger.dev/sdk/v3";
import Stripe from "stripe";

const chargeCustomerTask = task({
  id: "charge-customer",
  maxAttempts: 3,  // retries the entire task on failure
  run: async (payload: { customerId: string; amountCents: number }) => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);  // full-access key

    const paymentIntent = await stripe.paymentIntents.create({
      amount: payload.amountCents,
      currency: "usd",
      customer: payload.customerId,
    });

    return { chargeId: paymentIntent.id };
  },
});

const billingAgentTask = task({
  id: "billing-agent",
  run: async (payload: { planId: string; budgetUsd: number }) => {
    const customers = await fetchCustomersForPlan(payload.planId);

    // Triggers all charge tasks in parallel — no per-run dollar cap
    const results = await batch.triggerAndWait(
      customers.map((c) => ({
        id: "charge-customer",
        payload: { customerId: c.id, amountCents: c.amountCents },
      }))
    );

    return { charged: results.length };
  },
});

This is standard Trigger.dev. batch.triggerAndWait() triggers one charge-customer task run per customer in parallel. If fetchCustomersForPlan returns 500 customers, 500 simultaneous Stripe calls fire with no per-run dollar cap. The maxAttempts: 3 setting means each failed task run retries up to 3 times — potentially tripling vendor calls for every task that fails mid-run. A data bug that returns 3,000 customers instead of 300 triggers 3,000 Stripe calls before anyone notices.

Three gaps Trigger.dev's native tooling doesn't fill for vendor spend control

GapWhat happens in practiceTrigger.dev's answer
No per-run spend cap Trigger.dev's concurrency limits control how many task runs execute simultaneously, and queue rate limits control how many runs are dispatched per second. Neither controls the dollar amount of vendor calls made within each run. A batch of 500 customer task runs each making a $50 Stripe charge completes $25,000 in charges regardless of concurrency settings — just faster or slower depending on the concurrency limit. There is no mechanism to abort the batch when cumulative vendor spend crosses a dollar threshold. Trigger.dev's queue concurrency and rate limits control task throughput. No per-run or per-batch dollar cap for vendor API spend.
No task-level vendor revoke without environment changes Trigger.dev allows cancelling individual task runs or entire batches via the dashboard or API. Cancellation marks runs as cancelled and prevents new runs from starting, but doesn't interrupt a task that is currently executing on a worker. The Stripe API key in the task's environment (STRIPE_SECRET_KEY) can be rotated, but this requires redeploying the Trigger.dev worker — which terminates all running tasks. For a batch of 500 tasks, cancelling the runaway ones individually while leaving the legitimate ones running requires identifying which specific run IDs are responsible. Run and batch cancellation are available via Trigger.dev's dashboard and API. No per-task API key scoping or mid-execution vendor termination without redeployment.
No per-call audit with Trigger.dev run context Trigger.dev's run history captures task inputs, outputs, and timing per run. It doesn't parse dollar amounts from Stripe responses, correlate Stripe PaymentIntent.id values with Trigger.dev run IDs in a queryable cost table, or provide a per-batch spend summary. Debugging an overcharge means cross-referencing the Trigger.dev dashboard (which shows task outputs) with the Stripe dashboard, matching on timestamps since there's no shared identifier between the two systems at the vendor-call level. Trigger.dev's observability shows task run inputs and outputs per run. No structured vendor cost tracking or external transaction ID correlation.

Trigger.dev task retry behavior and vendor spend multiplication

Trigger.dev's maxAttempts retries the entire task run when it throws an unhandled error. If a charge-customer task calls Stripe successfully then throws an unrelated error (e.g., a database write fails after the charge completes), Trigger.dev retries the task — calling Stripe again. Without an idempotency key, this creates a duplicate charge. With maxAttempts: 3, a single transient database failure after a successful Stripe charge can result in up to 3 total Stripe calls for one customer.

In a batch of 500 tasks where 10% fail with transient errors, the retry math produces: 500 initial calls + 50 first retries + 5 second retries + 1 third retry = 556 total vendor calls from a 500-customer batch. Without idempotency keys and a spend cap, retries silently multiply the real vendor spend beyond what the task logic intends.

Scoping vault keys per Trigger.dev batch run

import { task, batch, AbortTaskRunError } from "@trigger.dev/sdk/v3";
import Stripe from "stripe";

const chargeCustomerTask = task({
  id: "charge-customer",
  maxAttempts: 3,
  run: async (payload: {
    customerId: string;
    amountCents: number;
    vaultKey: string;   // passed from parent task
    batchRunId: string; // stable across retries
  }) => {
    const stripe = new Stripe(payload.vaultKey, {
      // Point the Stripe SDK at the Keybrake proxy
      httpClient: Stripe.createNodeHttpClient(),
    });
    (stripe as any)._api.basePath = "https://proxy.keybrake.com/stripe";

    try {
      const paymentIntent = await stripe.paymentIntents.create({
        amount: payload.amountCents,
        currency: "usd",
        customer: payload.customerId,
        idempotency_key: `${payload.batchRunId}-${payload.customerId}`,
      });
      return { chargeId: paymentIntent.id };
    } catch (err: any) {
      if (err?.statusCode === 429 && err?.headers?.["x-keybrake-cap-hit"] === "true") {
        // Cap exhausted — don't retry, signal permanent failure
        throw new AbortTaskRunError("Vendor spend cap exhausted");
      }
      throw err; // Retriable errors bubble up to maxAttempts
    }
  },
});

const billingAgentTask = task({
  id: "billing-agent",
  run: async (payload: { planId: string; budgetUsd?: number }) => {
    // Issue one vault key for the entire batch
    const resp = await fetch("https://proxy.keybrake.com/vault/keys", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.KEYBRAKE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        vendor: "stripe",
        daily_usd_cap: payload.budgetUsd ?? 500,
        allowed_endpoints: ["POST /v1/payment_intents"],
        expires_in: "2h",
        agent_run_label: `trigger.dev/billing-agent/${context.run.id}`,
      }),
    });
    const { vault_key: vaultKey } = await resp.json();

    const customers = await fetchCustomersForPlan(payload.planId);

    const results = await batch.triggerAndWait(
      customers.map((c) => ({
        id: "charge-customer",
        payload: {
          customerId: c.id,
          amountCents: c.amountCents,
          vaultKey,                    // shared across all child runs
          batchRunId: context.run.id,  // stable across retries of the parent
        },
      }))
    );

    return { charged: results.filter((r) => r.ok).length };
  },
});

The vault key is issued once in the parent billing-agent task and passed in the payload to every child charge-customer task. All child runs share the same vault key — the dollar cap accumulates atomically across every parallel Stripe call. The idempotency key uses the parent task's run ID (context.run.id) combined with the customer ID — stable across retries of both the child task and the parent task.

The AbortTaskRunError signals to Trigger.dev that this failure is permanent and should not be retried — cap exhaustion is intentional, not a transient error that retry can fix. The agent_run_label includes the parent task run ID so every vendor call in the Keybrake audit log is traceable to the specific Trigger.dev batch execution.

How Keybrake fits

Keybrake is the proxy layer between your Trigger.dev tasks and Stripe, Twilio, or Resend. The vault key issued in the parent task replaces the full-access STRIPE_SECRET_KEY that was previously passed to the Stripe SDK in each child task. The real Stripe secret stays in Keybrake — never in your task environment variables or Trigger.dev run payloads beyond the scoped vault key. Cap enforcement fires atomically across all parallel child task runs sharing the same vault key. Revoking a runaway batch is a single DELETE /vault/keys/{key_id} call — no batch cancellation across hundreds of runs, no environment variable rotation, no redeployment.

Get early access

Related questions

Is it safe to put the vault key in the task payload? Trigger.dev stores run history.

The vault key in the task payload is a scoped key — it has a dollar cap, TTL, and endpoint allowlist. It is not the Keybrake admin API key or the real Stripe secret. If it's extracted from a Trigger.dev run history record, the attacker can only make vendor calls up to your configured cap before the key expires. For higher-security environments, store the vault key in your secrets manager after issuing it and pass the vault key ID in the payload instead — have each child task fetch the key from the secrets manager using the ID, so the actual key value isn't stored in Trigger.dev's database.

What happens if the parent task retries and tries to issue a new vault key?

If the parent billing-agent task fails and Trigger.dev retries it, the vault key issuance step runs again and issues a fresh vault key. This is correct behavior — the previous vault key is associated with the failed run and may have been partially used. To prevent this, you can check for an existing vault key before issuing a new one: include the parent run ID in the vault key's agent_run_label, query the Keybrake API for keys with that label at task start, and reuse the existing one if found. This makes the parent task idempotent with respect to vault key issuance across retries.

How do I use this pattern with Trigger.dev's wait.for() delays inside tasks?

Trigger.dev's wait.for() suspends the task for a duration, during which the serverless worker is released. When the task resumes, it runs on potentially a different worker but with the same task context and payload. The vault key passed in the payload is still available after the delay — it's part of the task input, not stored in worker memory. Set the vault key TTL to exceed the maximum total task duration including wait delays. If a task has a 1-hour wait.for() delay and subsequent Stripe calls, set expires_in to at least 90 minutes.

Further reading