Blog · · 6 min read

Sending Webhooks from a Static IP: NAT Gateways, Proxies and Relays

Send webhooks from fixed IPs for customers with allowlists: NAT gateway, proxy and relay setups, their costs, SSRF checks, and why to keep signatures.

Key points

  • Serverless and PaaS outbound IPs change with deploys and scaling, so customers who allowlist by IP need you to send from fixed addresses.
  • The three self-built options are a NAT gateway in a VPC, a forward proxy, and a relay service; each needs SSRF checks on the destination.
  • An IP allowlist doesn't replace signatures. Publish your IPs together with how to verify the signature.

Sooner or later a customer's security team asks: "Which IP addresses will your webhooks come from? We need to allowlist them." If your app runs on serverless functions, containers or a PaaS, the honest answer is "it changes," because outbound IPs move with deploys and scaling. This post covers three ways to send from fixed addresses, what each costs, and how IP allowlisting fits alongside signatures.

Why customers ask for it

Some companies filter inbound traffic by source IP as a matter of policy, especially for systems inside a corporate network. Telling them "we sign every request, so you don't need an allowlist" often doesn't get past the firewall team.

Providers have taken different positions. Stripe publishes 15 webhook source IPs, with text and JSON lists, and announces changes 7 days in advance on a mailing list (Stripe). Svix publishes static source IPs per region and commits to not changing them for existing customers, but only on its Professional and Enterprise tiers (Svix). KOMOJU lists its source IPs but notes they can change without notice and recommends verifying signatures instead of filtering by IP (KOMOJU). For a fuller table of what each provider publishes, see Webhook IP allowlisting.

Option 1: NAT gateway

Run the sending workers in a private subnet and route outbound traffic through a NAT gateway with an Elastic IP. Every request leaves from that address.

AWS pricing in us-east-1, checked on 2026-09-27:

Item Price Per month (730 h)
NAT gateway $0.045 / hour ~$32.85
NAT data processing $0.045 / GB depends on traffic
Public IPv4 address $0.005 / hour ~$3.65

Two gateways for redundancy, each with its own IP, come to about $73 a month before data processing and transfer. Prices vary by region.

Sources: Amazon VPC pricing, AWS Price List API

This fits if your workers already run in a VPC. It doesn't help if you send from Vercel, Cloudflare Workers or another platform outside your VPC.

Option 2: forward proxy

Run an HTTP CONNECT proxy on a host with a static IP and route only webhook traffic through it. On the app side it's one setting on the HTTP client.

import { ProxyAgent, fetch } from 'undici';

const proxy = new ProxyAgent(process.env.WEBHOOK_PROXY_URL!); // e.g. http://user:pass@proxy.internal:4750

await fetch(endpointUrl, {
  method: 'POST',
  headers,
  body,
  dispatcher: proxy,
  redirect: 'manual',
  signal: AbortSignal.timeout(15_000),
});

Stripe's open-source Smokescreen is a common choice. Convoy documents exactly this setup with mole, its wrapper around Smokescreen, and suggests putting several proxies behind a load balancer to scale (Convoy).

You pay for the hosts and the IPs, and you own redundancy, patching, and the SSRF checks described below.

Option 3: relay service

Your app asks a relay "send this body to this URL," and the relay sends it from its static IP and returns the result. Unlike a forward proxy, the relay understands the request, so it can validate destinations, enforce timeouts and record responses itself.

Host the relay somewhere that gives you a static source IP: a VM with a reserved IP, or a platform that offers static egress IPs. Check that the address stays the same across redeploys and machine replacement, that you get an IPv4 address, and that you can run two or more instances for redundancy. You pay for the IPs and the machines.

A minimal relay authenticates your app with a shared-secret HMAC and rejects stale requests:

import { createHmac, timingSafeEqual } from 'node:crypto';
import http from 'node:http';

const SECRET = process.env.APP_SHARED_SECRET!;

http.createServer(async (req, res) => {
  const ts = String(req.headers['x-app-timestamp'] ?? '');
  const sig = String(req.headers['x-app-signature'] ?? '');
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.writeHead(401).end();

  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(c as Buffer);
  const raw = Buffer.concat(chunks).toString('utf8');

  const expected = createHmac('sha256', SECRET).update(`${ts}.${raw}`).digest('base64');
  if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.writeHead(401).end();
  }

  const { url, headers, body } = JSON.parse(raw);
  // validate the destination here (next section), then send
  const r = await fetch(url, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ status: r.status }));
}).listen(8080);

SSRF checks you can't skip

A static-IP sender is, by design, trusted by your customers' firewalls. It also sends to URLs your users type in. If someone registers a URL that resolves to an internal address, that trusted box will happily POST into your own network or your cloud's metadata service.

Put these checks in the proxy or relay:

  • At registration: HTTPS only, standard ports only, no raw IP addresses in the URL, no internal hostnames such as .internal or .local.
  • At send time: resolve the hostname and refuse if any returned address is private (10/8, 172.16/12, 192.168/16), loopback, link-local (169.254/16, which includes cloud metadata endpoints), CGNAT (100.64/10), or an IPv6 ULA or link-local address.
  • Connect to the IP you just checked. Resolving again lets a DNS rebinding attack return a safe address to the check and an internal one to the connection. Keep SNI and certificate validation on the original hostname.
  • Don't follow redirects.

Pair the allowlist with signatures

An IP allowlist narrows which network paths can reach the endpoint, but it says nothing about who sent the request or whether the body was changed. Receivers should still verify the signature on every delivery (why allowlisting doesn't replace signatures).

Send customers the signature details together with the IP list. If you sign with Standard Webhooks, they can verify with an existing library in their language.

Publishing your IPs is fine

Source IP addresses aren't secrets. An HTTPS request needs a completed TCP handshake before any data flows, so an attacker can't spoof your source address and get a full request through. Knowing your IPs doesn't let anyone impersonate you. That's why Stripe publishes its list.

When you publish:

  • Put the list in two places: your docs and your dashboard. A machine-readable JSON file lets customers automate firewall updates.
  • Announce changes in advance: Stripe gives 7 days.
  • Ask customers to allow every address from day one: if you run more than one sender for redundancy, a customer who allowed only one will see failures when traffic moves.

How Webhook Admin does it

In Webhook Admin, sending from a static IP is a per-endpoint setting, available from the Starter plan ($20/mo).

  • Deliveries to that endpoint come from one IPv4 address, 209.71.107.233, which is also shown in the dashboard and the docs.
  • Right before each send, the hostname is resolved again and every returned address is checked; if any of them is internal, nothing is sent. Redirects are not followed. Because the source is IPv4, the destination needs an A record.
  • Signing (Standard Webhooks), retries and delivery logs work the same as for other endpoints. Each attempt in the log shows the IP it was sent from.

Create an endpoint with fixed_ip set:

curl -X POST https://api.webhookadmin.com/v1/endpoints \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"consumer_id":"con_...","url":"https://partner.example.com/webhooks","fixed_ip":true}'

Which setup to pick

Setup Rough monthly cost Good fit when
NAT gateway (AWS us-east-1, two AZs) ~$73 + data Your senders already run in a VPC
Forward proxy (self-hosted) hosts + IPs You want to add a proxy to an existing HTTP client and keep everything else
Relay (hosting with static egress IPs) IPs + machines You send from serverless and want destination checks and logging in one place
A delivery service's static IP depends on the plan You want redundancy and SSRF protection handled for you

If a customer's firewall is the only reason you're about to build and run a relay, Webhook Admin gives you a static source IP from the Starter plan ($20/mo): https://app.webhookadmin.com/signup

FAQ

Is it safe to publish my webhook source IPs?

Yes. Source IPs aren't secrets, and an attacker can't complete a TCP handshake from a spoofed address, so knowing the IPs doesn't let anyone impersonate you.

Do I still need signatures if the customer allowlists my IP?

Yes. An IP match only shows which network the request came from. The signature shows who sent it and that the body wasn't changed.

Why does the destination need an IPv4 address?

If you send from a single static IPv4 address, the connection has to go over IPv4, so a destination with only an AAAA record can't be reached.

Related posts