Inngest · AI agents · API key security

Inngest AI agent API key: scoping vendor calls in durable TypeScript functions

Inngest is a durable workflow SDK popular with TypeScript and Next.js teams — step.run() for durable, automatically-retried steps, step.waitForEvent() for event-driven coordination between functions, and inngest.createFunction() for background job processing. AI agent teams adopt Inngest because it handles the hard parts of reliable async execution: retries, concurrency, event fanout, and long-running processes that survive serverless timeouts. When those functions call Stripe, Twilio, or Resend, Inngest's durability guarantees become vendor spend amplifiers: automatic retries run failed steps again, parallel step execution fans out to multiple simultaneous vendor calls, and there is no per-function-run dollar cap built into the SDK. This page covers the vault-key pattern that bounds vendor spend per Inngest function execution.

TL;DR

Issue a vault key in the first step.run() of your Inngest function, then pass the returned vault key to all subsequent steps that make vendor calls. Each function run gets its own vault key with its own dollar cap and endpoint allowlist. Inngest's step.run() memoization ensures the vault key is issued once and reused on retries — it won't be re-issued on every retry, so the cap isn't reset. The real Stripe or Twilio secret stays in Keybrake, never in your Inngest function code or environment variables.

How Inngest AI agent functions call vendor APIs

A typical Inngest AI agent function uses multiple step.run() calls to process an event and make vendor API calls durably:

import { inngest } from "./client";
import Stripe from "stripe";

export const billingAgent = inngest.createFunction(
  {
    id: "billing-agent",
    retries: 3,  // Inngest retries the entire function on uncaught errors
  },
  { event: "billing/subscription.process" },
  async ({ event, step }) => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);  // full-access key

    // These steps run durably — each retried independently on failure
    const customers = await step.run("fetch-customers", async () => {
      return fetchCustomersForPlan(event.data.plan_id);
    });

    // Parallel step execution: all charges fire simultaneously
    const charges = await Promise.all(
      customers.map((customer) =>
        step.run(`charge-${customer.id}`, async () => {
          return stripe.paymentIntents.create({
            amount: customer.amount_cents,
            currency: "usd",
            customer: customer.id,
          });
        })
      )
    );

    return { charged: charges.length };
  }
);

This is standard Inngest. Promise.all() over step.run() calls creates parallel step execution — all charge steps run simultaneously. If fetchCustomersForPlan returns 300 customers due to a data bug, 300 parallel Stripe calls fire with no cap. The retries: 3 setting means any uncaught error triggers up to 3 full-function retries, potentially re-running all the charge steps.

Three gaps Inngest's native tooling doesn't fill for vendor spend control

GapWhat happens in practiceInngest's answer
No per-function spend cap Inngest's concurrency setting controls how many function runs execute simultaneously, and throttle settings limit event ingestion rate. Neither controls the dollar amount of vendor API calls made inside a single function run. If a function processes 500 customers in a single run, 500 Stripe calls execute within that run regardless of the concurrency or throttle settings applied to the function. Inngest's built-in rate limiting controls function invocation rate (events per second), not the dollar value of vendor API calls made inside each invocation.
No step-level vendor revoke Inngest allows cancelling a function run mid-execution via the dashboard or API. Cancellation stops new steps from being dispatched but doesn't interrupt a step.run() that is currently executing on a serverless worker. On serverless platforms (Vercel, Netlify, Cloudflare Workers), the step's execution context is already allocated and running. The real Stripe key in the environment can be rotated, but this requires a redeployment and breaks all other functions in the same deployment that use the same Stripe key. Function run cancellation is available in the Inngest dashboard. No per-step API key scoping or mid-execution vendor call termination.
No per-call audit with Inngest step context Inngest's run history captures step inputs, outputs, and timing. It doesn't parse dollar amounts from Stripe responses, correlate Stripe PaymentIntent.id values with Inngest run_id and step IDs in a queryable cost table, or provide a per-run spend summary. Debugging an overcharge requires cross-referencing Inngest run history with the Stripe dashboard manually, matching on timestamps since there's no shared identifier. Inngest's observability UI shows step states and outputs. No structured vendor cost tracking or external transaction ID correlation.

Inngest's step.run() memoization and vault key re-use on retries

Inngest's step.run() is durable: if the function errors and retries, each previously-completed step returns its memoized result rather than re-executing. This is the key Inngest property that makes vault key scoping work cleanly — the vault key step runs once, the key is memoized, and all retries of downstream steps use the same vault key rather than issuing a new one.

This matters for cap semantics: the cap accumulates across all retries of the same function run. If a step makes a Stripe call, errors after the call (but before returning), and retries — the retry gets the same vault key, the cap has already accumulated the cost of the first call, and the idempotency key prevents a duplicate charge. The cap correctly reflects the total spend of the function run, including retried steps.

Scoping vault keys per Inngest function run

import { inngest } from "./client";
import Stripe from "stripe";

export const billingAgent = inngest.createFunction(
  {
    id: "billing-agent",
    retries: 3,
  },
  { event: "billing/subscription.process" },
  async ({ event, step, runId }) => {

    // Step 1: issue vault key — memoized on retries, so issued exactly once per run
    const { vaultKey } = await step.run("issue-vault-key", async () => {
      const res = 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: event.data.budget_usd ?? 300,
          allowed_endpoints: ["POST /v1/payment_intents"],
          expires_in: "2h",
          agent_run_label: `inngest/${event.name}/${runId}`,
        }),
      });
      const { vault_key } = await res.json();
      return { vaultKey: vault_key };
    });

    // Step 2: fetch customers
    const customers = await step.run("fetch-customers", async () => {
      return fetchCustomersForPlan(event.data.plan_id);
    });

    // Step 3: parallel charges using the vault key
    const charges = await Promise.all(
      customers.map((customer) =>
        step.run(`charge-${customer.id}`, async () => {
          const stripe = new Stripe(vaultKey);
          (stripe as any)._api.basePath = "https://proxy.keybrake.com/stripe";

          return stripe.paymentIntents.create({
            amount: customer.amount_cents,
            currency: "usd",
            customer: customer.id,
            idempotency_key: `${runId}-${customer.id}`,  // stable across retries
          });
        })
      )
    );

    return { charged: charges.length };
  }
);

The issue-vault-key step runs once and its result is memoized by Inngest's durability layer. On any retry of the function, this step returns the cached vaultKey immediately. The vault key is passed as a closure variable to all downstream step callbacks — Inngest's execution model re-runs the entire function body on retries, so the vaultKey variable is available in scope for all steps after the first.

Use runId in the idempotency key so that retries of the same step don't create duplicate charges. The agent_run_label includes runId so every vendor call in the audit log is traceable to the specific Inngest function run.

How Keybrake fits

Keybrake is the proxy layer between your Inngest functions and Stripe, Twilio, or Resend. The vault key issued in the first step replaces the full-access key that was previously read from process.env.STRIPE_SECRET_KEY in each step closure. The real Stripe secret stays in Keybrake — never in your function code, serverless environment variables, or Inngest step outputs. Cap enforcement fires atomically across all parallel step.run() calls sharing the same vault key. Revoking a runaway function run is a single DELETE /vault/keys/{key_id} call — the key expires on the next vendor call, without a redeployment or environment variable rotation.

Get early access

Related questions

How does step.run() memoization interact with vault key expiry?

Inngest memoizes the output of completed steps in its durable state store. If the vault key expires (TTL reached) while the function is still running, the memoized vaultKey value is still valid in Inngest's replay model — but calls to the proxy with the expired key will return 401. Set the vault key TTL to be longer than the maximum expected function run duration. For long-running functions (hours), set expires_in to match — or add a step that refreshes the vault key if the remaining TTL drops below a threshold. On function retries after days (if Inngest's retry schedule extends far), the memoized key may have expired; in this case, cancel the old key and issue a fresh one by checking the expiry before use.

Does this work with Inngest's fan-out pattern (sending events to trigger child functions)?

Inngest supports fan-out by having a parent function send multiple events that each trigger a separate child function run. If child functions make vendor calls, issue a vault key in each child function's setup step — not in the parent. Each child run gets its own vault key with its own cap. The parent can include a budget_usd field in the event payload that child functions use to set the vault key cap. This gives you per-child-run budget control even in fan-out scenarios where children are independent function executions.

How do I handle cap exhaustion 429s in Inngest's retry logic?

When the proxy returns 429 due to cap exhaustion, throw a custom error class in the step callback. Inngest's retry behavior is configurable per function and can be customized to not retry on specific error types using the NonRetriableError class: throw new NonRetriableError("Vendor spend cap exhausted"). This marks the function run as failed without consuming retry attempts. Cap exhaustion is an intentional stop, not a transient error — using NonRetriableError ensures Inngest treats it correctly. The X-Keybrake-Cap-Hit: true response header distinguishes cap exhaustion from transient vendor 429s that should still be retried.

Further reading