Bojan Josifoski < wp developer />

Using Cloudflare Tunnel for Local WordPress Development and Webhook Testing

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:

  1. What Cloudflare Tunnel is and why it’s perfect for WordPress webhook work
  2. How to point a tunnel to your local WP environment
  3. How to expose custom API endpoints in WordPress for webhooks
  4. Tricks for manipulating endpoint URLs (pretty URLs, versioning, and multiple tunnels)
  5. 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:

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:


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:

Step 2.4 – Wire the DNS record

In Cloudflare’s dashboard, under DNS, create a CNAME:

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:

  1. REST API route (/wp-json/your-namespace/v1/...)
  2. 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:

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:

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:

That lets you test, for example:

And you can point different webhook endpoints at different hosts:

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:

Example: queue + scheduled processor

Imagine you:

  1. Accept raw webhook payloads and store them
  2. 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:

  1. Local WP stack running
    • MAMP / LocalWP / Docker / Valet – doesn’t matter.
  2. Cloudflare Tunnel configured
    • cloudflared installed
    • Tunnel created: wp-dev-tunnel
    • dev-api.yourdomain.comhttp://localhost:8000
  3. Custom endpoint implemented in WordPress
    • Either a REST route (/wp-json/dev-hooks/v1/stripe)
    • Or a pretty route (/webhooks/stripe, /webhooks/v1/stripe)
  4. Webhook provider configured
    • Paste your tunnel URL into Stripe/HubSpot/Zapier
    • Trigger a test payload
  5. Logs and queue
    • Log incoming payloads via error_log() or a proper logger
    • Optionally enqueue events and process with WP-Cron
  6. Iterate fast
    • Change code locally
    • Save, hit “Send test webhook” again
    • No staging deploys, no port-forwarding drama
About the Author

About the Author

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.

← Back to Blog