Test Webhooks Locally with Cloudflare Tunnels

by Fahim

You want to test a webhook from Stripe or GitHub, but your local server is hidden behind a NAT. Today we are going to expose localhost to the public internet using Cloudflare Tunnels without paying a cent or dealing with random Ngrok URL changes.

Terminal screen running a Cloudflare Tunnel routing webhook requests to a local Express server.
Terminal screen running a Cloudflare Tunnel routing webhook requests to a local Express server.

The Ngrok Problem and Why Cloudflare Tunnels Win

For years, Ngrok was the default tool for local webhook debugging. But their free tier has become increasingly restrictive, forcing random domain names on every restart unless you pay up. If you are building complex integrations, updating your webhook endpoint in three different developer dashboards every time your terminal crashes is a massive waste of time.

Cloudflare Tunnels (part of their Cloudflare One suite) solved this for me. They let you run a small daemon on your machine that establishes an outbound-only connection to Cloudflare’s edge. You get a stable, secure, free public URL that routes directly to your local port. Best of all, if you own a domain on Cloudflare, you can assign a permanent subdomain (like dev-webhook.yourdomain.com) to your local machine for zero cost.

When planning your setup, it is worth reviewing a broader API integrations and webhooks guide to understand how these local tunnels fit into your overall development architecture.

Spin Up a Local Webhook Listener

Before we route traffic from the outside world, we need a local server running to receive it. I wrote a quick Node.js app using the Express.js framework to log incoming webhook payloads.

Create a new directory, initialize your project, and install Express:

mkdir local-webhook-tester
cd local-webhook-tester
npm init -y
npm install express

Next, create an index.js file. This script sets up a basic POST endpoint and logs the headers and body of any incoming request so we can verify our tunnel is working.

const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000; app.use(express.json()); app.post('/webhook', (req, res) => { console.log('--- RECEIVED WEBHOOK ---'); console.log('Headers:', JSON.stringify(req.headers, null, 2)); console.log('Payload:', JSON.stringify(req.body, null, 2)); res.status(200).json({ received: true });
}); app.listen(PORT, () => { console.log(`Local webhook listener running on port ${PORT}`);
});

Start your server with node index.js. It will sit there waiting on port 3000. If you are building a production-grade listener later, you might want to look at how to build a secure GoHighLevel webhook listener to handle real-world security concerns.

Install Cloudflared on Your Machine

To bridge the gap between Cloudflare and your local machine, you need the cloudflared CLI tool. This daemon handles the secure connection tunnels.

Depending on your operating system, install the binary. On macOS, Homebrew makes this incredibly simple:

brew install cloudflare/cloudflare/cloudflared

If you are running Ubuntu or Debian, run these commands to add the repository and install the package:

curl -L https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflare-main.gpg $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflare.list
sudo apt-get update && sudo apt-get install cloudflared

For Windows or other distributions, grab the latest executable directly from the cloudflared download page and add it to your system PATH.

Route Local Traffic Through the Tunnel

If you just want a quick, throwaway tunnel without configuring domains, Cloudflare offers free, anonymous tunnels. These are perfect for quick testing sessions.

Run this command in a new terminal window:

cloudflared tunnel --url http://localhost:3000

The console output will spin up, establish connections to several Cloudflare edge servers, and print a unique URL ending in .trycloudflare.com. It looks like this:

+------------------------------------------------------------+
| Your quick tunnel has been created! Visit it at: |
| https://some-random-words.trycloudflare.com |
+------------------------------------------------------------+

Copy that URL, append /webhook to the end, and paste it into your browser or use curl to send a test payload. You will see the request log instantly in your Node.js console with a response latency of around 120ms.

Set Up a Persistent Custom Domain

Anonymous tunnels are great, but their URLs change every time you restart the command. To get a permanent URL for your local environment, you need a free Cloudflare account and a domain pointed to their nameservers. Here is how I set up my persistent development tunnel.

First, authenticate the CLI tool with your Cloudflare account:

cloudflared tunnel login

This command opens a browser window. Select your domain, click authorize, and the CLI will download a credentials file (usually saved in ~/.cloudflared/cert.pem).

Next, create a named tunnel. I will call mine local-dev:

cloudflared tunnel create local-dev

This command generates a tunnel ID and a JSON credentials file. Now we need to configure it. Create a file named config.yml in your ~/.cloudflared/ directory (or your project root) and add the following configuration:

tunnel: YOUR_TUNNEL_UUID
credentials-file: /Users/yourusername/.cloudflared/YOUR_TUNNEL_UUID.json ingress: - hostname: dev-webhook.yourdomain.com service: http://localhost:3000 - service: http_status:404

Now, associate your custom subdomain with the tunnel by creating a DNS routing rule:

cloudflared tunnel route dns local-dev dev-webhook.yourdomain.com

Finally, start your persistent tunnel using the configuration file:

cloudflared tunnel run local-dev

Now, any traffic sent to https://dev-webhook.yourdomain.com/webhook will route straight to your local port 3000. You can stop and start this tunnel as much as you want; the domain name will never change.

The Gotcha: Express Body Parsers and Webhook Signatures

When I first moved my webhook integrations over, my webhook signature verification failed. I spent two hours debugging why Stripe kept throwing 400 Bad Request errors even though the payloads matched perfectly.

The issue was how Express handles body parsing. Most webhook providers require the raw, unparsed request body string to verify cryptographic signatures. If you parse the JSON before verifying, the formatting changes slightly, causing the signature match to fail.

To fix this, use a custom verifier function in your Express JSON middleware to preserve the raw body buffer on the request object:

const express = require('express');
const app = express(); app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); }
})); app.post('/webhook', (req, res) => { // Access req.rawBody here to verify signatures console.log('Raw body preserved:', req.rawBody); res.sendStatus(200);
});

This simple fix keeps both the parsed object in req.body and the exact raw payload in req.rawBody, allowing signature verification to pass without issues. If your webhook receiver drops requests due to temporary network issues, you may also want to implement a webhook retry system with backoff to handle delivery failures gracefully.

Frequently Asked Questions

Is a Cloudflare Tunnel secure?

Yes. Because the cloudflared daemon establishes an outbound connection to Cloudflare’s network, you do not need to open any incoming ports on your home router or configure port forwarding. External traffic is filtered through Cloudflare’s security layer before reaching your machine.

Do I need a paid Cloudflare account to use tunnels?

No. Cloudflare Tunnels are completely free for personal and commercial developer use. You only need a free Cloudflare account and a registered domain if you want to use custom subdomains.

Can I route multiple local ports through a single tunnel?

Yes. You can configure multiple ingress rules inside your config.yml file to route different subdomains to different local ports (for example, routing api.yourdomain.com to port 4000 and web.yourdomain.com to port 3000).

What happens if my connection drops?

The cloudflared daemon automatically attempts to reconnect to the nearest Cloudflare edge server if your internet connection drops. Once reconnected, your public URL resumes routing traffic immediately.

Next Steps

Now that you have a stable local webhook testing environment, you can start building more complex architectures. If you are dealing with high volumes of incoming data, check out how to route webhooks to multiple endpoints to manage your development workflows efficiently.

all_in_one_marketing_tool