Agent Governance
Make.com Stripe Integration: Restricted API Keys, Spend Caps, and Agent Governance
Make.com's visual automation builder makes it deceptively easy to connect a Stripe billing action to any trigger — drag an HTTP module onto the canvas, paste in https://api.stripe.com/v1/charges, fill in the request body with Make.com expressions, and you have a working billing scenario in under five minutes. Three production failure modes emerge in Make.com Stripe setups: when a scenario errors after the Stripe HTTP module has already created a charge, Make.com marks it Incomplete — re-running it from the Operations panel restarts the entire scenario from module 1, re-firing the Stripe call on an already-billed customer; Make.com's error handler Retry directive re-executes the exact module that failed, so a transient network error after a successful charge fires an identical request without an idempotency key; and Make.com's instant webhook trigger can receive the same payload twice — from the upstream system retrying and from Make.com's own delivery retry — creating two concurrent scenario runs that both reach the billing module with identical inputs.
The standard Make.com + Stripe scenario setup
A typical Make.com billing scenario has four modules on the canvas: a Webhook or Scheduler trigger, a data-mapping Tools module (Set Variable or Parse JSON), an HTTP module calling https://api.stripe.com/v1/charges, and a downstream notification or database-write module. The HTTP module is configured with Bearer token authentication using the Stripe secret key stored as a Make.com Connection or hardcoded in the Authorization header field:
// Make.com HTTP Module: Create Stripe charge
// URL: https://api.stripe.com/v1/charges
// Method: POST
// Auth: Bearer {{connection.stripe_secret_key}}
//
// Body (application/x-www-form-urlencoded):
// amount={{1.amount_cents}}
// currency=usd
// customer={{1.customer_id}}
// description=Billing for {{1.billing_period}}
//
// Make.com expression for amount:
// {{1.amount_cents}} ← data from webhook bundle, module 1 output
This is the standard Make.com pattern: variables from upstream modules flow into the HTTP body via double-brace expressions, the module fires the Stripe API call, and the response bundle flows downstream for confirmation handling. The problems appear when Make.com's execution recovery system, its error handling primitives, and its webhook delivery semantics interact with a Stripe call that must be idempotent.
Failure mode 1: Incomplete Execution re-run restarts the entire scenario
Make.com's Incomplete Executions feature stores failed scenario runs so they can be re-processed. When any module in a scenario throws an unhandled error — a downstream database write fails, an email notification module times out, a data validation step rejects the payload — Make.com pauses the execution and records it as Incomplete in the Operations > Incomplete Executions panel. The stored execution includes the original webhook bundle. From the panel, a user (or an automated cleanup job) can click Re-run to process the Incomplete execution again.
The critical detail is that Re-run restarts the scenario from the first module, not from the module that failed. Every module runs again in sequence. If the Stripe HTTP module is module 3 and the failure happened at module 5 (the downstream confirmation), re-running the Incomplete execution sends a new Stripe charge request on module 3 — whether or not the original execution already created a charge there.
What goes wrong: a Make.com billing scenario fires at midnight for 200 subscription customers. The HTTP module creates all 200 charges successfully. Module 5 — a Notion database write to log the billing outcome — fails for 12 customers due to a Notion API rate limit at 00:04 UTC. Make.com marks 12 executions as Incomplete. An ops engineer sees the Incomplete count in the morning and clicks Re-run All. All 12 scenarios restart from module 1. The Stripe HTTP module runs again for each. Without idempotency keys, Stripe creates 12 new charges. Those 12 customers are billed twice. Both the original and the retry execution show status "completed successfully" in Make.com's execution history — the double charge is invisible unless you cross-reference the Stripe dashboard.
Make.com gives no built-in mechanism to skip already-completed modules during an Incomplete re-run. The scenario design has no "checkpoint" concept — modules either all re-run or the whole execution is discarded. The only protection at the Stripe layer is an idempotency key that produces the same string for the same billing operation, so Stripe returns the original charge object rather than creating a new one.
// Make.com HTTP Module — request body WITHOUT idempotency key:
// amount={{1.amount_cents}}
// currency=usd
// customer={{1.customer_id}}
// description=Billing for {{1.billing_period}}
// [no Idempotency-Key header — Stripe creates a new charge on every request]
//
// Make.com HTTP Module — request body WITH content-hash idempotency key:
// amount={{1.amount_cents}}
// currency=usd
// customer={{1.customer_id}}
// description=Billing for {{1.billing_period}}
//
// Headers:
// Idempotency-Key: {{sha256(1.customer_id + ":" + 1.amount_cents + ":" + 1.billing_period + ":make-billing")}}
//
// Make.com's built-in sha256() function derives a deterministic 64-char hex key.
// Re-run of the same Incomplete execution → same customer, amount, period → same key
// → Stripe returns the original charge object, no duplicate.
Make.com's expression engine includes sha256() natively — no custom module or external webhook required. Constructing the idempotency key inline in the HTTP module header field closes the Incomplete re-run failure mode without changing the scenario structure.
Failure mode 2: Error handler Retry directive fires a second charge
Make.com's error handling system lets you attach a handler route to any module. The available directives are Ignore (skip the erroring module and continue), Break (stop the scenario and save as Incomplete), Rollback (undo upstream module operations), Commit (save progress so far and stop), and Retry (re-execute the failed module up to N times with a delay). Retry is designed for transient failures — a momentary API timeout, a brief service interruption — where running the same request a second time is expected to succeed.
The problem arises when Retry is attached to the Stripe HTTP module itself. If Stripe processes the charge request and creates a charge, but the HTTP response takes longer than Make.com's module timeout (or a network error occurs after the charge was created but before the response arrives), Make.com treats the module as failed. The Retry directive fires the same HTTP request again. Without an idempotency key, Stripe sees a new request with the same parameters and creates a new charge.
What goes wrong: the Stripe HTTP module has a Retry error handler configured with 3 attempts and a 10-second delay — a reasonable setup for an API that occasionally has brief outages. At 14:32 UTC, a billing scenario fires for customer cus_B200. The HTTP module sends POST /v1/charges with amount=9900. Stripe processes the request and creates ch_xyz in 280ms. But a CDN edge node between Make.com's infrastructure and Stripe's API closes the TCP connection before forwarding the 200 response — a rare but real event on high-latency edge links. Make.com's HTTP module receives a connection reset, logs it as an error, and the Retry directive fires 10 seconds later. The second request reaches Stripe cleanly and creates ch_abc for the same amount. The scenario continues, logs the second charge ID, and marks the execution successful. Customer cus_B200 is billed $99 twice. The Retry directive did exactly what it was designed to do — the problem is that Stripe is not idempotent by default.
The Retry failure mode is particularly deceptive because the scenario's error handler behaves correctly — it recovered from a transient failure. The audit trail shows one Make.com execution, one final success status, and one charge ID (the second one). The first charge exists in the Stripe dashboard but has no corresponding record in Make.com's execution log. The only way to detect it is a reconciliation report that cross-references Make.com execution timestamps with Stripe charge creation timestamps for the same customer.
// Make.com HTTP Module: Create Stripe charge — with Retry error handler
//
// WITHOUT idempotency key:
// POST https://api.stripe.com/v1/charges
// amount=9900¤cy=usd&customer=cus_B200
//
// Attempt 1: Stripe creates ch_xyz → connection reset → Make.com sees error
// Retry after 10s:
// Attempt 2: POST same body → Stripe creates ch_abc → 200 OK → scenario continues
// Result: ch_xyz + ch_abc both exist. cus_B200 billed twice.
//
// WITH idempotency key in Retry-attached module:
// Headers:
// Idempotency-Key: {{sha256("cus_B200:9900:2026-06:make-billing")}}
// = "a3f7c..." ← same key on every attempt
//
// Attempt 1: Stripe creates ch_xyz, stores key "a3f7c..." → connection reset
// Retry after 10s:
// Attempt 2: Stripe sees key "a3f7c..." already used → returns ch_xyz, HTTP 200
// Result: one charge. Retry worked as intended.
The fix is the same as for Incomplete re-runs: a content-hash idempotency key in the HTTP module's headers. The Retry directive then becomes genuinely safe — the second (and third) attempts return Stripe's original charge object instead of creating new ones, and the scenario logs the correct charge ID either way.
Failure mode 3: Webhook queue double-delivery creates concurrent billing runs
Make.com's instant webhook trigger processes incoming payloads as they arrive, queuing them for the active scenario. The webhook URL is persistent — any HTTP POST to that URL triggers a new scenario execution. Two sources of double-delivery exist in production billing setups: the upstream system's retry policy and Make.com's internal queue behavior.
The upstream retry path: many billing orchestration systems (Stripe Events, billing platforms, internal job queues) retry webhook deliveries after a timeout. If Make.com's webhook receiver acknowledges the POST (returning 200 to the caller) but the downstream scenario processing takes longer than the upstream system's retry window, the upstream system re-delivers the same payload. Make.com receives the second delivery as a new, independent event — it has no deduplication layer on webhook payloads. A second scenario execution starts, parallel to the first.
What goes wrong: a billing orchestration service sends a webhook to Make.com's trigger URL for each monthly renewal. Customer cus_C300's renewal payload arrives at 09:00:00 UTC. Make.com acknowledges the POST (200 OK) immediately but queues the scenario execution — the actual scenario processing starts at 09:00:02. The billing scenario takes 38 seconds (LLM enrichment step, Stripe call, two database writes). The orchestration service has a 30-second retry window. At 09:00:30, it re-delivers the same payload (no 200 confirmation from the scenario, only the initial webhook acknowledgment). Make.com receives the second delivery and starts a new scenario execution. Now two scenario runs are in progress simultaneously for cus_C300. Both reach the Stripe HTTP module. Both fire POST /v1/charges with amount=4900, customer=cus_C300. Without an idempotency key, Stripe creates two charges. Without a per-session vault key, neither execution can be blocked by a spend cap — there is no proxy layer tracking how much this customer has been billed in this scenario run.
The parallel execution dimension makes this failure mode distinct from the first two. The two Make.com scenario runs are genuinely concurrent — they are not sequential re-runs of the same execution, but two independent processes with no shared state. Even if one scenario checks a Make.com Data Store for a "charge in progress" flag, the race condition window (between the flag check and the Stripe call) means both runs can pass the check before either writes the flag.
// Make.com webhook double-delivery timeline:
//
// T+00s Orchestration service sends POST to Make.com webhook URL
// Make.com responds 200 OK (webhook acknowledged)
// Scenario execution #1 queued
//
// T+02s Execution #1 starts processing
// Module 1: Parse webhook payload → customer=cus_C300, amount=4900, period=2026-06
// Module 2: LLM enrichment step... (takes ~25s)
//
// T+30s Orchestration service retry: re-sends same payload (30s timeout window)
// Make.com responds 200 OK again (new independent delivery)
// Scenario execution #2 queued
//
// T+30s Execution #2 starts processing
// Module 1: Parse same payload → customer=cus_C300, amount=4900, period=2026-06
//
// T+27s Execution #1 reaches Stripe HTTP module:
// POST /v1/charges amount=4900, customer=cus_C300 → ch_pqr ✓
//
// T+35s Execution #2 reaches Stripe HTTP module:
// POST /v1/charges amount=4900, customer=cus_C300 → ch_stu ✗ (duplicate)
//
// WITH idempotency key:
// Both executions compute sha256("cus_C300:4900:2026-06:make-billing") = "b8e2d..."
// Execution #1: Stripe creates ch_pqr, stores key "b8e2d..."
// Execution #2: Stripe sees key "b8e2d..." → returns ch_pqr, no new charge created
The content-hash idempotency key closes the Stripe layer. The per-run vault key from the proxy adds a second layer: if the orchestration service has no Stripe key access at all, and both Make.com executions use a vault key with a per-customer daily USD cap matching the expected charge, the proxy's ledger blocks the second charge at the proxy boundary — even if somehow two requests arrived with different idempotency keys due to a key construction bug.
The two-layer fix for Make.com Stripe scenarios
The pattern that closes all three failure modes: a content-hash idempotency key in the Stripe HTTP module header, and the Stripe base URL routed through the Keybrake proxy with a vault key instead of the raw Stripe secret.
Layer 1: content-hash idempotency key in the HTTP module
Make.com's built-in sha256() function generates the key directly in the HTTP module's header field — no code module, no webhook call, no additional API required. The key is derived from the billing operation's content, not from Make.com's internal execution ID, which changes on every run:
// Make.com HTTP Module — full configuration with idempotency key
//
// URL: https://proxy.keybrake.com/stripe/v1/charges
// Method: POST
// Auth: Bearer {{1.vault_key}} ← per-scenario vault key, not raw Stripe secret
//
// Headers:
// Content-Type: application/x-www-form-urlencoded
// Idempotency-Key: {{sha256(1.customer_id + ":" + toString(1.amount_cents) + ":" + 1.billing_period + ":make-billing")}}
//
// Body (URL-encoded form):
// amount={{1.amount_cents}}
// currency=usd
// customer={{1.customer_id}}
// description=Billing for {{1.billing_period}}
//
// The vault_key comes from the webhook payload (issued per-scenario by your system)
// or from a Make.com Data Store keyed by customer_id.
// The proxy enforces: POST /v1/charges only, daily USD cap, audit log entry.
The base URL change — from api.stripe.com to proxy.keybrake.com/stripe — is the only structural change to the Make.com scenario. The HTTP module sends to the proxy instead of directly to Stripe; the proxy looks up the real Stripe key from the vault key in the Authorization header, enforces the policy, forwards to Stripe, logs the response, and returns Stripe's response body unchanged. From Make.com's perspective, the module works identically to before.
Layer 2: per-scenario vault key with daily USD cap
A vault key scoped to a single customer's billing operation — issued at the start of the scenario run via the proxy's key-issue endpoint — gives you three guarantees that a raw Stripe key cannot provide: the key is valid only for POST /v1/charges on a single customer's allowed merchant ID, the daily cap is set to exactly the expected charge amount (blocking any runaway retry loop that somehow produces a novel idempotency key), and every charge attempt is logged in the proxy's audit table with the scenario execution timestamp, the vault key ID, and the Stripe response.
// Upstream system: issue a vault key before sending the Make.com webhook
//
const response = await fetch('https://proxy.keybrake.com/admin/vault-keys', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.KEYBRAKE_ADMIN_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
vendor: 'stripe',
customer_id: customerId, // scope to this customer only
allowed_endpoints: ['POST /v1/charges'],
daily_usd_cap: chargeAmountUSD, // exactly the expected charge — blocks overrun
expires_at: new Date(Date.now() + 3600_000).toISOString(),
}),
});
const { vault_key } = await response.json();
// Include vault_key in the Make.com webhook payload:
await fetch(makeWebhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId, amount_cents: amountCents,
billing_period: billingPeriod, vault_key }),
});
// Make.com HTTP module uses {{1.vault_key}} in the Authorization header.
Comparison: raw key vs. restricted key vs. vault key in Make.com scenarios
| Governance layer | Incomplete re-run guard | Error handler Retry guard | Webhook double-delivery guard | Per-scenario spend cap | Endpoint allowlist | Audit log |
|---|---|---|---|---|---|---|
Raw sk_live_ key in HTTP module |
None — each re-run creates a new charge | None — each retry fires a new request | None — parallel runs both charge independently | None | None — full Stripe API access | Stripe dashboard only |
| Stripe restricted key | None without idempotency key | None without idempotency key | None — race window still present | None — Stripe doesn't enforce USD caps per key | Yes — charge endpoint only | Stripe dashboard only |
| Content-hash idempotency key (HTTP module header) | Stripe deduplicates — one charge regardless of re-run count | Stripe deduplicates — retry returns original charge | Stripe deduplicates — parallel runs return same charge object | None — no proxy cap | Depends on key type | Stripe dashboard only |
| Vault key via Keybrake proxy | Proxy deduplicates + Stripe deduplicates | Proxy cap blocks overrun even on novel key | Second run blocked at proxy if cap exhausted | Yes — per-scenario daily USD cap | Yes — proxy enforces allowed endpoints | Proxy audit table + Stripe dashboard |
Enforcement tests for the Make.com + Stripe integration
Make.com scenarios don't have native unit tests, but the idempotency key function and vault key scoping logic run in the upstream system that prepares the webhook payload. Test those directly:
// Jest: Make.com Stripe integration — idempotency key and vault key scoping
const crypto = require('crypto');
function makeBillingIdempotencyKey(customerId, amountCents, billingPeriod) {
// Replicates Make.com's sha256() expression: sha256(id + ":" + amount + ":" + period + ":make-billing")
const payload = `${customerId}:${amountCents}:${billingPeriod}:make-billing`;
return crypto.createHash('sha256').update(payload).digest('hex');
}
test('same inputs produce the same idempotency key', () => {
const k1 = makeBillingIdempotencyKey('cus_C300', 4900, '2026-06');
const k2 = makeBillingIdempotencyKey('cus_C300', 4900, '2026-06');
expect(k1).toBe(k2);
});
test('different billing periods produce different keys', () => {
const jun = makeBillingIdempotencyKey('cus_C300', 4900, '2026-06');
const jul = makeBillingIdempotencyKey('cus_C300', 4900, '2026-07');
expect(jun).not.toBe(jul);
});
test('different customers produce different keys', () => {
const c300 = makeBillingIdempotencyKey('cus_C300', 4900, '2026-06');
const c400 = makeBillingIdempotencyKey('cus_C400', 4900, '2026-06');
expect(c300).not.toBe(c400);
});
test('vault key daily cap equals expected charge — no overrun headroom', () => {
const chargeAmountUSD = 49.00;
const vaultKeyConfig = {
vendor: 'stripe',
allowed_endpoints: ['POST /v1/charges'],
daily_usd_cap: chargeAmountUSD,
};
// Cap equals charge — a second charge (duplicate) hits the cap and is blocked
expect(vaultKeyConfig.daily_usd_cap).toBe(chargeAmountUSD);
expect(vaultKeyConfig.allowed_endpoints).toEqual(['POST /v1/charges']);
});
test('Make.com webhook payload includes vault_key field', () => {
const payload = { customer_id: 'cus_C300', amount_cents: 4900,
billing_period: '2026-06', vault_key: 'vk_abc123' };
expect(payload.vault_key).toMatch(/^vk_/);
expect(payload.customer_id).toBeTruthy();
expect(payload.billing_period).toBeTruthy();
});
Gap analysis
Make.com native Stripe module vs. HTTP module idempotency support. Make.com provides an official Stripe integration module (in the Make.com app directory) with pre-built actions for common Stripe operations. As of mid-2026, the native Stripe module's "Create a Charge" action does not expose an idempotency key field in its configuration UI. You must use the HTTP module (not the native Stripe module) to set the Idempotency-Key header. Teams already using the native Stripe module will need to migrate those actions to HTTP modules to apply the fix in this post.
Make.com AI Agent scenario execution semantics. Make.com's AI Agent module (in beta as of mid-2026) adds an LLM reasoning step inside a scenario — the agent can decide which downstream modules to invoke, including Stripe HTTP modules. If the AI Agent module selects a billing action after a multi-turn reasoning loop, the same execution retry risks apply at the AI Agent level: the agent may be re-run as part of an Incomplete execution or a Retry directive, and it may decide to bill again on the second run. The content-hash idempotency key still applies if the agent's billing inputs are deterministic across runs — but if the agent generates a new billing decision on each run (different amount or description), the key will differ and Stripe will create a new charge. Constrain the agent's billing inputs to structured fields (not free-text amounts) to keep the idempotency key deterministic.
Make.com Data Store as a charge deduplication layer. Some teams use a Make.com Data Store (built-in key-value store) to track "charge attempted" status per customer per billing period, as an alternative to idempotency keys. The Data Store check-and-set pattern has a race condition window for concurrent executions: two Make.com runs can both read "not charged" before either writes "charged" — identical to the classic check-then-act concurrency bug. A Stripe-layer idempotency key is atomic by design (Stripe's backend guarantees it); a Data Store flag is not. Use idempotency keys as the primary guard; Data Store flags are a useful secondary signal for alerting, not a replacement.
Make.com Incomplete Executions auto-processing. Make.com offers an "Auto-commit incomplete executions" setting that automatically re-runs Incomplete executions on a schedule without manual intervention. If this setting is enabled in a scenario containing a Stripe billing module, duplicate charges can occur silently overnight without any human click — the Incomplete re-run risk becomes a background automation risk. Verify this setting is either disabled for billing scenarios or that all billing HTTP modules use content-hash idempotency keys before enabling auto-processing.
FAQ
Can I use Make.com's execution ID as the idempotency key instead of a content hash?
No. Make.com assigns a new execution ID to every scenario run — including Incomplete re-runs and retry attempts. Two runs of the same billing scenario (the original and the re-run) get different execution IDs, which produces different idempotency keys, which causes Stripe to create two separate charges. The idempotency key must be derived from the billing operation's content — (customer_id, amount_cents, billing_period) — so that any number of Make.com scenario runs for the same billing event produce the same key and Stripe deduplicates them all.
Does Make.com's native Stripe module support idempotency keys?
Not via a UI field as of mid-2026. The native Make.com Stripe module exposes the common Stripe parameters (amount, currency, customer) but not the Idempotency-Key header. To set idempotency keys, replace the native Stripe module with a Make.com HTTP module configured with Bearer auth and the Idempotency-Key header set to {{sha256(...)}} in the Headers section. The HTTP module gives full control over every request header and body field.
What happens if two Make.com runs send the same idempotency key to Stripe at exactly the same time?
Stripe handles concurrent requests with the same idempotency key safely: one request wins the lock, creates the charge, and returns it. The other request waits briefly (Stripe holds the key for 30 minutes for concurrent deduplication) and then returns the same charge object. Both Make.com executions log the same charge ID. No duplicate charge is created. This is exactly the Stripe-layer guarantee idempotency keys provide — they are safe under concurrent use.
How do I handle a scenario where the charge amount might legitimately change between billing cycles?
Include the billing period in the idempotency key — sha256(customer_id + ":" + amount_cents + ":" + billing_period). A different billing period (July vs. June) produces a different key, so a July charge for the same customer at the same amount is treated as a new billing event, not a duplicate of June. A proration or one-time adjustment mid-cycle uses a distinct billing_period string (e.g., 2026-06-proration-1) to produce a unique key. The key structure ensures that the same business event always maps to one charge and different business events map to different charges.
My Make.com scenario hits the daily cap on the proxy vault key mid-batch. How do I handle cap exhaustion?
Set the vault key's daily_usd_cap to the sum of all charges the scenario is expected to create in that run — not just a single charge — if one vault key covers a multi-customer batch. For single-customer vault keys (issued per webhook payload), set the cap to exactly that customer's charge amount. If the proxy returns 429 (cap exhausted), the Make.com HTTP module receives a non-200 response. Configure the HTTP module's error handler to Break (save as Incomplete) rather than Retry — re-trying a cap-exhausted request won't succeed and will waste Make.com operations. Investigate the cap exhaustion event in the proxy audit log to confirm whether the original charge completed.
Does this proxy approach work with Make.com's native Stripe webhook triggers?
Yes, but that's a different direction. The proxy sits between your Make.com scenario and the Stripe API (outbound calls from Make.com to Stripe). Stripe webhooks going into Make.com are unaffected — you still receive them at Make.com's webhook URL directly from Stripe. The proxy governs outbound billing calls only. If you need to verify incoming Stripe webhook signatures in a Make.com scenario, use the HTTP module's response body parsing to check the Stripe-Signature header against your webhook secret — a separate concern from the outbound billing governance this post covers.
Put spend caps on your Make.com Stripe scenarios
Keybrake issues per-scenario vault keys with daily USD caps and endpoint allowlists. Route your Make.com HTTP modules through proxy.keybrake.com/stripe/v1 — one URL change, full audit trail, no more Incomplete re-run surprises.