Your egress proxy logs every call your services make to the billing vendor, yet the vendor’s dashboard lists invoices that no line in that log explains. They come from a Zap someone in finance built, calling from Zapier’s network with the account’s admin key pasted into its connection. Your allowlist, your budget, and your audit trail cover none of those calls.
What you get
You will end up with each automation workflow behind your egress proxy or rewritten as a service, a scoped key per workflow, and the retries and duplicate detection the platform provided. This is for you if automation platforms call your vendors.
Short answer
To bring Zapier or n8n calls under your outbound policy, move each workflow to where the policy
runs. Self-host n8n inside your network with HTTP_PROXY pointing at your egress proxy, or rewrite
it as a small service that calls through the proxy. Where Zapier or Make stays, register the
platform’s published egress addresses with the vendor and give each workflow a key scoped to its
job. A rewrite loses the platform’s retries and duplicate detection, so send an Idempotency-Key.
You will need
Node 22 or later, an egress proxy your services already use, and a vendor that issues scoped keys. The proxy here is the one from routing outbound API calls through an egress proxy, reduced to a method rule per vendor host. Verified 2026-10-21 against Node 22.22.0. Zapier, Make, and n8n did not run. The workflow engine is a stand-in that skips a repeated trigger and calls from wherever it runs, and the vendor and the proxy are loopback stand-ins.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A service you write yourself | A workflow of a few steps whose calls must obey the same allowlist, budget, and audit trail as your services | Retries, idempotency, and trigger handling become your code, and every change to the workflow waits for a developer | The people who change the workflow every week are not developers |
| Make | Workflows that stay hosted, calling a vendor that can restrict a key to source addresses | One published list of egress addresses per zone, shared by every customer in it, so the vendor admits Make rather than your workflow | The calls must appear in your egress log or count against your budget |
| Self-hosted n8n | A visual workflow builder that has to run inside your network, behind the same proxy as everything else | A stateful service to run, patch, back up, and scale, holding every workflow’s credentials in its own database | Nobody on the team will own one more stateful service |
| Zapier | Hosted workflows built outside engineering, where the speed of a change matters more than control | Calls leave from a broad AWS range unless the integration has static addresses, and bypass your proxy, your budget, and your log | The vendor or your auditors need to see each call |
Self-hosted n8n keeps the visual builder and puts every call through your proxy, at the price of a stateful service with credentials in its database. A rewritten service puts the call under the same review as any other code, and takes on the retries and duplicate detection the platform gave away. Zapier and Make stay the fastest to change, but an allowlist of their addresses admits every customer of the platform.
Run the workflow where your proxy can see it
Your egress log is the test of whether a workflow is under policy. The demo runs one workflow,
triggered twice by the same order, on a hosted platform and on a self-hosted n8n with
HTTP_PROXY set. The engine stand-in does the two things that matter here:
/**
* A stand-in for an automation platform's workflow engine, reduced to the two facts this page
* depends on. It skips a trigger it has already processed, as Zapier's polling triggers and n8n's
* Remove Duplicates node do. And it calls the vendor from wherever it runs: hosted, from the
* platform's network with no proxy in the path; self-hosted, as n8n can be, through the
* HTTP_PROXY you set.
*/
export async function runWorkflow({ triggers, key, vendorUrl, proxy = null }) {
const seen = new Set()
const outcome = []
for (const trigger of triggers) {
if (seen.has(trigger.id)) {
outcome.push(`${trigger.id}: duplicate trigger, skipped by the platform`)
continue
}
seen.add(trigger.id)
const res = await send(`${vendorUrl}/invoices`, { method: 'POST', proxy, headers: { 'x-api-key': key }, body: trigger })
outcome.push(`${trigger.id}: ${res.status} ${res.body.error ?? res.body.id}`)
}
return outcome
}
node demo.mjs
order o_1001, delivered twice
hosted platform, connection holding the admin key
o_1001: 201 inv_1
o_1001: duplicate trigger, skipped by the platform
your egress log: nothing
billco: 1 call with the admin key, 1 invoice
self-hosted n8n in your network, HTTP_PROXY set, workflow key
o_1001: 201 inv_1
o_1001: duplicate trigger, skipped by the platform
your egress log: 1 line, allowed POST 127.0.0.1:39979/invoices
billco: 1 call with the workflow key, 1 invoice
The hosted run created the invoice with the admin key and left your egress log empty. The
self-hosted run did the same work through your proxy, under its method rule, with the workflow key,
and left one line. n8n takes its proxy from HTTP_PROXY, HTTPS_PROXY, and ALL_PROXY, as its
deployment variables
document, so the move is configuration rather than workflow edits. Both runs skipped the second
delivery of the order, which is the platform doing its job.
Scope what stays hosted
Where Zapier or Make stays, shrink what a pasted key can do, since nothing you run will see the call. If the vendor can restrict a key to source addresses, register the platform’s published egress addresses. Make lists three per zone, and Zapier offers fixed ranges for an integration whose developers turned them on.1 Then give each workflow its own key, scoped to its one job, the way Stripe’s restricted keys are built:2
/** The two keys BillCo issues: the admin key its dashboard shows first, and one scoped to a workflow. */
export const KEYS = {
sk_admin_billco: { label: 'admin key', scopes: ['*'] },
sk_wf_invoices: { label: 'workflow key', scopes: ['invoices:write'] },
}
The workflow key can create invoices and do nothing else. A copy that leaks from a workflow export or a shared screen can only create invoices, and the vendor’s audit log names the key that did. Neither control puts the call in your egress log or under your budget, because the traffic never reaches your network.
Rebuild what the platform did for you
A rewrite puts the call under your proxy and gives up two things the platform supplied without being asked. Zapier skips a polling trigger’s items by their unique id and can replay failed runs. n8n has a Remove Duplicates node and a Retry On Fail setting, and Make keeps incomplete executions to retry. The service has to do both, and its retry is safe only with a key the vendor can match:
/**
* The workflow rewritten as a service of your own: create the invoice for an order at BillCo,
* through your egress proxy, with the key scoped to this workflow. Two things the platform did for
* you are written down here. The Idempotency-Key comes from the order id, so a second delivery of
* the same order, or a retry after a lost response, gets the first invoice back instead of a new
* one. The loop retries a 5xx, a dropped connection, or a response that has not come within
* `timeoutMs`, with that same key, backing off between attempts, and stops at the cap.
*/
export async function createInvoice(order, { vendorUrl, egress, key, attempts = 3, delayMs = 200, timeoutMs = 10_000 }) {
// Without an id every order would share the key order-undefined and get the first invoice back.
if (order.id == null || order.id === '') throw new TypeError('createInvoice needs the order id for its Idempotency-Key')
const headers = { 'x-api-key': key, 'idempotency-key': `order-${order.id}` }
let last
for (let i = 1; i <= attempts; i++) {
try {
const res = await send(`${vendorUrl}/invoices`, { method: 'POST', proxy: egress, headers, body: order, timeoutMs })
if (res.status < 500) return { ...res, attempts: i }
last = res
} catch (err) {
last = err // no response, or the connection dropped: the invoice may exist, so retry with the same key
}
if (i < attempts) await new Promise((resolve) => setTimeout(resolve, delayMs * 2 ** (i - 1)))
}
if (last instanceof Error) throw last
return { ...last, attempts }
}
Each attempt gets timeoutMs to answer, so a response that never comes counts as lost and is
retried rather than holding the service forever. The key comes from the order, not from the
attempt, so a retry after a lost response and a second delivery of the same order both carry it.
The last two runs of the demo show both:
node demo.mjs
your own service through the proxy, workflow key, Idempotency-Key per order
o_1001: 201 inv_1 after 1 attempt
o_1001: 200 inv_1 replayed after 1 attempt
your egress log: 2 lines, allowed POST 127.0.0.1:33783/invoices
billco: 2 calls with the workflow key, 1 invoice
the same service when billco commits the first invoice and the response is lost
o_1001: 200 inv_1 replayed after 2 attempts
o_1001: 200 inv_1 replayed after 1 attempt
your egress log: 3 lines, allowed POST 127.0.0.1:39741/invoices
billco: 3 calls with the workflow key, 1 invoice
The second delivery came back as the first invoice, marked replayed. In the last run the vendor
committed the invoice and lost the response, so the service retried with the same key and got that
invoice back. Three calls for one order left one invoice.
Check it worked
Nine tests pin the page’s claims. A hosted run leaves your egress log empty, and a self-hosted run
leaves a line. The platform and the service each turn a repeated order into one invoice. The
workflow key cannot delete a customer, and the proxy refuses a DELETE whatever key it carries.
Two lost responses in a row still leave one invoice, and so does a response that never comes. An
order with no id is refused, since its key would match every other order without one. The last two
hold the stand-ins to the names they were given. A key or a host named constructor is unknown, a
request in origin form is refused, and Host goes upstream as the host the rule checked.
node --test policy.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# pass 9 is the result to expect. A failure in the fifth test means the retry created a second
invoice, so the key is missing or changes with each attempt. A failure in the sixth means a call
waited on a response that never came.
When it goes wrong
A key pasted into a hosted connection does whatever the vendor allows that key, from an address
your proxy never sees. The run below sends the same DELETE with the admin key and with the
workflow key:
node pitfall.mjs
the connection key leaks, and somebody sends DELETE /customers/c_7 with it
sk_admin_billco 200 customer c_7 deleted
sk_wf_invoices 403 key lacks scope customers:delete
your egress log: nothing, the call never crossed your network
through your proxy, with the admin key: 403 DELETE is not allowed for billco
The admin key deleted the customer, and the vendor refused the workflow key. Through your proxy, the method rule refused even the admin key. Take the admin key out of every hosted connection, rotate it, and issue a scoped key per workflow.
The rewrite creates a second invoice. A retry without an Idempotency-Key is a second request as
far as the vendor can tell:
node pitfall.mjs
the rewrite without an Idempotency-Key, first response lost
attempt 1: 503 upstream timeout
attempt 2: 201 inv_2
invoices billco holds for order o_1001: 2
Derive the key from the trigger’s own id, as createInvoice does, so a retry and a redelivery both
match the first invoice.
A self-hosted workflow goes around the proxy. The n8n HTTP Request node takes a proxy of its own,
which takes precedence over
the environment variables, and n8n reads http_proxy before HTTP_PROXY when both are set.3
A host listed in NO_PROXY goes direct as well. Let only the egress proxy out of the network n8n
runs in, so a node configured to go elsewhere fails rather than leaving.
When not to do this
Do not move a workflow that calls no vendor you police. A Zap that posts a summary to a team chat gains nothing from your proxy, and moving it costs a service somebody has to run.
Do not self-host n8n to save the subscription. The cost moves to your on-call rotation, along with a database of credentials to protect.
Do not rewrite a workflow that changes every week. A service turns each change into a pull request, which is right when the calls move money and wrong when they send a newsletter.
Do not register a platform’s egress addresses and call the vendor locked down. The list admits every customer of that platform, so the key’s scope is still the only limit on a leaked copy.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed. Zapier, Make, and n8n did not run: the workflow engine is a stand-in that skips a repeated trigger and calls from wherever it runs. The platforms’ features are read from their documentation.
Footnotes
-
Zapier’s static ranges are two /28 blocks, one in
us-east-1and one inus-west-2, 32 addresses between them. They carry requests only from paid Zapier accounts, so an allowlist built from them admits every paying customer whose Zap uses that integration. Zapier’s allowlisting article suggests allowing requests that carryUser-Agent: Zapierinstead, a header any HTTP client can be configured to send. ↩︎ Back to text -
Stripe’s restricted key guide asks you to configure each key in line with the principle of least privilege, holding the minimum permissions its job needs. Jerome Saltzer and Michael Schroeder set the principle down in a paper whose manuscript was received in October 1974. Their version gives the reason this page cares about: when a privilege is misused, fewer programs have to be audited. A hosted connection holding the admin key is one more program on that list, in somebody else’s data center. ↩︎ Back to text
-
n8n reads its proxy variables through the
proxy-from-envpackage, and its documentation notes that the lowercase names take precedence when both forms are set. curl acceptshttp_proxyin lowercase only, because CGI hands a script each request header as an uppercase variable prefixedHTTP_. A header namedProxywould arrive asHTTP_PROXY. A platform team that sets only the uppercase name is relying on nobody having set the other. ↩︎ Back to text