Inngest Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance

Inngest's automatic step retry is what makes durable AI agent pipelines practical on it. It is also the mechanism most likely to silently fire duplicate Stripe charges when your billing step fails at exactly the wrong moment.

Inngest is a durable workflow SDK for TypeScript and JavaScript that runs serverless-style functions as durable, retryable workflows. The core abstraction is step.run(): a named, individually retried unit of work that Inngest memoizes on completion — so a replay or retry of the outer function does not re-execute steps that already succeeded. For AI agents that orchestrate billing — usage-based charges, subscription renewals, batch invoice runs — Inngest is an appealing orchestration layer. The retry and memoization semantics that make it fault-tolerant for most API calls become a subtle liability when the API call is a Stripe charge.

This post covers three failure modes specific to Inngest's architecture — step.run() retry on mid-callback throw, step.sendEvent() fan-out with no cross-run dedup, and module-level Stripe key sharing across fan-out function instances — with TypeScript SDK code for each, and the two-layer governance pattern that closes all three without changing how you structure your Inngest functions.

Failure mode 1: step.run() retries the billing callback when it throws after the charge

Inngest's step.run() memoization works like this: if the callback completes without throwing, Inngest records the return value and will replay it from memory on any future retry of the outer function. If the callback throws, Inngest marks the step as failed and retries the full callback — not just the line that threw, but the entire callback from the top. This is the correct behavior for most operations. For Stripe charges, it means that if your callback calls stripe.charges.create(), gets a successful response, and then throws on the next line (database write failure, downstream API error, validation error on the response object), Inngest will retry the entire callback. Without an idempotency key, Stripe treats the retry as a new request and creates a second charge.

// billing.ts — UNSAFE: no idempotency key inside step.run()
import Stripe from "stripe";
import { inngest } from "./inngest-client";

export const billCustomer = inngest.createFunction(
  { id: "bill-customer" },
  { event: "billing/customer.charge" },
  async ({ event, step }) => {
    const { customerId, amountCents, billingPeriod } = event.data;

    const chargeId = await step.run("charge-stripe", async () => {
      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

      // Charge succeeds — Stripe returns ch_A
      const charge = await stripe.charges.create({
        amount: amountCents,
        currency: "usd",
        customer: customerId,
        description: `Subscription ${billingPeriod}`,
        // No idempotency_key — each step retry = new charge
      });

      // If this database write throws, step.run() re-executes from the top
      // stripe.charges.create() fires again → ch_B, then ch_C on retry 2
      await db.writeChargeRecord(customerId, charge.id, billingPeriod);

      return charge.id;
    });

    return { chargeId };
  }
);

The failure sequence: stripe.charges.create() returns ch_A. db.writeChargeRecord() throws a ConnectionTimeoutError. The step.run("charge-stripe") callback throws. Inngest marks the step as failed and schedules retry 1 (with exponential backoff; default max retries is 4). Retry 1 runs the callback from the top. stripe.charges.create() fires again. Stripe creates ch_B. If the database write fails again, retry 2 creates ch_C. With four retries and a persistent database failure, the customer has been charged five times before Inngest gives up and marks the function run as failed. The function run log shows "charge-stripe: failed after 4 retries" — not four successful charges.

This is the most dangerous failure mode in Inngest because it is completely invisible at the Inngest layer. The step UI shows a failure, not a duplicate. The Stripe dashboard shows multiple charge objects from the same customer, created seconds apart, with no shared metadata linking them to the retry context. Finding the duplicates requires cross-referencing Stripe timestamps against Inngest retry timestamps.

The fix: content-hash idempotency key inside step.run()

The idempotency key must be computed outside the Stripe call and derived from parameters that are stable across retries — not from a UUID generated inside the callback, which would produce a different key on each run.

// billing.ts — SAFE: content-hash idempotency key + vault key via proxy
import Stripe from "stripe";
import { createHash } from "crypto";
import { inngest } from "./inngest-client";

function billingIdempotencyKey(
  customerId: string,
  amountCents: number,
  billingPeriod: string
): string {
  const raw = `${customerId}:${amountCents}:${billingPeriod}:inngest-billing`;
  return createHash("sha256").update(raw).digest("hex").slice(0, 32);
}

export const billCustomer = inngest.createFunction(
  { id: "bill-customer" },
  { event: "billing/customer.charge" },
  async ({ event, step }) => {
    const { customerId, amountCents, billingPeriod, vaultKey } = event.data;

    const chargeId = await step.run("charge-stripe", async () => {
      // vault_key: POST /v1/charges only, $500/day cap, expires in 24h
      const stripe = new Stripe(vaultKey, {
        host: "proxy.keybrake.com",
        protocol: "https",
        port: 443,
        basePath: "/stripe",
      });

      const idempKey = billingIdempotencyKey(customerId, amountCents, billingPeriod);

      // Safe to retry — Stripe deduplicates within 24h on idempotency_key
      const charge = await stripe.charges.create(
        {
          amount: amountCents,
          currency: "usd",
          customer: customerId,
          description: `Subscription ${billingPeriod}`,
          metadata: { billing_period: billingPeriod },
        },
        { idempotencyKey: idempKey }
      );

      await db.writeChargeRecord(customerId, charge.id, billingPeriod);
      return charge.id;
    });

    return { chargeId };
  }
);

The idempotency key is a SHA-256 hash of (customerId, amountCents, billingPeriod, 'inngest-billing'). This string is identical on every retry of the same billing operation — the parameters come from the event payload, which does not change between retries. Stripe's deduplication returns the original charge object for any request with the same key within 24 hours, even if the first call already completed. The vaultKey is passed in the event payload rather than read from process.env, so each event can carry a different key scoped to the specific customer or billing tier.

Failure mode 2: step.sendEvent() fan-out creates concurrent billing runs with no cross-run deduplication

Inngest functions often orchestrate batch operations by using step.sendEvent() to trigger child function runs — one run per customer, per invoice, or per billing cycle item. The Inngest documentation recommends this fan-out pattern for parallel workloads. Each child run has its own step state and retry budget, which is correct for isolation. The problem is that step.sendEvent() is itself a step and gets memoized: if the parent function is replayed, it will not re-send the events. However, if the parent function is triggered twice — by a webhook double-delivery, an upstream retry, or an operator manually re-running the function — two independent runs of the parent both reach step.sendEvent(), and both send a full batch of billing events. Each event triggers a child billing function run. The result is two concurrent billing runs per customer, both reaching stripe.charges.create() simultaneously.

// billing-orchestrator.ts — UNSAFE: no event deduplication, child runs share bare key
import { inngest } from "./inngest-client";

export const billingOrchestrator = inngest.createFunction(
  { id: "billing-orchestrator" },
  { event: "billing/monthly.start" },
  async ({ event, step }) => {
    const customers = await step.run("fetch-customers", () =>
      db.getActiveSubscribers()
    );

    // UNSAFE: if this function is triggered twice (webhook retry, operator re-run),
    // two parent runs both call step.sendEvent() with the same customer list.
    // Inngest memoizes step.sendEvent() per-run — but two separate function RUNS
    // each have their own step state. Both fire the full batch.
    await step.sendEvent("fan-out-billing", customers.map((c) => ({
      name: "billing/customer.charge",
      data: {
        customerId: c.id,
        amountCents: c.amountCents,
        billingPeriod: event.data.billingPeriod,
        stripeKey: process.env.STRIPE_SECRET_KEY, // bare key — shared across ALL runs
      },
    })));

    return { fanned_out: customers.length };
  }
);

If the billing/monthly.start event is delivered twice — a common scenario when the event source is a cron job with at-least-once delivery, or when a Stripe webhook is retried before Inngest acknowledges — both parent runs execute step.sendEvent() with their own step memoization context. Two identical batches of billing/customer.charge events land in Inngest's queue. Each event spins up a child function run. All 200 customers are charged twice.

The fix has two parts: event deduplication at the parent level, and per-customer vault keys with independent spend caps in the child payload.

// billing-orchestrator.ts — SAFE: event ID dedup + per-customer vault keys
import { inngest } from "./inngest-client";
import { createHash } from "crypto";

export const billingOrchestrator = inngest.createFunction(
  { id: "billing-orchestrator" },
  { event: "billing/monthly.start" },
  async ({ event, step }) => {
    const { billingPeriod } = event.data;

    const customers = await step.run("fetch-customers", () =>
      db.getActiveSubscribers()
    );

    // Each child event gets a stable ID: Inngest deduplicates events with the same
    // ID within a 24h window — second delivery of the same event is a no-op.
    await step.sendEvent("fan-out-billing", customers.map((c) => ({
      name: "billing/customer.charge",
      id: createHash("sha256")
        .update(`${c.id}:${billingPeriod}:inngest-billing`)
        .digest("hex")
        .slice(0, 32), // Inngest event dedup ID — stable across parent re-runs
      data: {
        customerId: c.id,
        amountCents: c.amountCents,
        billingPeriod,
        vaultKey: c.vaultKey, // per-customer vault key: $c.amountCents/day cap
      },
    })));

    return { fanned_out: customers.length };
  }
);

The id field on each Inngest event is the deduplication key. Inngest will discard any event with a duplicate id within a 24-hour window, so even if the parent function is triggered twice, only one child billing run fires per customer per billing period. The per-customer vaultKey ensures each child run is scoped to exactly that customer's expected charge amount — a runaway child cannot exhaust billing capacity for other customers in the batch.

Failure mode 3: Module-level Stripe key shared across concurrent fan-out instances

When Inngest fan-out billing functions run in parallel, they all execute in separate Node.js invocations — but the environment variable pattern for Stripe keys creates a subtle key-sharing problem if the key is not passed through the event payload. If each child function reads its Stripe key from process.env.STRIPE_SECRET_KEY, all concurrent billing runs use the same key. This has two consequences. First, a runaway billing run that makes many Stripe calls exhausts the account's rate limits for all concurrent runs, causing them to fail and retry — creating more load, not less. Second, there is no per-run spend cap: if a bug causes a billing loop that calls stripe.charges.create() in a tight retry cycle, there is nothing between it and the Stripe account's total balance. The damage is bounded only by how many retries Inngest allows before giving up — and 4 retries × 200 customers is 800 Stripe charges.

// child-billing.ts — UNSAFE: all parallel runs share one process.env key
export const chargeCustomer = inngest.createFunction(
  { id: "charge-customer" },
  { event: "billing/customer.charge" },
  async ({ event, step }) => {
    const chargeId = await step.run("charge-stripe", async () => {
      // All 200 concurrent instances of this function read the same env var.
      // One runaway loop exhausts rate limits for everyone.
      // No per-run spend cap. If billingPeriod is wrong, all 200 charge incorrectly.
      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

      const charge = await stripe.charges.create({
        amount: event.data.amountCents,
        currency: "usd",
        customer: event.data.customerId,
        description: `Subscription ${event.data.billingPeriod}`,
      });
      return charge.id;
    });
    return { chargeId };
  }
);

The fix is to issue a per-customer vault key through Keybrake at orchestrator time and pass it in the event payload. Each child function uses only its own vault key. A runaway child is rate-limited and cap-limited by its own key — it cannot exhaust capacity for other customers.

// child-billing.ts — SAFE: per-customer vault key + content-hash idempotency key
import Stripe from "stripe";
import { createHash } from "crypto";
import { inngest } from "./inngest-client";

export const chargeCustomer = inngest.createFunction(
  { id: "charge-customer" },
  { event: "billing/customer.charge" },
  async ({ event, step }) => {
    const { customerId, amountCents, billingPeriod, vaultKey } = event.data;

    // Pre-flight: check for existing charge before billing vault key fires
    const existingId = await step.run("check-existing-charge", async () => {
      // audit_vault_key: GET /v1/charges only, $0/day cap — cannot create charges
      const auditStripe = new Stripe(event.data.auditVaultKey, {
        host: "proxy.keybrake.com",
        protocol: "https",
        port: 443,
        basePath: "/stripe",
      });
      const charges = await auditStripe.charges.list({ customer: customerId, limit: 10 });
      const found = charges.data.find(
        (ch) => ch.metadata?.billing_period === billingPeriod && ch.status === "succeeded"
      );
      return found?.id ?? null;
    });

    if (existingId) {
      return { chargeId: existingId, skipped: true };
    }

    const chargeId = await step.run("charge-stripe", async () => {
      // vaultKey: POST /v1/charges only, $amountCents+10% cap, expires 24h
      const stripe = new Stripe(vaultKey, {
        host: "proxy.keybrake.com",
        protocol: "https",
        port: 443,
        basePath: "/stripe",
      });

      const idempKey = createHash("sha256")
        .update(`${customerId}:${amountCents}:${billingPeriod}:inngest-billing`)
        .digest("hex")
        .slice(0, 32);

      const charge = await stripe.charges.create(
        {
          amount: amountCents,
          currency: "usd",
          customer: customerId,
          description: `Subscription ${billingPeriod}`,
          metadata: { billing_period: billingPeriod },
        },
        { idempotencyKey: idempKey }
      );
      return charge.id;
    });

    return { chargeId };
  }
);

The two-step pattern — a read-only pre-flight step followed by a write-only billing step — provides defense in depth. The pre-flight catches charges that Stripe's idempotency window has already expired for (24-hour window; monthly billing cycles last 30 days). The audit vault key is configured with allowed_endpoints: ["GET /v1/charges"] and a zero dollar cap, so even if the pre-flight step itself retries, it can never create a charge. The billing vault key has allowed_endpoints: ["POST /v1/charges"] and a per-customer daily cap matching the expected maximum charge amount. Neither key has access to refunds, subscriptions, payment methods, or customers — they are narrow enough that a prompt injection or logic error has a bounded blast radius.

Approach comparison

Approach Retry safe? Fan-out dedup? Per-run cap? Endpoint scope? Key isolation? 24h+ dedup?
Bare key, no idem key No No No No No No
UUID idempotency key (per-run) No — new UUID on each retry No No No No No
Content-hash idem key only Yes (within 24h) Partial (idem key per customer) No No No No
Inngest event ID dedup Yes (via memoization) Yes No No No No
Restricted Stripe key (no proxy) No No No Partial No No
Content-hash idem + Inngest event ID + vault key via Keybrake proxy Yes Yes Yes Yes Yes (per customer) Yes (pre-flight)

Vitest enforcement suite

// billing.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHash } from "crypto";
import Stripe from "stripe";

// The idempotency key function to test
function billingIdempotencyKey(customerId: string, amountCents: number, billingPeriod: string): string {
  const raw = `${customerId}:${amountCents}:${billingPeriod}:inngest-billing`;
  return createHash("sha256").update(raw).digest("hex").slice(0, 32);
}

describe("Inngest billing governance", () => {
  it("idempotency key is identical across retries with same inputs", () => {
    const key1 = billingIdempotencyKey("cus_abc", 4999, "2026-06");
    const key2 = billingIdempotencyKey("cus_abc", 4999, "2026-06");
    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-06");
    const keyB = billingIdempotencyKey("cus_xyz", 4999, "2026-06");
    expect(keyA).not.toBe(keyB);
  });

  it("fan-out event ID is stable across parent function re-runs", () => {
    // Simulate two parent function runs with the same billing period
    const eventIdRun1 = createHash("sha256")
      .update("cus_abc:2026-06:inngest-billing")
      .digest("hex")
      .slice(0, 32);
    const eventIdRun2 = createHash("sha256")
      .update("cus_abc:2026-06:inngest-billing")
      .digest("hex")
      .slice(0, 32);
    expect(eventIdRun1).toBe(eventIdRun2);
  });

  it("vault key is passed via event payload, not process.env", async () => {
    // Mock Stripe constructor to capture which key is used
    const mockCreate = vi.fn().mockResolvedValue({ id: "ch_test", status: "succeeded" });
    const MockStripe = vi.fn().mockImplementation((apiKey: string) => {
      expect(apiKey).toBe("kb_test_vault_key"); // must be the per-customer vault key
      expect(apiKey).not.toBe(process.env.STRIPE_SECRET_KEY); // not the env var
      return { charges: { create: mockCreate } };
    });

    // Simulate the step.run callback with vault key from event payload
    const vaultKey = "kb_test_vault_key";
    const stripe = new MockStripe(vaultKey) as unknown as Stripe;
    await stripe.charges.create({ amount: 4999, currency: "usd", customer: "cus_abc", description: "test" });
    expect(mockCreate).toHaveBeenCalledOnce();
  });
});

Gap analysis

1. step.run() memoization does not protect against errors thrown by Stripe itself

If Stripe returns a 500 error (rare but possible), stripe.charges.create() throws before creating a charge. Inngest retries the step. This is safe — no charge was created. However, if Stripe returns a 402 (card declined) and your code throws to signal the decline, Inngest retries, and the second attempt may succeed if the customer updated their card in the meantime. Ensure that StripeCardError is caught and returned as structured data — not re-thrown — to prevent Inngest from retrying a declined charge as if it were a transient failure.

2. Inngest Concurrency primitive does not prevent duplicate events from different parent runs

Inngest's concurrency feature allows you to limit how many function runs execute simultaneously for a given key (e.g., one billing run per customer at a time). This prevents the parallel-execution race condition within a single batch. It does not prevent two separate parent runs — triggered by webhook double-delivery — from enqueuing two events with the same customer ID. The event id deduplication fix above is necessary in addition to concurrency limits.

3. step.waitForEvent() timeout triggers a separate retry path

Some billing workflows use step.waitForEvent() to wait for a customer approval or external signal before charging. If the wait times out (the timeout parameter), Inngest throws a TimeoutError inside the function. If your billing step is after the waitForEvent() call and you handle the timeout by re-sending the billing event, you can inadvertently trigger two billing runs: the original (which timed out but did not fail) and the retry. Always check whether the original function run is still active before re-triggering billing on a timeout.

4. Inngest Dev Server does not enforce vault key policies

The Inngest Dev Server (npx inngest-cli@latest dev) runs functions locally against real Stripe and real environment variables. If you test billing functions locally against a test Stripe key, the vault key pattern still works — but the proxy does not run locally by default. For local testing, use Stripe's own idempotency key deduplication to verify retry safety, and separately test the vault key policy enforcement against a staging Keybrake proxy that mirrors production policy rules. Do not use live Stripe keys during local Inngest dev server sessions.

FAQ

Does Inngest's step memoization make idempotency keys unnecessary?

No. Memoization prevents a successfully-completed step from re-running on outer function replay. But if the step callback itself throws — even after the Stripe call returns — Inngest marks the step as failed and retries the entire callback. The idempotency key is what makes the Stripe call inside that callback safe to retry. Memoization and idempotency keys are complementary, not alternatives.

Why use a content hash instead of the Inngest run ID as the idempotency key?

The Inngest run ID is different for each function run. If a parent function is triggered twice (webhook double-delivery), the two runs have different IDs, and so would their idempotency keys — defeating deduplication. The content hash is derived from the stable billing parameters (customer, amount, billing period), so it is identical regardless of which run fires it. Use the run ID only if you intentionally want each triggered run to create a new charge regardless of prior charges in the same billing period.

What happens if two customers have identical amountCents and billingPeriod?

The idempotency key includes the customerId, so two different customers with identical amount and billing period will have different keys. Stripe deduplication is per-key — there is no risk of deduplicating charges across customers. The content hash of (customerId:amountCents:billingPeriod:inngest-billing) is unique per customer per billing cycle.

How does the vault key daily cap work when the same customer is charged multiple times per day?

The Keybrake vault key cap is a daily USD total across all charges made with that key. If a customer's vault key has a $50/day cap and they are legitimately charged $29.99 for a subscription and $15 for overage in the same day, the total is $44.99 — within cap. If a bug causes a third charge attempt, it is rejected at the proxy layer with a 402 before reaching Stripe. Size the cap to the maximum expected daily charge total per customer, not the single charge amount.

Can I use the Inngest event ID to replace the Stripe idempotency key?

They serve different layers. The Inngest event id deduplicates at the event ingestion layer — it prevents two identical events from triggering two function runs. The Stripe idempotency key deduplicates at the Stripe API layer — it prevents two identical charge requests from creating two charges. You need both: event dedup prevents extra function runs, and Stripe idem keys protect against mid-callback failures within any single run. Removing either layer leaves a gap.

What is the right scope for the audit vault key vs the billing vault key?

The audit vault key should have allowed_endpoints: ["GET /v1/charges"] and a zero dollar cap. It is used for the pre-flight check step only. The billing vault key should have allowed_endpoints: ["POST /v1/charges"] and a daily cap equal to the maximum expected charge amount for that customer tier. Neither key should have access to /v1/refunds, /v1/subscriptions, /v1/payment_intents, or /v1/customers. If your billing function also needs to update customer metadata, issue a separate customer-update vault key scoped to POST /v1/customers/{id} with a zero dollar cap.

Keybrake: per-vendor spend caps for AI agents

Issue scoped vault keys for each Inngest function run. Set endpoint allowlists and daily USD caps enforced at the proxy layer — before any request reaches Stripe. One-line URL change in your Stripe client config.