Agent Governance

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

Activepieces is the open-source automation platform that engineers reach for when they want the visual flow builder experience of Zapier or Make.com without the vendor lock-in. Its open architecture — self-hosted Node.js workers, a TypeScript Code piece for custom logic, and a webhook-based trigger model — makes it popular for AI billing workflows. Three production failure modes emerge when Stripe actions sit inside Activepieces flows: retrying a failed flow run from the dashboard creates a new execution from step 1 with the original trigger data, re-firing the Stripe action whether or not the charge already completed; Activepieces processes each received webhook as a fully independent flow run with no cross-run deduplication, so an upstream service retrying a webhook delivery creates two concurrent runs that both execute the billing step simultaneously; and a Code piece that initializes the Stripe client at module scope rather than inside the run() function shares that client — and its key — across all concurrent flow executions in the same Node.js worker process.

The standard Activepieces + Stripe setup

A typical Activepieces billing flow uses three to five steps: a Webhook trigger (Activepieces' built-in Webhook piece, which provides a unique endpoint URL per flow), one or more data transformation steps (a Code piece for custom logic, or the built-in text/math pieces), an HTTP Request action that POSTs to the Stripe API, and optionally downstream notification steps. Activepieces does not have a native Stripe piece that handles authentication — the standard pattern is to store the API key as a connection credential or pass it via an environment variable in the Code piece:

// Activepieces flow: monthly subscription billing
// Step 1: Trigger — Webhook (receives renewal payload)
// Step 2: Code piece — parse customer_id, amount_cents, billing_period
// Step 3: HTTP Request — POST to Stripe
//   URL: https://api.stripe.com/v1/charges
//   Method: POST
//   Headers: { Authorization: "Bearer sk_live_xxxxxxxxxxxx" }
//   Body: amount={{step2.amount_cents}}¤cy=usd&customer={{step2.customer_id}}
// Step 4: HTTP Request — POST notification to Slack
//
// The Stripe API key is either stored in Activepieces Connections
// or hard-coded in the Code piece as an environment-level constant.
// No scope limits. No per-run isolation. No spend cap.

This is the standard Activepieces pattern for billing automation: the flow receives a webhook trigger, transforms the data in a Code piece, and sends a POST to the Stripe API with a stored credential. The three problems below emerge from Activepieces' flow retry model, its webhook processing architecture, and its Code piece execution environment.

Failure mode 1: Flow Retry from dashboard restarts all steps

When an Activepieces flow run fails — any step throws an unhandled exception, an HTTP Request step receives a 4xx or 5xx response, or a Code piece crashes — Activepieces marks the run as failed and records it in the flow runs table. The dashboard displays the failed run with the error step highlighted. A developer or operator can click the "Retry" button on any failed run to re-execute it.

Retry in Activepieces creates a new flow run from step 1 with the original trigger payload. It does not resume from the failed step, skip steps that already succeeded, or replay only the downstream portion of the flow. Every step executes again from the beginning — including the Stripe HTTP Request step that may have already completed successfully on the original run.

What goes wrong: a webhook triggers a billing flow for customer cus_A100 at 09:00 UTC. Step 3 (HTTP Request → Stripe Create Charge) completes successfully, creating ch_live_xxx for $49. Step 4 (HTTP Request → Slack notification) fails — Slack returns a 503 during a brief outage. Activepieces marks the run as failed at Step 4. An engineer opens the flow runs dashboard 20 minutes later, sees the failure, and clicks Retry — the intent is to re-send the Slack notification. Activepieces creates a new flow run from Step 1. Step 3 fires again. Stripe receives POST /v1/charges with customer=cus_A100, amount=4900. Without an idempotency key, Stripe creates ch_live_yyy. cus_A100 is billed twice. The original run shows Step 4 failed; the retried run shows all steps succeeded. No entry in either run log indicates that the same customer was charged twice.

This failure mode is particularly common in self-hosted Activepieces deployments where the ops team monitors the flows dashboard. The Retry button is the natural response to any failed step — especially when the failure is on a downstream notification step that has nothing to do with the billing action. Activepieces provides no mechanism to conditionally skip steps that completed successfully in the original run.

// WITHOUT idempotency key — every retry creates a new charge:
// Step 3: HTTP Request
// URL: https://api.stripe.com/v1/charges
// Headers: { Authorization: "Bearer sk_live_xxxx" }
// Body: amount={{step2.amount_cents}}¤cy=usd&customer={{step2.customer_id}}

// WITH content-hash idempotency key — retry is safe:
// Step 2b: Code piece — compute idempotency key before the HTTP Request step:
const { createHash } = require('crypto');

const { customer_id, amount_cents, billing_period } = inputs;

const idempotency_key = createHash('sha256')
  .update(`${customer_id}:${amount_cents}:${billing_period}:activepieces-billing`)
  .digest('hex');

return { idempotency_key };

// Step 3: HTTP Request
// URL: https://api.stripe.com/v1/charges
// Headers:
//   Authorization: Bearer sk_live_xxxx (or vault key — see Layer 2 below)
//   Idempotency-Key: {{step_2b.idempotency_key}}
// Body: amount={{step2.amount_cents}}¤cy=usd&customer={{step2.customer_id}}

The idempotency key is computed in a Code piece that runs immediately before the Stripe HTTP Request step. The hash is derived from the four fields that define a unique billing operation: customer ID, amount, billing period, and a fixed per-flow prefix string. Every retry of the same flow run uses the same trigger payload, producing the same key — Stripe deduplicates to the original charge object and returns it without creating a new charge.

Failure mode 2: Duplicate webhook delivery creates concurrent billing runs

Activepieces processes webhooks by creating a separate, fully independent flow run for each HTTP request it receives at the trigger endpoint. When an upstream service sends a webhook and receives no acknowledgment within its timeout window — because Activepieces is briefly overloaded, a network hop adds latency, or the acknowledgment packet is lost — the upstream service retries the webhook delivery. Activepieces receives the retry as a new HTTP request and creates a second flow run with the same payload.

The two flow runs execute entirely independently. Activepieces has no cross-run deduplication mechanism — there is no built-in check for "has a flow run with this payload already been processed?" Both runs proceed through every step, including the Stripe billing action, with identical trigger data.

What goes wrong: a CRM system fires a webhook to Activepieces at 14:00:00 UTC when a customer's trial expires. The Activepieces webhook endpoint acknowledges the request, but the acknowledgment packet is lost at a network hop. The CRM waits 30 seconds and retries at 14:00:30 UTC. Activepieces creates two flow runs: Run A (original, 14:00:00) and Run B (retry, 14:00:30). Both runs reach Step 3 (Stripe Create Charge) within seconds of each other. Without idempotency keys, Stripe processes both POST /v1/charges requests independently and creates two charge objects — one for each run — with identical amounts and customer IDs. The customer is billed twice. The Activepieces runs table shows two successful runs with different run IDs and slightly different timestamps, each with its own Stripe charge ID. Nothing in the platform flags that both runs processed the same billing event.

The duplicate webhook failure mode is structurally different from the retry failure mode because it requires no human action — it happens automatically when network conditions are imperfect, which is routine in production. Any upstream service that uses HTTP webhooks with a retry policy (virtually all of them) is a potential source of duplicate webhook deliveries. The typical upstream retry window is 30 seconds to 5 minutes, exactly the window in which two concurrent Activepieces runs can both complete the billing step before either one's result is visible to the other.

// Activepieces Code piece — dedup guard using Activepieces Store (KV):
// Step 1: Webhook trigger
// Step 2: Code piece — dedup check + compute idempotency key

const { customer_id, amount_cents, billing_period } = inputs;

// Compute content-hash idempotency key — same for any run with this payload:
const { createHash } = require('crypto');
const idempotency_key = createHash('sha256')
  .update(`${customer_id}:${amount_cents}:${billing_period}:activepieces-billing`)
  .digest('hex');

// The idempotency_key on the Stripe request handles the duplicate-charge case.
// Even if two runs reach Stripe simultaneously with the same key,
// Stripe's atomic dedup ensures exactly one charge is created.
// The second concurrent call returns the same charge object as the first.

return { idempotency_key, customer_id, amount_cents };

// Step 3: HTTP Request → Stripe
// Headers: { ..., Idempotency-Key: {{step2.idempotency_key}} }
// Both concurrent runs send the same key — Stripe deduplicates atomically.

The content-hash idempotency key is the correct fix for the duplicate webhook failure mode. Because the key is derived from the billing event fields — not from a run-specific value like a run ID or timestamp — both concurrent runs compute the same key from the same trigger payload. Stripe's idempotency implementation is atomic: when two requests arrive simultaneously with the same key, Stripe processes one and returns the same charge object to both. The customer is charged exactly once regardless of how many concurrent Activepieces runs reach the Stripe endpoint.

Failure mode 3: Code piece module-scope Stripe client shares key across concurrent runs

Activepieces' Code piece executes TypeScript/JavaScript in a Node.js environment. The piece is structured as a module with a run() function that receives the step inputs and returns the step output. A common pattern — and a dangerous one for billing automation — is to initialize the Stripe client outside the run() function at module scope:

// UNSAFE: Stripe client initialized at module scope
// Node.js module caching means this line runs once per worker process start.
// All concurrent Code piece executions in this worker share 'stripe'.
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export const run = async ({ inputs }) => {
  const charge = await stripe.charges.create({
    amount: inputs.amount_cents,
    currency: 'usd',
    customer: inputs.customer_id,
  });
  return { charge_id: charge.id };
};

In a low-concurrency deployment this is harmless — one run at a time, no sharing issue. At production scale, Activepieces workers process multiple flow runs concurrently. The module-level stripe instance — and the API key embedded in it — is shared across all of them. The failure path is not about duplicate charges; it is about key governance: a full sk_live_ key at module scope means that any one flow run with a logic error — an infinite retry loop, an off-by-one that charges amount * 100 instead of amount, or a mis-configured batch trigger that fires for every customer in a 10,000-row table — can issue charges against the same unrestricted key until Stripe's own daily volume limits intervene.

What goes wrong: an Activepieces flow is configured to charge customers when a scheduled trigger fires at midnight. A developer changes the trigger from "Daily at 00:00" to "Every hour" while testing and forgets to revert it before deploying. The flow fires 24 times in 24 hours. Each run reaches the Code piece with the module-scope Stripe client. Each run calls stripe.charges.create() with the same customer and amount — but without an idempotency key, each creates a new charge. At a $99/month plan, the customer is charged 24 × $99 = $2,376 over 24 hours. The module-scope key has no per-customer, per-run, or per-day cap. Stripe does not intervene because the individual charges are normal size. The billing team discovers the issue when the customer disputes the charges — 24 separate dispute events at $99 each.

// SAFE: Stripe client initialized inside run() — no shared state between runs
// Each concurrent execution has its own client instance and key scope.

export const run = async ({ inputs, context }) => {
  const Stripe = require('stripe');

  // Per-run vault key: scoped to POST /v1/charges, daily USD cap = expected charge
  // vault_key comes from the flow's input parameters or trigger payload
  const stripe = new Stripe(inputs.vault_key, {
    // Route through Keybrake proxy to enforce spend cap at the network layer
    host: 'proxy.keybrake.com',
    port: 443,
    protocol: 'https',
  });

  const { createHash } = require('crypto');
  const idempotency_key = createHash('sha256')
    .update(`${inputs.customer_id}:${inputs.amount_cents}:${inputs.billing_period}:activepieces-billing`)
    .digest('hex');

  const charge = await stripe.charges.create(
    {
      amount: inputs.amount_cents,
      currency: 'usd',
      customer: inputs.customer_id,
      description: `Renewal for ${inputs.billing_period}`,
    },
    { idempotencyKey: idempotency_key }
  );

  return { charge_id: charge.id, idempotency_key };
};

Moving the Stripe client initialization inside run() ensures each flow execution gets its own client instance with its own key. Passing a per-run vault key (rather than a shared module-level environment variable) adds the second governance layer: the vault key is scoped to POST /v1/charges only, with a daily_usd_cap equal to the maximum expected single charge. A runaway flow that fires 24 times will be blocked by the proxy after the first successful charge — subsequent calls return HTTP 429 with a cap-exceeded error.

The two-layer governance fix

All three Activepieces failure modes — flow retry, duplicate webhook, and module-scope key — are addressed by the same two-layer pattern: a content-hash idempotency key that makes the Stripe operation idempotent at the API layer, and a per-flow vault key with a daily USD spend cap that provides a hard stop at the proxy layer.

Layer 1: content-hash idempotency key (Stripe deduplication)

Add a Code piece step before every Stripe HTTP Request step. Compute a SHA-256 hash from the four fields that define a unique billing operation: customer_id, amount_cents, billing_period, and a fixed per-flow prefix string. Pass the hash as the Idempotency-Key header in the HTTP Request step. This closes the flow retry failure mode (same trigger payload → same key → Stripe returns original charge) and the duplicate webhook failure mode (both concurrent runs compute the same key → Stripe deduplicates atomically).

Layer 2: per-flow vault key with daily USD cap (proxy enforcement)

Issue a Keybrake vault key per flow or per trigger event. Store it in Activepieces' flow settings as an input parameter, or derive it from the trigger payload if per-customer isolation is required. Set the vault key policy to allowed_endpoints: ["POST /v1/charges"] and daily_usd_cap: <max_expected_single_charge>. Point the HTTP Request step at https://proxy.keybrake.com/stripe/v1/charges instead of https://api.stripe.com/v1/charges.

// Activepieces Code piece — full two-layer governance pattern:
export const run = async ({ inputs }) => {
  const { createHash } = require('crypto');

  const {
    customer_id,
    amount_cents,
    billing_period,
    vault_key,    // per-flow or per-customer vault key from flow inputs
  } = inputs;

  // Layer 1: content-hash idempotency key
  const idempotency_key = createHash('sha256')
    .update(`${customer_id}:${amount_cents}:${billing_period}:activepieces-billing`)
    .digest('hex');

  return { idempotency_key, vault_key, customer_id, amount_cents, billing_period };
};

// Follow-up HTTP Request step:
// URL:     https://proxy.keybrake.com/stripe/v1/charges   (Layer 2: proxy)
// Method:  POST
// Headers:
//   Authorization: Bearer {{code_step.vault_key}}         (Layer 2: vault key)
//   Idempotency-Key: {{code_step.idempotency_key}}        (Layer 1: dedup)
//   Content-Type: application/x-www-form-urlencoded
// Body:
//   amount={{code_step.amount_cents}}
//   currency=usd
//   customer={{code_step.customer_id}}
//   description=Renewal for {{code_step.billing_period}}

Comparison: Activepieces Stripe governance options

Approach Flow retry guard Duplicate webhook guard Module-scope key isolation Per-flow spend cap Endpoint allowlist Audit log
Raw sk_live_ key, no idempotency ✗ duplicate charge on retry ✗ duplicate charge on concurrent run ✗ shared across all runs ✗ none ✗ none ✗ none
Stripe restricted key (read/write limits) ✗ still creates duplicate charges ✗ still creates duplicate charges ✗ still shared at module scope ✗ none Partial (resource type only) ✗ none
Idempotency key only (no proxy) ✓ Stripe dedup on retry ✓ Stripe dedup on concurrent run ✗ module scope still shared ✗ none ✗ none ✗ none
Vault key + proxy (no idempotency key) Partial (cap helps, no dedup) Partial (cap helps, no dedup) ✓ per-run vault key isolates scope ✓ daily USD cap ✓ endpoint allowlist ✓ every call logged
Idempotency key + vault key + proxy ✓ Stripe dedup + cap hard stop ✓ Stripe dedup + cap hard stop ✓ per-run vault key isolates scope ✓ daily USD cap ✓ endpoint allowlist ✓ every call logged

Enforcement tests

// activepieces-billing.test.ts — enforce governance invariants in CI
import { createHash } from 'crypto';
import { describe, test, expect } from 'vitest';

const computeIdempotencyKey = (
  customer_id: string,
  amount_cents: number,
  billing_period: string
) =>
  createHash('sha256')
    .update(`${customer_id}:${amount_cents}:${billing_period}:activepieces-billing`)
    .digest('hex');

describe('Activepieces billing idempotency key', () => {
  test('is deterministic — same inputs produce same key', () => {
    const k1 = computeIdempotencyKey('cus_A100', 4900, '2026-06');
    const k2 = computeIdempotencyKey('cus_A100', 4900, '2026-06');
    expect(k1).toBe(k2);
  });

  test('differs across billing periods — no cross-period dedup', () => {
    const k1 = computeIdempotencyKey('cus_A100', 4900, '2026-06');
    const k2 = computeIdempotencyKey('cus_A100', 4900, '2026-07');
    expect(k1).not.toBe(k2);
  });

  test('differs across customers — no cross-customer dedup', () => {
    const k1 = computeIdempotencyKey('cus_A100', 4900, '2026-06');
    const k2 = computeIdempotencyKey('cus_B200', 4900, '2026-06');
    expect(k1).not.toBe(k2);
  });

  test('is 64 hex characters (SHA-256 output)', () => {
    const k = computeIdempotencyKey('cus_A100', 4900, '2026-06');
    expect(k).toMatch(/^[0-9a-f]{64}$/);
  });

  test('vault key daily cap equals expected max single charge', () => {
    // Enforced by Keybrake policy — this test documents the intended cap.
    // If the max plan amount changes, update BOTH this test and the vault key policy.
    const MAX_PLAN_AMOUNT_CENTS = 39900; // $399 Scale plan
    const VAULT_KEY_DAILY_CAP_CENTS = 39900;
    expect(VAULT_KEY_DAILY_CAP_CENTS).toBe(MAX_PLAN_AMOUNT_CENTS);
  });
});

Gap analysis

Activepieces native HTTP Request piece lacks an Idempotency-Key field. The built-in HTTP Request action supports custom headers, so you can pass the key manually. The Code piece must compute it in a preceding step and reference it via template variable. There is no idempotency-key UI field equivalent to what some native Stripe pieces in other platforms provide — but the Code piece pattern above is the correct workaround.

Activepieces branches and loop steps complicate the idempotency key scope. If the billing step sits inside an Activepieces Loop piece, the loop index must be included in the idempotency key — otherwise all iterations of the loop hash to the same key and Stripe deduplicates them into one charge. Add :loop_${loop_index} to the hash input for any billing step inside a Loop. For Branch pieces, include the branch name in the key prefix (activepieces-billing-branch-upgrade vs activepieces-billing-branch-renewal).

Activepieces flow versioning can create key collisions on plan changes. If a flow is updated — the amount_cents field is renamed, billing_period format changes — old runs retried against the new flow version may compute a different idempotency key for the same billing event (because the input fields changed). Store idempotency keys externally in a persistent KV store keyed by webhook_delivery_id or external_event_id for flows that handle retry-sensitive billing at scale.

Self-hosted Activepieces worker restarts lose in-memory dedup state. Any in-memory deduplication in a Code piece (a Map or Set at module scope) is cleared on worker restart. Use Stripe's idempotency key mechanism (durable, on Stripe's side) as the authoritative dedup layer, not in-memory state in Activepieces workers.

FAQ

Can I use the Activepieces run ID as the idempotency key instead of a content hash? No. The Activepieces run ID changes on retry — a retried run gets a new run ID. A content hash derived from the billing event fields is stable across all retry paths, including manual retry, duplicate webhook, and any future platform-level retry mechanism.

Does Activepieces Cloud vs. self-hosted change the failure modes? The failure modes are identical in both deployments — they arise from the flow execution model, not the hosting. The main operational difference is that self-hosted workers give you direct access to logs and the ability to run dedup middleware at the infrastructure layer, but the Code piece governance pattern above works in both.

What if two concurrent runs submit the same idempotency key to Stripe at exactly the same millisecond? Stripe handles this atomically. Concurrent requests with the same idempotency key are serialized on Stripe's side — one creates the charge, the other returns the same charge object. No duplicate charge is created. This is the correct behavior and the designed use case for Stripe's idempotency key mechanism.

How do I handle two legitimate charges for the same customer in the same billing period? Include a charge type or line-item suffix in the idempotency key hash input — for example, ${customer_id}:${amount_cents}:${billing_period}:renewal vs. ${customer_id}:${amount_cents}:${billing_period}:upgrade. Different suffixes produce different keys, allowing both charges to succeed independently while still deduplicating each one against its own retries.

What happens when the vault key daily cap is exhausted mid-batch? The proxy returns HTTP 429 with {"error": "daily_usd_cap_exceeded"}. The Activepieces HTTP Request step fails. Activepieces marks the run as failed at that step. The failed run is visible in the runs table. Issue a new vault key with a higher cap for intentional over-cap scenarios, or split the batch into per-customer vault keys each capped at one charge per customer per day.

Does Activepieces have a built-in deduplication step? Not for billing idempotency. Activepieces has a "Deduplicate" piece in its community pieces library that deduplicates trigger events by a field value using an in-memory or Redis-backed store. This is useful for deduplicating repeated webhook deliveries before they reach the billing step, but it is a complement to, not a replacement for, Stripe-layer idempotency keys — in-process dedup is lost on worker restart and does not protect against the case where two workers process the same event simultaneously.

Enforce Activepieces billing governance in one URL change

Point your Activepieces HTTP Request step at proxy.keybrake.com/stripe/v1/charges. Issue a vault key scoped to POST /v1/charges with a daily USD cap. Every call is checked, capped, and logged — flow retry, duplicate webhook, and module-scope key risks are all covered at the proxy layer.