Using Cloudflare Tunnel for Local WordPress Development and Webhook Testing
When you build WordPress integrations that talk to external services – Stripe, HubSpot, Salesforce, Zapier, Make, whatever – sooner or later you hit the same wall: “I need a public HTTPS URL for my local dev site so I can test webhooks.” You don’t want to deploy to staging for every tiny change. You just

When you build WordPress integrations that talk to external services – Stripe, HubSpot, Salesforce, Zapier, Make, whatever – sooner or later you hit the same wall:
“I need a public HTTPS URL for my local dev site so I can test webhooks.”
You don’t want to deploy to staging for every tiny change. You just want your wp.test or local.samplehq.test to be reachable from the outside world in a safe, temporary way.
This is where Cloudflare Tunnel becomes stupidly useful.
In this article we’ll go through:
- What Cloudflare Tunnel is and why it’s perfect for WordPress webhook work
- How to point a tunnel to your local WP environment
- How to expose custom API endpoints in WordPress for webhooks
- Tricks for manipulating endpoint URLs (pretty URLs, versioning, and multiple tunnels)
- How to add a basic webhook “schedule” using WP Cron so you can replay or poll webhooks
1. What Cloudflare Tunnel Actually Does (In Human Words)
Cloudflare Tunnel lets you:
- Run a tiny daemon (
cloudflared) on your machine or dev server - That daemon opens an outgoing, encrypted connection to Cloudflare
- Cloudflare then gives you a public HTTPS URL (either on your domain, or a random
*.trycloudflare.com) - Any request that hits that URL is tunneled back into whatever you define (your local
localhost:80,localhost:8080, etc.)
Important point:
There are no incoming ports opened on your network. Everything is outbound. That makes it pretty good even if you’re behind NAT, working from home, on hotel Wi-Fi, etc.
For local WordPress development, this means:
- You run
cloudflaredon your dev machine - Your local site (e.g.
http://wp.local:8000) becomes reachable at something likehttps://dev-api.yourdomain.com - You give that URL to Stripe / HubSpot / whatever as the webhook URL
- You code normally on localhost and watch the webhook payloads hit your logs / debugger
2. Pointing Cloudflare Tunnel to Your Local WordPress Site
Step 2.1 – Install cloudflared
On macOS (Homebrew):
brew install cloudflare/cloudflare/cloudflared
On other systems you can download the binary from Cloudflare and add it to your PATH.
Step 2.2 – Log in and create a tunnel
cloudflared tunnel login
This will open a browser window where you pick your Cloudflare account and authorize.
Then:
cloudflared tunnel create wp-dev-tunnel
This creates a tunnel and stores credentials in ~/.cloudflared.
Step 2.3 – Create a config file
In ~/.cloudflared/config.yml:
tunnel: wp-dev-tunnel
credentials-file: /Users/you/.cloudflared/<your-tunnel-id>.json
ingress:
- hostname: dev-api.yourdomain.com
service: http://localhost:8000
- service: http_status:404
Key bits:
hostnameis a subdomain you control in Cloudflare DNSserviceis your local site. If you use something like LocalWP or a custom port, adjust accordingly:http://localhost:10003,http://wp.test, etc.
Step 2.4 – Wire the DNS record
In Cloudflare’s dashboard, under DNS, create a CNAME:
- Name:
dev-api - Target:
your-tunnel-id.cfargotunnel.com - Proxy: Proxied (orange cloud)
Cloudflare usually offers to create this automatically the first time you run the tunnel from the dashboard, but doing it once manually teaches you what’s happening.
Step 2.5 – Run the tunnel
From your terminal:
cloudflared tunnel run wp-dev-tunnel
Now https://dev-api.yourdomain.com should show your local WordPress site.
3. Exposing Custom API Endpoints in WordPress for Webhooks
Now that the world can reach your dev site, you need an endpoint that an external service can POST data to.
You have two general options:
- REST API route (
/wp-json/your-namespace/v1/...) - Custom rewrite-based endpoint (
/webhooks/stripe,/api/hook/...)
Let’s do both.
Option A: WordPress REST API endpoint
In a simple plugin (e.g. wp-content/plugins/dev-webhooks/dev-webhooks.php):
<?php
/**
* Plugin Name: Dev Webhooks Tester
*/
add_action('rest_api_init', function() {
register_rest_route('dev-hooks/v1', '/stripe', [
'methods' => 'POST',
'callback' => 'dev_hooks_handle_stripe',
'permission_callback' => '__return_true', // Important: you handle auth yourself
]);
});
function dev_hooks_handle_stripe(\WP_REST_Request $request) {
$payload = $request->get_json_params();
// Log it for debugging
if (!empty($payload)) {
error_log('[STRIPE WEBHOOK] ' . wp_json_encode($payload));
}
// TODO: verify signature header if needed
// $sig = $request->get_header('stripe-signature');
// Return a standard 200 response
return new \WP_REST_Response([
'status' => 'ok',
'message' => 'Webhook received',
], 200);
}
Your webhook URL becomes:
https://dev-api.yourdomain.com/wp-json/dev-hooks/v1/stripe
Paste that into Stripe / HubSpot / whatever.
This is great for quick iteration because:
- You get automatic JSON parsing
- You can easily send structured responses
- You can reuse WP REST API tools and auth later
Option B: Pretty URL endpoint /webhooks/stripe
Sometimes you want nicer URLs or multiple providers under a single “webhooks router”.
In your plugin:
add_action('init', function() {
add_rewrite_rule(
'^webhooks/([^/]+)/?$',
'index.php?dev_webhook_provider=$matches[1]',
'top'
);
});
add_filter('query_vars', function($vars) {
$vars[] = 'dev_webhook_provider';
return $vars;
});
add_action('template_redirect', function() {
$provider = get_query_var('dev_webhook_provider');
if (!$provider) {
return;
}
// This request is meant for our webhook router
dev_webhooks_router($provider);
exit;
});
function dev_webhooks_router(string $provider) {
// Get raw body
$raw = file_get_contents('php://input');
// Basic routing
switch ($provider) {
case 'stripe':
// Handle Stripe
error_log('[STRIPE WEBHOOK] ' . $raw);
break;
case 'hubspot':
error_log('[HUBSPOT WEBHOOK] ' . $raw);
break;
default:
status_header(404);
echo 'Unknown provider';
return;
}
status_header(200);
header('Content-Type: application/json; charset=utf-8');
echo wp_json_encode(['status' => 'ok', 'provider' => $provider]);
}
Flush permalinks once (Settings → Permalinks → Save) or run:
flush_rewrite_rules();
Now you have URLs like:
https://dev-api.yourdomain.com/webhooks/stripe
https://dev-api.yourdomain.com/webhooks/hubspot
Perfect for configuring multiple services.
4. Neat Tricks for Manipulating Endpoint URLs
Once you mix Cloudflare Tunnel + WordPress routing, you unlock some handy patterns.
Trick 1: Versioned webhook endpoints
You don’t want to break live webhooks when you refactor code. One trick:
add_action('init', function() {
add_rewrite_rule(
'^webhooks/v([0-9]+)/([^/]+)/?$',
'index.php?dev_webhook_version=$matches[1]&dev_webhook_provider=$matches[2]',
'top'
);
});
add_filter('query_vars', function($vars) {
$vars[] = 'dev_webhook_version';
$vars[] = 'dev_webhook_provider';
return $vars;
});
add_action('template_redirect', function() {
$version = get_query_var('dev_webhook_version');
$provider = get_query_var('dev_webhook_provider');
if (!$provider) {
return;
}
dev_webhooks_versioned_router((int) $version, $provider);
exit;
});
Now you can have:
https://dev-api.yourdomain.com/webhooks/v1/stripehttps://dev-api.yourdomain.com/webhooks/v2/stripe
And run them side-by-side during a migration.
Trick 2: Multiple tunnels → multiple contexts
You can run more than one Cloudflare Tunnel config pointing at the same machine but different ports or different vhosts:
dev-api.yourdomain.com→ local port 8000 (WP dev site)dev-sandbox.yourdomain.com→ local port 8001 (a sandbox site with messy experiments)
That lets you test, for example:
- Stable webhook handler on one instance
- Experimental refactor on another instance
And you can point different webhook endpoints at different hosts:
- Stripe test mode →
https://dev-api.yourdomain.com/webhooks/stripe - Stripe live mode (dev-only sandbox) →
https://dev-sandbox.yourdomain.com/webhooks/stripe
Trick 3: Short, pretty “API root” for local dev
You don’t have to expose your whole WordPress frontend.
In a Cloudflare Tunnel config, you can point to a specific internal reverse proxy that only exposes API routes, or to a dedicated PHP front controller. For example, you could map:
ingress:
- hostname: dev-hooks.yourdomain.com
service: http://localhost:9000
- service: http_status:404
And run a tiny PHP front controller on port 9000 that only knows about /webhooks/* URLs. This is overkill for many cases, but helpful when you want to isolate webhook behavior from the rest of your dev stack.
5. Adding a Basic Webhook “Schedule” with WP-Cron
Some webhook flows are push-based (service calls you).
Others are poll-based (you call their API every X minutes).
You can fake a webhook “schedule” by using WordPress cron to:
- Store incoming events in a DB table or option
- Process them periodically
- Or poll external APIs and enqueue “fake” webhook events into your system
Example: queue + scheduled processor
Imagine you:
- Accept raw webhook payloads and store them
- Process them every minute in a controlled way (retries, rate-limiting, etc.)
Minimal example:
// On load, schedule a cron if not exists
add_action('init', function() {
if (!wp_next_scheduled('dev_webhooks_cron_process')) {
wp_schedule_event(time(), 'minute', 'dev_webhooks_cron_process'); // you may need a custom 'minute' schedule
}
});
add_action('dev_webhooks_cron_process', 'dev_webhooks_process_queue');
function dev_webhooks_enqueue_event(string $provider, array $payload) {
$queue = get_option('dev_webhooks_queue', []);
$queue[] = [
'provider' => $provider,
'payload' => $payload,
'time' => time(),
];
update_option('dev_webhooks_queue', $queue, false);
}
function dev_webhooks_process_queue() {
$queue = get_option('dev_webhooks_queue', []);
if (empty($queue)) {
return;
}
// Basic "one at a time" processing
$event = array_shift($queue);
// Do something based on provider
// e.g. update orders, trigger internal actions, etc.
error_log('[WEBHOOK QUEUE] Processing ' . $event['provider']);
update_option('dev_webhooks_queue', $queue, false);
}
In your webhook handler:
function dev_hooks_handle_stripe(\WP_REST_Request $request) {
$payload = $request->get_json_params();
dev_webhooks_enqueue_event('stripe', $payload);
return new \WP_REST_Response(['status' => 'queued'], 200);
}
Is this production-ready queueing? No.
Is it enough for local dev and understanding your flow? Yes.
You can evolve this later into a real custom table with statuses, retries, etc.
6. Workflow Summary: From “Nothing” to “Webhook-Ready Dev”
Here’s the TL;DR of how this fits together in real life:
- Local WP stack running
- MAMP / LocalWP / Docker / Valet – doesn’t matter.
- Cloudflare Tunnel configured
cloudflaredinstalled- Tunnel created:
wp-dev-tunnel dev-api.yourdomain.com→http://localhost:8000
- Custom endpoint implemented in WordPress
- Either a REST route (
/wp-json/dev-hooks/v1/stripe) - Or a pretty route (
/webhooks/stripe,/webhooks/v1/stripe)
- Either a REST route (
- Webhook provider configured
- Paste your tunnel URL into Stripe/HubSpot/Zapier
- Trigger a test payload
- Logs and queue
- Log incoming payloads via
error_log()or a proper logger - Optionally enqueue events and process with WP-Cron
- Log incoming payloads via
- Iterate fast
- Change code locally
- Save, hit “Send test webhook” again
- No staging deploys, no port-forwarding drama
Bojan