Blog ·

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.

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).

Search suggestions tell the same story from the other side: "stripe webhook ip whitelist," "github webhook ip ranges," "sendgrid webhook ip whitelist." Receivers want a list they can put in a firewall rule.

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. Tokyo (ap-northeast-1) is $0.062 per hour and per GB.

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.

Fly.io supports app-scoped static egress IPs. fly ips allocate-egress --app <app> -r <region> allocates an IPv4/IPv6 pair in a region, and each IPv4 costs $3.60 a month. The addresses survive machine replacement and redeploys (Fly.io docs).

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.RELAY_SECRET!;

http.createServer(async (req, res) => {
  const ts = String(req.headers['x-relay-timestamp'] ?? '');
  const sig = String(req.headers['x-relay-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. It says nothing about who sent the request or whether the body was changed. Shared proxies and NAT services put many tenants behind the same addresses. Receivers should filter by IP and still verify the signature on every delivery; Stripe's docs recommend using both.

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.
  • Use at least two IPs: so one failed host doesn't stop delivery, and ask customers to allow all of them from day one.

How Webhook Admin does it

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

  • Static-IP deliveries are sent from one fixed IPv4 address, listed in the dashboard and the docs.
  • Just before sending, the hostname is resolved 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 IPv4 address (an A record).
  • Signing (Standard Webhooks), retries and delivery logs work the same as for direct sends. Each attempt in the log records which 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 (Fly.io or similar) $3.60 per IPv4 + machines You send from serverless and want destination checks and logging in one place
A delivery service's static IPs 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