Agent Governance
Flowise Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Flowise's visual low-code builder makes it unusually fast to wire a Stripe billing tool into an agent chatflow — define a Custom Tool with a JavaScript function body that calls stripe.charges.create(), drag it into a ReAct Agent node, and expose the chatflow via its built-in REST API. Three specific failure modes emerge in production Flowise deployments: Custom Tool functions execute in the Flowise server process using a single shared environment variable for the Stripe key, so all concurrent chatflow sessions make billing calls with no per-session key isolation; the Flowise chatflow API has no input-hash deduplication, so a network-level caller retry re-runs the full agent conversation and re-fires the billing tool on an already-billed customer; and in Flowise Agentflow (the multi-agent canvas), a Worker agent that exceeds its max iterations limit without producing a valid Final Answer can be retried by the Orchestrator with the same task — re-calling the billing tool that already completed a Stripe charge on an earlier iteration.
The standard Flowise + Stripe chatflow setup
A typical Flowise billing chatflow has three components on the canvas: an agent node (ReAct Agent or Conversational Agent), a memory node (Buffer Memory, scoped by sessionId), and a Custom Tool node that wraps the Stripe charge call. The Custom Tool is defined with a JSON Schema describing its parameters and a JavaScript function body that Flowise executes server-side when the agent invokes the tool:
// Flowise Custom Tool: charge_stripe
// Function body (runs server-side in Flowise Node.js process)
const stripe = require('stripe')(process.env.STRIPE_KEY); // ← shared server env var
const { customer_id, amount_cents, billing_period } = $input;
try {
const charge = await stripe.charges.create({
amount: amount_cents,
currency: 'usd',
customer: customer_id,
description: `Billing for ${billing_period}`,
});
return JSON.stringify({ status: 'succeeded', charge_id: charge.id });
} catch (error) {
throw new Error(`Stripe error: ${error.message}`);
}
This is idiomatic Flowise: the Custom Tool function body is clean JavaScript, Flowise handles the LangChain tool wrapping under the hood, and the chatflow is ready to demo in minutes. The problems appear when Flowise's server-side execution model, its REST API retry semantics, and its multi-agent canvas interact with a Stripe call that must be idempotent.
Failure mode 1: Custom Tool runs server-side — one Stripe key shared across all concurrent sessions
In Flowise, Custom Tool function bodies run inside the Flowise server process using Node.js's vm module or a dynamic Function() constructor. The tool's code does not run in a user-isolated sandbox — it runs in the same Node.js process that serves all Flowise requests. Environment variables (process.env) are server-wide. There is no API to inject a per-session or per-chatflow key into the Custom Tool function at runtime; the function receives only the structured tool parameters defined in its JSON Schema.
What goes wrong: your company has two active billing chatflows — one for subscription renewals, one for usage-based invoicing. Both use a "charge_stripe" Custom Tool that reads process.env.STRIPE_KEY. At 09:00 UTC, the monthly renewal batch triggers 300 chatflow sessions simultaneously. At the same time, a customer support chatflow also reaches the charge tool for a manual billing correction. All 301 concurrent tool executions use the same Stripe key — the same daily rate limit, the same audit trail in the Stripe dashboard, and the same sk_live_ secret exposed in every execution context. If that key is compromised through any one of those tool invocations, every billing workflow on the platform is exposed. If that key is rotated, all three chatflows break simultaneously until the new key is deployed to the Flowise server environment.
The deeper problem is that there is no per-session or per-customer isolation at the Stripe API level. Flowise does not provide a built-in mechanism to pass a per-session credential into a Custom Tool function. The $input object contains only the parameters defined in the tool's JSON Schema — you can add a vault_key field to the schema and have the LLM pass it, but that requires the key to be present in the agent's context (defeating the purpose of isolation) or injected via the chatflow's system prompt (readable in logs).
// The isolation gap: all concurrent sessions call this function
// with the same process.env.STRIPE_KEY
//
// Session A (customer cus_001): stripe.charges.create({ customer: 'cus_001', ... })
// ↑ uses process.env.STRIPE_KEY
// Session B (customer cus_002): stripe.charges.create({ customer: 'cus_002', ... })
// ↑ same key, same daily cap, same Stripe audit entry
// Session C (support agent): stripe.charges.create({ customer: 'cus_003', ... })
// ↑ still the same key
//
// A vault key from the proxy breaks this: each session gets a key
// scoped to its customer, with its own daily cap, its own endpoint allowlist.
The fix is to pass the Stripe call through a proxy that issues per-session vault keys. The Custom Tool function replaces process.env.STRIPE_KEY with a vault key fetched from the proxy at session start — either passed into the tool via a session variable in the system prompt, or fetched inside the tool function using a read-only API call to the proxy's key-issue endpoint. Each vault key has its own daily USD cap, its own endpoint allowlist, and its own audit log row — session A's runaway retry loop cannot exhaust session B's billing budget.
Failure mode 2: Flowise chatflow API caller retry re-runs the full agent conversation
Each Flowise chatflow is accessible via a REST API endpoint: POST /api/v1/prediction/{chatflowId}. The caller sends a question or instruction, Flowise runs the full agent loop (LLM calls, tool invocations, observations, final answer), and returns the final response. From the caller's perspective, this is a single synchronous HTTP request — but internally, the agent loop can take 10–60 seconds for billing workflows that involve multiple LLM turns and Stripe API calls.
Flowise does not deduplicate incoming API requests by input content. If two identical POST requests arrive for the same chatflow — whether from a caller retry after a network timeout, a load balancer health check that mis-fires, or a double-submit from a client-side retry library — Flowise starts two independent agent conversations with the same input. Both agents complete their tool call sequence, including the billing tool call.
What goes wrong: an upstream orchestration system (n8n, Make.com, a custom billing service) calls the Flowise chatflow API with the instruction "charge customer cus_A100 $49 for the June subscription." The Flowise agent runs: it calls charge_stripe, Stripe creates ch_xyz, the agent produces a Final Answer "Charged $49.00 successfully." But the HTTP response takes 45 seconds to arrive — just past the upstream system's 30-second timeout. The upstream system marks the call as failed and retries. Flowise receives a second identical POST request. A second agent conversation starts. The same charge_stripe tool is called. Stripe creates ch_abc. Customer cus_A100 is billed $49 twice. Both Flowise executions show "succeeded" in the chat history. The upstream system receives a successful response on the retry and logs one billing event. The double charge is invisible to the caller.
This failure mode is particularly insidious because it produces no error on either side. Flowise does not know the caller retried; the caller does not know the first call completed. The only place where the duplicate is detectable is the Stripe dashboard (two charges for the same customer in the same minute) and the proxy audit log (two tool calls with the same customer and amount in rapid succession).
// Flowise chatflow API — what two concurrent identical requests look like:
//
// Request 1 (original): POST /api/v1/prediction/chatflow-abc123
// { "question": "charge cus_A100 $49 for 2026-06" }
// → agent loop starts, calls charge_stripe
// → charge: ch_xyz (created, $49)
// → response: { "text": "Charged $49.00 successfully." }
// [TIMEOUT: response takes 45s, caller gives up at 30s]
//
// Request 2 (retry): POST /api/v1/prediction/chatflow-abc123
// { "question": "charge cus_A100 $49 for 2026-06" } ← identical
// → NEW agent loop starts, calls charge_stripe AGAIN
// → charge: ch_abc (created, $49) ← duplicate
// → response: { "text": "Charged $49.00 successfully." }
//
// No error raised. No deduplication. Two Stripe charges for one billing event.
A content-hash idempotency key derived from (customer_id, amount_cents, billing_period) closes this at the Stripe API layer. Regardless of how many times Flowise runs the agent conversation with the same billing inputs, Stripe returns the original charge object for any charges.create() call that uses the same idempotency key — whether that call comes from request 1's agent loop or request 2's.
Failure mode 3: Agentflow Worker node re-fires billing on max iterations exceeded
Flowise v2 introduced Agentflow — a visual multi-agent canvas where an Orchestrator agent coordinates Worker agents. Each Worker agent is configured with a system prompt, a tool set, and a "Max Iterations" limit. The Orchestrator sends tasks to Workers and collects their Final Answers to compose the overall response. This is Flowise's analog to AutoGen's GroupChat or CrewAI's crew — an Orchestrator-Worker topology with configurable iteration bounds.
When a Worker agent exhausts its max iterations without producing a valid Final Answer, Flowise raises an iteration-exceeded error internally. The Orchestrator receives this as a Worker failure. Depending on how the Agentflow is configured, the Orchestrator may retry the Worker with the same task — interpreting the iteration failure as a transient issue rather than a permanent one.
What goes wrong: the Orchestrator delegates "process June billing for cus_A100" to the Billing Worker. The Billing Worker calls charge_stripe on iteration 1 — Stripe charge ch_xyz is created. The Worker then calls a downstream confirmation tool (a database write) on iteration 2. The database tool returns an ambiguous response that the Worker cannot parse as a success or failure. The Worker spends iterations 3–5 trying to interpret the database response or re-call the confirmation tool. On iteration 6 (the configured max), Flowise stops the Worker and reports iteration-exceeded to the Orchestrator. The Orchestrator retries the Worker with the same task. The Worker starts from iteration 1 again — it calls charge_stripe with no memory that it already completed that call. Stripe creates ch_abc. Customer cus_A100 is billed twice.
The failure is structural to the Orchestrator-Worker model: the Worker has no persistent state between retry attempts (Flowise Workers in Agentflow start fresh on each invocation from the Orchestrator). The Worker cannot check its own prior actions — it sees only the task string passed from the Orchestrator. If that task string contains billing instructions, the Worker will attempt to fulfill them regardless of whether a prior invocation already did.
// Flowise Agentflow — Orchestrator task to Billing Worker (both invocations):
//
// Invocation 1: "charge cus_A100 $49.00 for billing_period=2026-06 and confirm in DB"
// Worker iteration 1: calls charge_stripe → ch_xyz created ✓
// Worker iteration 2: calls db_confirm → ambiguous response
// Worker iterations 3-5: tries to handle db_confirm ambiguity
// Worker iteration 6: max iterations exceeded — no Final Answer
// Orchestrator: Worker failed, retrying...
//
// Invocation 2: "charge cus_A100 $49.00 for billing_period=2026-06 and confirm in DB"
// Worker iteration 1: calls charge_stripe → ch_abc created ✗ (duplicate!)
// [no memory of ch_xyz from invocation 1]
//
// Idempotency key: makeKey('cus_A100', 4900, '2026-06') → same key both invocations
// → Stripe returns ch_xyz for both calls. One charge, regardless of retry count.
The two-layer fix
The pattern that closes all three failure modes combines a content-hash idempotency key inside the Custom Tool function with a per-session vault key issued by a spend-cap proxy. The idempotency key collapses duplicate Stripe calls regardless of whether they come from concurrent API retries, Agentflow Worker re-runs, or any other execution path. The vault key caps how much a single session can charge before the proxy blocks further calls.
Layer 1: content-hash idempotency key in the Custom Tool function
Derive the idempotency key from the billing operation's content — not from a random UUID, not from a Flowise session ID, not from a tool call UUID. The same (customer_id, amount_cents, billing_period) triple must always produce the same key, so that any re-run of the same billing instruction results in Stripe returning the original charge rather than creating a new one:
// Flowise Custom Tool: charge_stripe — with content-hash idempotency key
const crypto = require('crypto');
const { customer_id, amount_cents, billing_period, vault_key } = $input;
function makeIdempotencyKey(customerId, amountCents, billingPeriod) {
const payload = `${customerId}:${amountCents}:${billingPeriod}:flowise-billing`;
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 40);
}
// Use vault_key if provided (per-session isolation), else fall back to shared env key
const stripeKey = vault_key || process.env.STRIPE_KEY;
const stripe = require('stripe')(stripeKey);
const idempKey = makeIdempotencyKey(customer_id, amount_cents, billing_period);
try {
const charge = await stripe.charges.create(
{ amount: amount_cents, currency: 'usd', customer: customer_id,
description: `Billing for ${billing_period}` },
{ idempotencyKey: idempKey }
);
return JSON.stringify({ status: 'succeeded', charge_id: charge.id });
} catch (error) {
// Return error as JSON string — do NOT throw.
// Throwing causes the Flowise agent to treat the tool call as a failure
// and attempt to retry it. Returning a structured error string lets the
// agent surface it as an observation and decide whether to stop.
return JSON.stringify({ status: 'error', message: error.message });
}
The return-not-throw pattern matters for Agentflow Worker re-runs: if the Custom Tool throws, the Worker's ReAct loop sees a tool error in the Observation and may retry the tool call on the next iteration — without an idempotency key, that retry charges again. Returning a structured error JSON object as a string means the Observation is a well-formed tool result. The LLM reads the error and can stop gracefully rather than retry the billing tool.
Layer 2: per-session vault keys via Keybrake proxy
To break the shared-key problem from failure mode 1, issue a vault key per chatflow session from the proxy. The vault key carries a daily USD cap scoped to that session's expected billing amount — a runaway Agentflow Worker that retries indefinitely against the proxy will exhaust the per-session cap and receive 429 Daily cap exceeded after the first successful charge, not an unlimited number of duplicate charges:
// Flowise Custom Tool: charge_stripe — vault key + proxy routing
const crypto = require('crypto');
const Stripe = require('stripe');
const { customer_id, amount_cents, billing_period, vault_key } = $input;
// vault_key is passed from the chatflow system prompt or a session variable node.
// The proxy issues vault_key at session start; each session gets its own key
// with its own daily USD cap and endpoint allowlist (POST /v1/charges only).
const stripe = new Stripe(vault_key, {
host: 'proxy.keybrake.com',
protocol: 'https',
basePath: '/stripe',
});
function makeIdempotencyKey(customerId, amountCents, billingPeriod) {
const payload = `${customerId}:${amountCents}:${billingPeriod}:flowise-billing`;
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 40);
}
const idempKey = makeIdempotencyKey(customer_id, amount_cents, billing_period);
try {
const charge = await stripe.charges.create(
{ amount: amount_cents, currency: 'usd', customer: customer_id },
{ idempotencyKey: idempKey }
);
return JSON.stringify({ status: 'succeeded', charge_id: charge.id });
} catch (error) {
return JSON.stringify({ status: 'error', message: error.message });
}
// The proxy enforces per vault_key:
// - Endpoint allowlist: billing vault key → POST /v1/charges only
// - Daily USD cap: set to expected max single-session charge amount
// - Audit log: every call recorded with customer_id, amount, key, timestamp
// - Agentflow Worker re-run dedup: idempotency key collapses re-runs to one charge
For the session-replay protection (memory node replaying prior billing context), add a check_existing_charge Custom Tool that uses a read-only audit vault key (GET /v1/charges only). The Agentflow Orchestrator's system prompt can instruct it to always check for an existing charge before delegating to the Billing Worker — catching the memory-replay case before any billing sub-agent ever starts.
Comparison: raw key vs restricted key vs vault key
| Property | Raw key (sk_live_) |
Restricted key | Vault key (proxy) |
|---|---|---|---|
| Endpoint allowlist | All Stripe endpoints | Selected resource types | Exact method+path (POST /v1/charges) |
| Daily USD cap | None | None | Per-key cap enforced at proxy |
| Per-session isolation | One server env var — all concurrent sessions share one key | Same shared problem | New vault key per chatflow session; one runaway session cannot drain others |
| API caller retry guard | No guard — second POST re-runs the agent and re-fires Stripe | No guard | Content-hash idempotency key: Stripe returns existing charge for duplicate calls |
| Agentflow Worker re-run guard | No guard — Worker re-runs charge_stripe from scratch on retry | No guard | Content-hash idem key collapses Worker re-runs to one charge regardless of retry count |
| Memory replay guard | No guard — agent sees prior billing in memory and may re-charge | No guard | Audit vault key powers check_existing_charge; idem key collapses replay calls |
| Audit log | Stripe dashboard only | Stripe dashboard only | Per-request structured log at proxy (customer_id, key, amount, session, timestamp) |
Enforcement tests
// Jest tests for the Flowise Custom Tool helper functions
const crypto = require('crypto');
function makeIdempotencyKey(customerId, amountCents, billingPeriod) {
const payload = `${customerId}:${amountCents}:${billingPeriod}:flowise-billing`;
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 40);
}
test('idempotency key is deterministic across runs', () => {
const k1 = makeIdempotencyKey('cus_A100', 4900, '2026-06');
const k2 = makeIdempotencyKey('cus_A100', 4900, '2026-06');
expect(k1).toBe(k2);
// API caller retry + Agentflow Worker re-run both produce the same key
});
test('different billing periods produce different keys', () => {
const k1 = makeIdempotencyKey('cus_A100', 4900, '2026-05');
const k2 = makeIdempotencyKey('cus_A100', 4900, '2026-06');
expect(k1).not.toBe(k2);
// Monthly rebilling for the same customer uses a fresh key each period
});
test('concurrent chatflow sessions for same customer use the same key', () => {
// Simulates two concurrent API calls with identical billing inputs
const session1 = makeIdempotencyKey('cus_A100', 4900, '2026-06');
const session2 = makeIdempotencyKey('cus_A100', 4900, '2026-06');
expect(session1).toBe(session2);
// Stripe deduplicates: returns ch_xyz for both, no duplicate charge
});
test('charge_stripe returns error JSON, not thrown exception', async () => {
const mockStripe = {
charges: {
create: jest.fn().mockRejectedValue(new Error('Connection timeout'))
}
};
let result;
try {
await mockStripe.charges.create({ amount: 4900, currency: 'usd', customer: 'cus_A100' });
} catch (error) {
result = JSON.stringify({ status: 'error', message: error.message });
}
const parsed = JSON.parse(result);
expect(parsed.status).toBe('error');
expect(parsed.message).toContain('timeout');
// No throw: Flowise agent receives structured tool output, does not retry the tool
});
test('different customers produce different idempotency keys', () => {
const k1 = makeIdempotencyKey('cus_A100', 4900, '2026-06');
const k2 = makeIdempotencyKey('cus_B200', 4900, '2026-06');
expect(k1).not.toBe(k2);
// Prevents cross-customer dedup: cus_A100's key cannot match cus_B200's
});
Gap analysis
1. Flowise Custom Tool execution environment across versions
Flowise has changed how Custom Tool function bodies execute across versions. Early Flowise versions ran tool functions directly via Node.js eval() or new Function() with minimal sandboxing. Later versions introduced a more controlled execution context. The level of access to process.env, require(), and Node.js built-ins varies by Flowise version and deployment configuration. Before deploying a billing Custom Tool, verify that your Flowise version supports require('stripe') and require('crypto') in the tool function body. Some self-hosted Flowise deployments sandbox tool functions with restricted module access. If require() is not available, use Flowise's HTTP Tool node instead — which makes an HTTP POST to an external endpoint you control, where the idempotency key logic lives.
2. Flowise HTTP Tool node as an alternative to Custom Tool
For production billing workflows, the Flowise HTTP Tool node (which calls an external HTTP endpoint) is often safer than a Custom Tool function body. The HTTP endpoint you define — a simple Express or Fastify function on your infrastructure — runs in your own process with full control over credentials, idempotency key logic, and error handling. The Flowise server process never touches your Stripe key. The vault key is a request header on the HTTP call; the endpoint validates it against the proxy before forwarding to Stripe. This architecture cleanly separates Flowise's agent loop from your billing logic and is easier to test, version, and monitor independently.
3. Flowise chatflow versioning and execution replay
Flowise stores chatflow execution history including the full conversation thread (inputs, tool call arguments, and tool results). In some versions, you can re-run a past execution from the Flowise UI or API for debugging purposes. A re-run with the same inputs re-executes the full agent loop — including the Custom Tool call with the same customer ID, amount, and billing period. A content-hash idempotency key protects against this: the re-run produces the same key, and Stripe returns the original charge. This protection is unconditional — it applies whether the re-run is triggered by a developer debugging the chatflow, an automated test, or an accidental UI double-click.
4. Flowise session memory node scoped to customer ID
Flowise's Buffer Memory node uses a sessionId to scope conversation history. The sessionId is typically passed by the API caller. In recurring billing workflows, teams commonly use the customer ID as the sessionId to give the billing agent continuity — it can reference prior payment history within a session. This creates the same memory-replay risk as n8n's window buffer memory: when the same customer's billing chatflow is triggered next month with the same sessionId, the agent loads prior month's billing tool call result into its context. Mitigation: use a composite sessionId of ${customerId}:${billingPeriod} so each billing period gets its own isolated memory context, with no prior period's tool calls visible.
FAQ
Can I pass a per-session vault key into a Flowise Custom Tool without embedding it in the system prompt?
Yes, via Flowise's "Variables" feature. Flowise chatflows support runtime variables that can be injected into node configurations at API call time (passed in the overrideConfig field of the prediction API request). You can define a vault_key variable in the chatflow configuration and reference it in the Custom Tool's function body as $vars.vault_key. The vault key travels through Flowise's internal variable context, not through the LLM's visible system prompt or chat history — it is not included in the agent's context window. This is the recommended pattern for injecting per-session credentials without leaking them to the LLM.
Does Flowise's built-in session ID provide enough isolation between chatflow calls?
Flowise's session ID scopes conversation memory — it does not scope API credentials, execution context, or Stripe key isolation. Two concurrent chatflow sessions with different session IDs still share the same process.env.STRIPE_KEY in their Custom Tool executions. Session IDs and vault keys solve different problems: session IDs prevent memory context from bleeding between users; vault keys prevent billing capacity from bleeding between users. Both are needed for a production billing chatflow.
How do I handle a billing period that legitimately has two charges?
Add a charge_type disambiguator to the idempotency key: ${customerId}:${amountCents}:${billingPeriod}:${chargeType}:flowise-billing where chargeType is "subscription", "overage", or "setup-fee". Include charge_type as a required parameter in the Custom Tool's JSON Schema so the LLM must explicitly specify it. This keeps the idempotency key stable across retries while allowing multiple distinct charges per billing period for the same customer.
What happens when the vault key daily cap is exhausted mid-session?
The proxy returns 429 Daily cap exceeded. The Custom Tool function catches this, returns { "status": "error", "message": "daily cap exceeded" } as JSON. The Flowise agent receives this as an Observation and should stop rather than retry. Add a system prompt instruction: "If you receive a 'daily cap exceeded' error from charge_stripe, stop immediately and report the error to the caller. Do not retry the charge." The daily cap is per vault key — other customers' sessions have their own vault keys and their own caps. Cap exhaustion on one session does not affect others.
Does the Agentflow Worker receive any context about whether prior invocations ran?
By default, no — Flowise Agentflow Workers are stateless between Orchestrator invocations. Each Worker invocation starts from a fresh conversation with only the task string passed by the Orchestrator. This is by design (Workers are meant to be composable and isolated), but it means the Worker has no built-in awareness of its own retry count or prior completion state. The idempotency key pattern is the correct solution: it externalizes the "did this already complete?" check to Stripe's own idempotency engine, which is authoritative regardless of how many Worker invocations have run.
Is this post relevant for Flowise v1 as well as v2 Agentflow?
Failure modes 1 (shared server-side key) and 2 (API caller retry) apply to both Flowise v1 and v2 chatflows — both expose the same Custom Tool execution model and the same POST /api/v1/prediction/{chatflowId} REST API. Failure mode 3 (Agentflow Worker re-run) is specific to Flowise v2's Agentflow canvas. If you are on Flowise v1 with a single-agent chatflow, focus on fixing failure modes 1 and 2 first. If you are building multi-agent Agentflow workflows on v2, all three failure modes apply and all three fixes are needed.
Per-session vault keys for every Flowise billing chatflow
Keybrake issues per-session vault keys with endpoint allowlists and daily USD caps — so API caller retries, Agentflow Worker re-runs, and shared-key billing all collapse to a single Stripe charge per customer per billing period. One-line proxy switch in your Custom Tool function.