← Back to blog

How to test a WordPress webhook when the sender needs a public URL

The SandyWP team 6 min read

A webhook sender needs a public HTTPS URL, and your local WordPress does not have one. You can either tunnel to your machine, or put the site somewhere that already has a public URL.

Every guide on this covers the first option and stops there. The part that eats the afternoon is not reachability, it is that a tunnel gives your site a second address while WordPress still believes it lives at http://localhost:8080.

Two separate problems, and only one of them is the tunnel's job

Reachability. The sender has to open a connection to your site. Nothing on your laptop is routable from Stripe's network.

Identity. WordPress stores its own address in the database, in the home and siteurl options. Almost everything WordPress generates is built from that value.

A tunnel solves reachability. It does nothing about identity, and identity is what produces the symptoms people write support tickets about.

What actually goes wrong once the tunnel is up

You start ngrok http 8080, get https://a1b2c3.ngrok.app, register it as your webhook endpoint, and fire a test event. Then one of these happens.

A 301 redirect instead of your handler. WordPress canonicalises the request to its stored siteurl, so the POST is redirected to http://localhost:8080/.... Most senders do not follow redirects on a POST, and the ones that do drop the body. The delivery log shows a 301 and you see nothing in your code.

The sender rejects the URL for being HTTP. The tunnel terminates TLS and forwards plain HTTP, so WordPress builds http:// URLs and reports itself as insecure unless it is told to trust the proxy headers.

A new URL every restart. The free tunnel hands you a different subdomain each session, so the endpoint you registered yesterday is dead. If the provider requires re-verification of the callback URL, that is a fresh round trip every morning.

The usual fix is to stop WordPress deriving its own address, by pinning it in wp-config.php above the require_once line:

define( 'WP_HOME', 'https://a1b2c3.ngrok.app' );
define( 'WP_SITEURL', 'https://a1b2c3.ngrok.app' );
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
	$_SERVER['HTTPS'] = 'on';
}

That works. It also means editing wp-config.php every time the tunnel URL changes, and it will bite you later when you forget it is there.

To be fair to the tunnels: for watching raw traffic they are better than anything WordPress gives you. ngrok's traffic inspector shows every request with headers and body, and lets you replay a failed delivery without asking the provider to retry. Nothing described below replaces that.

The other route: give the site a public URL from the start

If the site is already served from a real HTTPS address, both problems disappear at once. There is no second identity, because there is only one address.

That is what a disposable sandbox is useful for here. A SandyWP sandbox comes up on its own public HTTPS URL, and WP_HOME and WP_SITEURL are set to that URL when the site is built, so nothing is pointing at localhost to begin with.

It matters most when you clone a live site to test against real data. The clone rewrites the stored siteurl and home to the sandbox address as part of the import, so the copy does not spend its first request redirecting you to production.

The honest tradeoff: your handler code now lives on a remote site rather than in your editor's working tree, so you need a way to get changes there. SSH and SFTP or a mounted folder covers that, and so does pushing a branch if you connect a repository.

Seeing what arrived, because WordPress will not tell you

WordPress does not log incoming requests. If your handler does not run, or runs and throws, the default install is silent about all of it.

Log the raw request from inside the handler before you do anything else with it:

add_action( 'rest_api_init', function () {
	register_rest_route( 'myplugin/v1', '/hook', [
		'methods'             => 'POST',
		'permission_callback' => '__return_true',
		'callback'            => function ( WP_REST_Request $req ) {
			error_log( 'hook headers: ' . wp_json_encode( $req->get_headers() ) );
			error_log( 'hook body: ' . $req->get_body() );
			return new WP_REST_Response( [ 'ok' => true ], 200 );
		},
	] );
} );

error_log() goes nowhere useful unless WP_DEBUG_LOG is on. Turn it on with debug mode, which flips WP_DEBUG and WP_DEBUG_LOG per site and puts a wp-content/debug.log viewer next to the toggles.

Leave WP_DEBUG_DISPLAY off while you do this. Errors printed into the response body will be read by the sender as a malformed reply, and some providers will disable an endpoint that keeps returning junk.

Over SSH the same file is a tail:

sandywp ssh my-site --cmd "tail -f wp-content/debug.log"

One ordering tip that saves an hour. Point the provider at a throwaway URL from a service like webhook.site first, confirm the payload shape and headers, and only then point it at WordPress. That splits "the provider is not sending what I expected" from "my handler is wrong", and those two bugs look identical from inside WordPress.

Signature verification is where most handlers break

Providers sign the request over the exact bytes they sent. Read those bytes, not a parsed copy.

$raw = $req->get_body(); // not $_POST, not json_decode then re-encode

$_POST is empty for a JSON body, and re-encoding an array changes key order, spacing and unicode escaping, so the computed signature will not match no matter how correct your algorithm is.

Compare with hash_equals(), and check the timestamp the provider sends so a captured request cannot be replayed at you later.

Test the failure path deliberately. Send one request with a corrupted signature and confirm you return a 4xx, because a handler that accepts everything passes every happy-path test you will ever write.

Test what your site sends back out, too

A lot of webhook work is not just receiving. The event arrives, and the site emails someone.

On a sandbox with no SMTP configured, wp_mail() is intercepted and recorded rather than delivered, and the rendered message is readable in the email log. So you can fire a payment.succeeded event at a copy of your live site and confirm the receipt was composed correctly, without a real customer receiving a receipt for a payment they did not make.

Add your own SMTP plugin and credentials and delivery goes through as normal, still logged. Which is exactly the switch you want to leave alone while testing against production data.

What a sandbox will not show you

It is not a request inspector. There is no traffic pane and no replay button. If you need to see bytes on the wire independently of PHP, use webhook.site or a tunnel's inspector alongside it.

The URL has an expiry. Account sandboxes default to one week and can be set from one hour to one month, so a registered endpoint will stop answering when the site expires. If the integration is long-running, make the sandbox permanent instead of re-registering the URL every week.

No host layer. A sandbox will not reproduce your production WAF, an IP allowlist, a rate limiter, a CDN rule, or the host firewall that is actually blocking the sender in production. If deliveries fail on live and succeed everywhere else, the problem is in that layer and no sandbox will find it for you.

A cloned site carries real data. The clone brings the real user table with it. Treat it as production data, and do not hand the URL around.

The short routine

  1. Create a sandbox, or clone the site the integration runs on.
  2. Turn on debug mode so debug.log exists.
  3. Send the provider's test event to webhook.site once, and read the real payload and headers.
  4. Register the sandbox URL as the endpoint and send the same event.
  5. Watch debug.log for the raw body, and the email log for anything the handler sent.
  6. Send one request with a broken signature and confirm it is rejected.
  7. Delete the sandbox, or let it expire.

None of this is faster than a tunnel for a five-minute check on a plugin you are writing from scratch. It is faster the moment the question is "does this integration work against a copy of the real site", which is usually when it matters.