Using Cloudflare Tunnel for Local WordPress Development and Webhook Testing
November 25, 2025 • Bojan
November 25, 2025 • Bojan

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:
Cloudflare Tunnel lets you:
cloudflared) on your machine or dev server*.trycloudflare.com)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:
cloudflared on your dev machinehttp://wp.local:8000) becomes reachable at something like https://dev-api.yourdomain.comcloudflaredOn macOS (Homebrew):
brew install cloudflare/cloudflare/cloudflared
On other systems you can download the binary from Cloudflare and add it to your PATH.
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.
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:
hostname is a subdomain you control in Cloudflare DNSservice is your local site. If you use something like LocalWP or a custom port, adjust accordingly: http://localhost:10003, http://wp.test, etc.In Cloudflare’s dashboard, under DNS, create a CNAME:
dev-apiyour-tunnel-id.cfargotunnel.comCloudflare 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.
From your terminal:
cloudflared tunnel run wp-dev-tunnel
Now https://dev-api.yourdomain.com should show your local WordPress site.
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:
/wp-json/your-namespace/v1/...)/webhooks/stripe, /api/hook/...)Let’s do both.
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:
/webhooks/stripeSometimes 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.
Once you mix Cloudflare Tunnel + WordPress routing, you unlock some handy patterns.
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/stripeAnd run them side-by-side during a migration.
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:
And you can point different webhook endpoints at different hosts:
https://dev-api.yourdomain.com/webhooks/stripehttps://dev-sandbox.yourdomain.com/webhooks/stripeYou 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.
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:
Imagine you:
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.
Here’s the TL;DR of how this fits together in real life:
cloudflared installedwp-dev-tunneldev-api.yourdomain.com → http://localhost:8000/wp-json/dev-hooks/v1/stripe)/webhooks/stripe, /webhooks/v1/stripe)error_log() or a proper logger
I’m Bojan Josifoski - I’m a WordPress systems engineer who developed and maintained a proprietary WordPress-based framework used by U.S. financial institutions between 2016 and 2025.