Windmill · AI agents · API key security

Windmill AI agent API key: scoping vendor calls in developer workflow scripts

Windmill is a developer platform for building internal tools, automation scripts, and multi-step flows — TypeScript or Python scripts composed into flows via a visual editor or YAML, with a self-hostable worker architecture. AI agent teams adopt Windmill for its clean developer experience: write a Python or TypeScript function, wire it into a flow, and schedule or trigger it on events. When those scripts call vendor APIs like Stripe or Twilio, Windmill's resource system provides credentials to each worker — but provides no per-flow dollar cap, no mid-run revocation without rotating the resource, and no per-call audit log that correlates vendor transaction IDs with flow run context. This page covers the vault-key pattern that scopes vendor spend per Windmill flow execution.

TL;DR

Add a setup script as the first step in your Windmill flow. That script calls the Keybrake API to issue a vault key with a per-flow dollar cap, and returns the vault key as its output. Downstream steps receive the vault key as an input bound to the setup step's output — Windmill's input binding UI makes this a drag-and-drop connection. Each flow run gets its own vault key. The real Stripe or Twilio secret lives only in Keybrake, not in your Windmill resource variables.

How Windmill AI agent scripts call vendor APIs

Windmill scripts are functions with typed inputs and outputs. A billing agent flow might look like this — a for-each step that runs a charge script for each customer ID in a list:

// charge_customer.ts — Windmill TypeScript script
import Stripe from "npm:stripe";

type Params = {
  customer_id: string;
  amount_cents: number;
  stripe_resource: {  // Windmill resource variable — full-access key
    secret_key: string;
  };
};

export async function main({ customer_id, amount_cents, stripe_resource }: Params) {
  const stripe = new Stripe(stripe_resource.secret_key);  // full-access key
  const intent = await stripe.paymentIntents.create({
    amount: amount_cents,
    currency: "usd",
    customer: customer_id,
  });
  return { payment_intent_id: intent.id, status: intent.status };
}

In the Windmill flow editor, the stripe_resource input is connected to a Windmill resource variable (the equivalent of a secrets store entry). When the flow runs a for-each step over this script, each parallel execution reads the same Windmill resource — the full-access Stripe key — and makes an independent call to Stripe. There is no mechanism in the Windmill flow to cap total vendor spend across the for-each iterations.

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

GapWhat happens in practiceWindmill's answer
No per-flow spend cap A webhook trigger passes a customer ID list from an upstream data API. The upstream API returns duplicates due to a pagination bug — 500 IDs instead of 50. Windmill's for-each step spawns 500 parallel workers, each calling Stripe with the same full-access key. The Stripe dashboard shows 500 charges instead of 50. Windmill's flow metrics show execution count and timing, but no dollar amount spent on vendor calls. The cap in Windmill's concurrency settings limits simultaneous worker count, not cumulative spend. None. Windmill's resource system stores and injects credentials but doesn't track how many times they're used or how much they spend.
No mid-run revocation without resource update To stop a runaway Windmill flow mid-run, you can cancel the flow from the UI — but worker scripts currently executing their main() function will complete before the cancellation takes effect. Rotating the Windmill resource variable to an invalid key stops new worker spawns, but breaks every other Windmill flow and scheduled script in the workspace that reads the same Stripe resource. Windmill's workspace-level resource model means one credential is shared across all flows unless you create resource-per-flow — which scales poorly. Flow cancellation stops queuing new steps. Windmill doesn't support per-execution resource scoping or mid-execution key revocation at the worker level.
No per-call audit with flow run context Windmill's run history captures script inputs, outputs, and logs per step. It doesn't parse dollar amounts from Stripe responses, correlate Stripe PaymentIntent.id values with Windmill job_id and flow_run_id in a queryable table, or provide a cost summary per flow trigger. Debugging an overcharge requires cross-referencing Windmill run logs with Stripe's event log manually. Windmill's audit log records who triggered what and when. No structured vendor cost tracking or external transaction ID correlation.

The for-each risk: parallel worker spawning and simultaneous vendor calls

Windmill's for-each flow step is the primary fan-out mechanism for list-based agent workloads. Each item in the input array spawns an independent worker job. Worker concurrency is bounded by the workspace's worker pool capacity (configurable in self-hosted deployments or by plan in cloud), but not by vendor spend. On Windmill cloud plans with adequate worker capacity, 200 for-each items can all run simultaneously.

Windmill's concurrency limit setting controls how many iterations run at once, but it's a throughput control, not a spend cap. Setting concurrency to 5 means the 200 charges happen over 40 batches instead of 1 batch — the total spend is identical, it just takes longer. A vault key with a dollar cap fires at the total spend limit regardless of concurrency setting.

Scoping vault keys per Windmill flow run

Add an issue_vault_key script as step 1 in the flow. Bind its output to the downstream charge scripts using Windmill's input binding:

// issue_vault_key.ts — Step 1 in the flow
type Params = {
  keybrake_api_key: string;  // Windmill resource: keybrake credentials
  budget_usd: number;
};

export async function main({ keybrake_api_key, budget_usd }: Params) {
  const res = await fetch("https://proxy.keybrake.com/vault/keys", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${keybrake_api_key}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      vendor: "stripe",
      daily_usd_cap: budget_usd,
      allowed_endpoints: ["POST /v1/payment_intents"],
      expires_in: "2h",
      agent_run_label: `windmill/${Deno.env.get("WM_FLOW_JOB_ID") ?? "local"}`,
    }),
  });
  const { vault_key } = await res.json();
  return { vault_key };  // output bound to downstream steps
}
// charge_customer.ts — Step 2 (for-each over customer_ids)
import Stripe from "npm:stripe";

type Params = {
  customer_id: string;
  amount_cents: number;
  vault_key: string;  // bound to step 1's output.vault_key in flow editor
};

export async function main({ customer_id, amount_cents, vault_key }: Params) {
  const stripe = new Stripe(vault_key, {
    apiVersion: "2024-06-20",
  });
  // Override the base URL to route through the proxy
  (stripe as any)._api.basePath = "https://proxy.keybrake.com/stripe";

  const intent = await stripe.paymentIntents.create({
    amount: amount_cents,
    currency: "usd",
    customer: customer_id,
    idempotency_key: `${Deno.env.get("WM_FLOW_JOB_ID")}-${customer_id}`,
  });
  return { payment_intent_id: intent.id };
}

In the Windmill flow editor, connect the vault_key input of the for-each step to results.step1.vault_key. All for-each iterations receive the same vault key — scoped to this flow run's budget. The Windmill resource variable now contains only the Keybrake API key (used once in step 1), not the real Stripe secret. Revoking a runaway flow run is a single DELETE /vault/keys/{key_id} call against the Keybrake API — the vault key expires immediately on the next vendor call from any worker in the flow.

How Keybrake fits

Keybrake is the proxy layer between your Windmill scripts and Stripe, Twilio, or Resend. The vault key replaces the full-access API key that previously lived in a Windmill resource variable. Each flow execution issues its own vault key with its own dollar cap and TTL. When a flow is re-triggered (scheduled re-run, webhook re-delivery), the new execution issues a fresh vault key — so previous runs' caps don't carry over. The real vendor secret stays in Keybrake regardless of how many Windmill workspaces, flows, or script versions use it.

Get early access

Related questions

How do I access the Windmill flow run ID in scripts for audit labeling?

Windmill injects several environment variables into worker processes: WM_JOB_ID (the individual step job ID), WM_FLOW_JOB_ID (the parent flow run ID), and WM_WORKSPACE. In TypeScript scripts, access them via Deno.env.get("WM_FLOW_JOB_ID"). In Python scripts, use os.environ.get("WM_FLOW_JOB_ID"). Use WM_FLOW_JOB_ID as the agent_run_label value when issuing the vault key — this makes every vendor API call in the audit log queryable by Windmill flow run ID.

Does this work with Windmill's approval steps and human-in-the-loop flows?

Yes. Windmill supports approval steps (flows that pause and wait for human confirmation before proceeding). If you issue the vault key before an approval step, the key's TTL needs to be long enough to cover the approval wait time plus the execution time. For flows with variable approval latency, issue the vault key after the approval step instead — in a step immediately before the first vendor-calling step. The vault key is then scoped to the execution window after approval, not the entire flow duration including the wait.

How do I configure Windmill's retry behavior to avoid double charges on cap exhaustion?

Windmill flow steps have a configurable retry setting (max attempts and delay). For steps that make vendor API calls, set retry.max_attempts: 1 (no retries) or implement retry logic manually inside the script with distinction between retryable errors (transient network errors) and non-retryable errors (cap exhaustion 429s). Check for X-Keybrake-Cap-Hit: true in the response headers — if present, throw a custom error that you catch in the flow's error handler without retrying. Windmill's flow error handler can then log the cap-hit event and cancel remaining for-each iterations.

Further reading