21 min read

How to Test Webhooks Locally and Expose Your Development Server to the Internet

Every developer building a Stripe integration, a GitHub automation, or any service that receives webhooks hits the same wall: your app is running on localhost, but the payment processor, the code hosting platform, or the SaaS tool sending the webhook needs a public HTTPS URL to deliver it to.

Developer Tools · Webhooks · Local Development · Stripe · GitHub · 2026

How to Test Webhooks Locally and Expose Your Development Server to the Internet

Every developer building a Stripe integration, a GitHub automation, or any service that receives webhooks hits the same wall: your app is running on localhost, but the payment processor, the code hosting platform, or the SaaS tool sending the webhook needs a public HTTPS URL to deliver it to. You cannot give Stripe your localhost:3000. This guide explains why that problem exists, the different approaches to solving it, and how to use Localtonet to get a stable public URL for your local development server, inspect every incoming request in real time, and never lose a webhook payload while debugging.

💳 Stripe · GitHub · Shopify · Discord 🔍 Real-time Request Inspector 🌐 Stable HTTPS URL for localhost ⚡ Node.js · Python · PHP examples

Why You Cannot Test Webhooks on Localhost

A webhook is an HTTP POST request that a service sends to your server when something happens. When a customer completes a payment on Stripe, Stripe sends a POST to your /webhook endpoint. When someone pushes to a GitHub repository, GitHub sends a POST to your configured URL. When a Shopify order is placed, Shopify hits your endpoint with the order data.

In production, this works because your server has a public IP address and a domain name. Stripe can reach https://yourdomain.com/webhook. During development, your server is running on your laptop at http://localhost:3000. Stripe cannot reach that. Neither can GitHub, Shopify, or any external service. Your machine is behind a home or office router that uses NAT — it has a private IP address that is not routable from the public internet.

The development loop without a tunnel

Without a way to receive webhooks locally, most developers end up in one of two painful situations. The first is deploying to a staging server every time they make a change to their webhook handler, waiting for the deploy, triggering an event, reading the logs, going back to the code, and repeating. The second is mocking the webhook payload with a static JSON file and testing against that, which works until the real payload from the provider turns out to be slightly different from what you expected. A tunnel solves both: your production-like webhook traffic goes directly to your local server as you write the code.

The solution is a tunnel: a service that gives your local server a public HTTPS URL and forwards all traffic it receives to your machine. Setting one up takes under two minutes and the payoff is immediate.

The Problem with Random URLs

The first tool most developers reach for is ngrok. It works, it is well-documented, and the free tier is functional enough to get started. But once you have been using it for a day or two, the biggest frustration appears: every time you restart the ngrok process, the URL changes.

This creates a tedious cycle:

1

Start ngrok, get a new random URL

Something like https://a4b2c9d1.ngrok-free.app. Different every time.

2

Open the Stripe dashboard, update the webhook URL

Developers → Webhooks → Edit endpoint → paste new URL → Save.

3

Do the same for every other service

GitHub repository settings, Shopify partner dashboard, Discord application settings. Each one needs the new URL.

4

Restart your computer or close the terminal

Back to step 1. New URL. Update everything again.

ngrok now assigns a free dev domain that persists across restarts, so this specific issue is less severe than it used to be. However, the free tier still injects an interstitial warning page in front of all HTML browser traffic, showing visitors a "You are about to access a site served by ngrok" page before they can proceed. This breaks client-facing demos and browser-based webhook testing. It cannot be removed without a paid plan ($8/month minimum).

Localtonet gives you a stable subdomain on localto.net that stays the same across restarts. Set your webhook URL once in Stripe, once in GitHub, once in Shopify. It never needs to change. No interstitial page. No warning screen.

Getting a Public URL for Your Local Server in Two Minutes

Install Localtonet on your development machine, authenticate, and create an HTTP tunnel pointing to whatever port your local server runs on. Here is the complete setup:

1

Download and install Localtonet

Download the binary for your platform from localtonet.com. On macOS and Linux, make it executable and move it to your PATH. On Windows, add the directory to your PATH or run it from the folder directly.

2

Authenticate with your token

Copy your authtoken from the Localtonet dashboard and run: localtonet authtoken YOUR_TOKEN

3

Create an HTTP tunnel in the dashboard

In the Localtonet web dashboard, create a new tunnel: Protocol = HTTP, Local IP = 127.0.0.1, Local Port = your app's port (e.g. 3000), Subdomain = your choice (e.g. mydevmydev.localto.net).

4

Start Localtonet and your local server

Run localtonet in a terminal. Your public URL is now live. Navigate to https://mydev.localto.net from any device and you will see whatever is running on your local port 3000.

From this point on, use https://mydev.localto.net wherever a webhook URL is required. The subdomain stays the same every time you restart Localtonet.

The Request Inspector: See Every Webhook in Real Time

The most useful feature for webhook development is the built-in HTTP request inspector. In the Localtonet dashboard, click on any HTTP tunnel and open the Inspector tab. Every request that arrives at your public URL is logged here in real time.

For each request, the inspector shows:

FieldWhat it showsWhy it matters for webhooks
Method GET, POST, PUT, etc. Webhooks are always POST. Seeing a GET tells you something else is hitting your URL.
Endpoint The path, e.g. /webhook/stripe Confirms the webhook is being delivered to the right path.
Country + Request IP Sender's country flag and IP address Useful for verifying the request comes from Stripe's, GitHub's, or your expected sender's IP range.
Date and Time Exact timestamp of delivery Compare with your application logs to diagnose timing issues.
Status HTTP response code your server returned 200 means your handler received and acknowledged it. 4xx/5xx means your handler errored. Stripe and GitHub retry on non-200 responses.
Duration Round-trip time in seconds Stripe requires a response within 5 seconds. GitHub requires a response within 10 seconds. If you see long durations, your handler is doing too much work before responding.

Click on any row to open the full Webhook Details panel. This shows the complete request and response side by side:

📨 Request Summary Request ID, sender IP, method, host, full URL, timestamp, URL length, and total header count. Everything you need to correlate this request with your application logs.
📋 Request Headers Every header sent by the webhook provider. For Stripe this includes Stripe-Signature. For GitHub this includes X-Hub-Signature-256, X-GitHub-Event, and X-GitHub-Delivery. See the exact values being sent.
📦 Request Body The complete raw request body. For Stripe and GitHub webhooks, this is the full JSON payload. You can read every field exactly as your handler receives it, without any print statements or log files.
🔍 IP Intelligence Geolocation and ASN information for the sender's IP address. Useful for confirming the request genuinely originates from the provider's infrastructure.
📤 Response Summary The HTTP status code, response time, and header count that your local server returned. The response time is particularly important for providers with strict timeout requirements.
📄 Response Body The full response body your server sent back. If your handler returned an error message or a JSON error response, it appears here in full — without having to check server logs.
The inspector works even when your server crashes

If your webhook handler throws an unhandled exception, your server might return a 500 or no response at all. The inspector still captures the incoming request completely — method, headers, and body — because the request was received at the Localtonet edge before being forwarded. When your server is back up, you have the full payload waiting in the inspector to resend or inspect without triggering the event again in the provider's dashboard.

Testing Stripe Webhooks Locally: Step by Step

Stripe webhooks have a few non-negotiable requirements that catch developers off guard the first time. Understanding them before you start saves a lot of debugging time.

What Stripe sends and what you must do with it

Every Stripe webhook delivery includes a Stripe-Signature header. The value looks like this:

HTTP Header — sent with every Stripe webhook
Stripe-Signature: t=1740000000,v1=4f3a2b1c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a

The t value is the timestamp of when Stripe sent the webhook. The v1 value is an HMAC-SHA256 signature computed from the raw request body and your webhook endpoint secret. Your handler must verify this signature before processing the event. This prevents attackers from sending fake webhook events to your endpoint.

You must use express.raw(), not express.json(), for Stripe webhooks

Stripe's signature verification requires access to the raw, unmodified request body exactly as it was received over the network. If you parse the body with express.json() before verifying the signature, the verification will fail because JSON parsing re-serializes the body and may alter whitespace or key ordering. Use express.raw({type: 'application/json'}) specifically for the Stripe webhook route.

Complete Node.js (Express) Stripe webhook handler

Node.js — stripe-webhook.js
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();

// IMPORTANT: Use express.raw() for this route, NOT express.json()
// Stripe signature verification requires the raw body bytes.
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['stripe-signature'];
  const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // starts with whsec_

  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
  } catch (err) {
    console.error('Signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // IMPORTANT: Respond with 200 IMMEDIATELY, before doing any async work.
  // Stripe requires a response within 5 seconds. If you timeout,
  // Stripe will retry the webhook (potentially causing duplicate processing).
  res.json({ received: true });

  // Process the event asynchronously after responding
  handleStripeEvent(event);
});

async function handleStripeEvent(event) {
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object;
      console.log('Payment succeeded:', paymentIntent.id, paymentIntent.amount);
      // fulfill order, send confirmation email, update database...
      break;

    case 'customer.subscription.deleted':
      const subscription = event.data.object;
      console.log('Subscription cancelled:', subscription.id);
      // update user's plan in database, revoke access...
      break;

    case 'checkout.session.completed':
      const session = event.data.object;
      console.log('Checkout completed:', session.id);
      break;

    default:
      console.log('Unhandled event type:', event.type);
  }
}

app.listen(3000, () => console.log('Webhook server running on port 3000'));

Setting up the Stripe dashboard endpoint

1

Open Stripe Dashboard → Developers → Webhooks

Make sure you are in Test mode (toggle in the top left) so you are using test keys and test events, not live payment data.

2

Click "Add endpoint" and enter your Localtonet URL

Endpoint URL: https://mydev.localto.net/webhook/stripe. Select the events you want to receive (e.g. payment_intent.succeeded, checkout.session.completed).

3

Copy the Signing Secret

After creating the endpoint, Stripe shows you a "Signing secret" starting with whsec_. Copy this value and set it as STRIPE_WEBHOOK_SECRET in your environment. This is the secret used to verify the Stripe-Signature header.

4

Send a test event

From the endpoint details page, click "Send test webhook" and select an event type. Stripe sends a realistic test payload to your URL immediately. Open the Localtonet Inspector to see it arrive, and check your terminal for the console output.

The 5-second rule: respond first, process later

Stripe expects your endpoint to return a 2xx response within 5 seconds. If you do not respond in time, Stripe marks the delivery as failed and will retry it later (using exponential backoff for up to 3 days). This means any slow work — database writes, sending emails, calling other APIs — must happen after you have already responded. The pattern is: receive the webhook, respond with HTTP 200 immediately, then process the event asynchronously. If you return a 5xx response, Stripe also retries. Only 2xx tells Stripe the delivery succeeded.

Testing GitHub Webhooks Locally

GitHub webhooks work similarly to Stripe but with different headers and a 10-second timeout instead of 5 seconds.

GitHub webhook headers

HeaderValuePurpose
X-GitHub-Event e.g. push, pull_request, issues Which event triggered this webhook. Use this to route to the right handler.
X-GitHub-Delivery UUID, e.g. abc1-def2-... Unique ID for this delivery. Use as an idempotency key to prevent processing the same event twice if GitHub retries.
X-Hub-Signature-256 sha256=abc123... HMAC-SHA256 signature of the payload body, signed with your webhook secret. Always verify this.

Node.js GitHub webhook handler with signature verification

Node.js — github-webhook.js
const express = require('express');
const crypto = require('crypto');

const app = express();

// Use raw body for signature verification (same reason as Stripe)
app.post('/webhook/github', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-hub-signature-256'];
  const event = req.headers['x-github-event'];
  const deliveryId = req.headers['x-github-delivery'];

  if (!signature) {
    return res.status(401).send('Missing signature');
  }

  // Verify the HMAC-SHA256 signature
  const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
  hmac.update(req.body);
  const expectedSignature = 'sha256=' + hmac.digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  const sigBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expectedSignature);

  if (sigBuffer.length !== expectedBuffer.length ||
      !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
    return res.status(401).send('Invalid signature');
  }

  // Respond immediately — GitHub times out after 10 seconds
  res.status(200).send('OK');

  // Parse and process asynchronously
  const payload = JSON.parse(req.body.toString());
  handleGitHubEvent(event, deliveryId, payload);
});

function handleGitHubEvent(event, deliveryId, payload) {
  console.log(`Received ${event} event (delivery: ${deliveryId})`);

  switch (event) {
    case 'push':
      const branch = payload.ref.replace('refs/heads/', '');
      const pusher = payload.pusher.name;
      const commits = payload.commits.length;
      console.log(`${pusher} pushed ${commits} commit(s) to ${branch}`);
      // trigger CI/CD, send Slack notification, update deployment status...
      break;

    case 'pull_request':
      const action = payload.action; // opened, closed, merged, etc.
      const prNumber = payload.number;
      const prTitle = payload.pull_request.title;
      console.log(`PR #${prNumber} ${action}: ${prTitle}`);
      break;

    case 'issues':
      console.log(`Issue ${payload.action}: ${payload.issue.title}`);
      break;

    default:
      console.log(`Unhandled event: ${event}`);
  }
}

app.listen(3000, () => console.log('GitHub webhook server running on port 3000'));

Configuring the GitHub webhook

1

Go to your repository Settings → Webhooks → Add webhook

Or for organization-level webhooks: Organization Settings → Webhooks → Add webhook.

2

Fill in the form

Payload URL: https://mydev.localto.net/webhook/github. Content type: application/json. Secret: a random string you choose (set the same value as GITHUB_WEBHOOK_SECRET in your environment). Select which events to receive.

3

Click "Add webhook" — GitHub sends a ping immediately

GitHub sends a ping event to verify your endpoint is reachable. If it gets a 200 response, a green checkmark appears next to your webhook. Open the Localtonet Inspector to see the ping delivery details.

4

Trigger a real event to test your handler

Push a commit, open an issue, or create a pull request. GitHub delivers the webhook within seconds. Watch it arrive in the Inspector, then check your application logs for the handler output.

Webhook Handler Examples in Python and PHP

Python (Flask) — Stripe webhook

Python — app.py
import stripe
import os
from flask import Flask, request, jsonify

app = Flask(__name__)
stripe.api_key = os.environ['STRIPE_SECRET_KEY']
endpoint_secret = os.environ['STRIPE_WEBHOOK_SECRET']

@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()  # raw bytes, not parsed JSON
    signature = request.headers.get('Stripe-Signature')

    try:
        event = stripe.Webhook.construct_event(payload, signature, endpoint_secret)
    except stripe.error.SignatureVerificationError as e:
        return jsonify(error=str(e)), 400

    # Respond immediately
    response = jsonify(received=True)

    # Handle event types
    if event['type'] == 'payment_intent.succeeded':
        payment_intent = event['data']['object']
        print(f"Payment succeeded: {payment_intent['id']}")

    elif event['type'] == 'customer.subscription.deleted':
        subscription = event['data']['object']
        print(f"Subscription cancelled: {subscription['id']}")

    return response

if __name__ == '__main__':
    app.run(port=3000)

PHP — GitHub webhook

PHP — webhook.php
<?php
$secret = getenv('GITHUB_WEBHOOK_SECRET');

// Read raw body before any output
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$event = $_SERVER['HTTP_X_GITHUB_EVENT'] ?? '';
$delivery = $_SERVER['HTTP_X_GITHUB_DELIVERY'] ?? '';

// Verify signature
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

// Respond immediately
http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['received' => true]);

// Flush output to the client before continuing processing
// This ensures GitHub receives the 200 within the timeout window
ob_flush();
flush();

// Now process the event
$data = json_decode($payload, true);

switch ($event) {
    case 'push':
        $branch = str_replace('refs/heads/', '', $data['ref']);
        $pusher = $data['pusher']['name'];
        error_log("Push to {$branch} by {$pusher}");
        break;

    case 'issues':
        error_log("Issue {$data['action']}: {$data['issue']['title']}");
        break;
}

Quick Reference: Webhook Requirements by Provider

ProviderSignature HeaderAlgorithmResponse TimeoutRetry Behavior
Stripe Stripe-Signature HMAC-SHA256 5 seconds Retries for up to 3 days with exponential backoff on non-2xx
GitHub X-Hub-Signature-256 HMAC-SHA256 10 seconds No automatic retries — manual redeliver from Recent Deliveries
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 (Base64) 5 seconds Retries up to 19 times over 48 hours
Discord X-Signature-Ed25519 + X-Signature-Timestamp Ed25519 3 seconds No retries — marks interaction as failed if no response
PayPal Multiple headers (Auth-Algo, Cert-Url, Transmission-Id) PKCS7 + RSA 3 seconds Retries multiple times
Twilio X-Twilio-Signature HMAC-SHA1 15 seconds Retries up to 3 times

Debugging Workflow: Using the Inspector to Fix Real Problems

Here are the most common webhook debugging scenarios and how the Inspector helps resolve them quickly:

Problem: Stripe returns "No signatures found matching the expected signature"

Open the Inspector and click the failed request. Go to Request → Headers. Verify that Stripe-Signature is present. If it is, the issue is almost always one of two things: your route is using express.json() instead of express.raw() (the body is being parsed and re-serialized before verification), or you are using the wrong signing secret. The signing secret for a test mode endpoint starts with whsec_ and is specific to that endpoint — it is not your Stripe API key.

Problem: GitHub shows "Failed to deliver" but your server looks fine

Open the Inspector and check the Response section. Look at the Status code and Response Time. If the status is 200 but the duration shows something close to or over 10 seconds, your handler is exceeding GitHub's timeout. The response looks successful to GitHub because you eventually responded, but the connection was cut before the response arrived. Move your processing logic to run after res.status(200).send('OK').

Problem: Receiving duplicate webhook events

Open the Inspector and check the X-GitHub-Delivery or Stripe event ID in the request body. If you see the same delivery ID appearing multiple times, the provider is retrying because your server previously returned a non-200 or timed out. Use the delivery ID (GitHub) or event.id (Stripe) as an idempotency key — store it in your database on first processing and skip any event with a stored ID.

Problem: Webhook arrives but your handler crashes

The Inspector captures the full incoming request regardless of what your server does with it. Open the Body tab in the Inspector and read the exact JSON payload. This lets you understand the real structure of the event, check whether a field you are expecting is actually present, and spot any differences between the test payload you were mocking and the real one the provider sends. Copy the body from the Inspector and replay it against your local server using curl without needing to trigger the original event again.

Localtonet vs ngrok for Webhook Development

FeatureLocaltonetngrok Free (2026)ngrok Personal ($8/mo)
Stable URL ✅ Yes, always ✅ One .ngrok-free.app domain ✅ Yes
Browser interstitial warning ✅ None ⚠️ Shown on all HTML pages to visitors ✅ None
Request inspector ✅ Built-in, full headers + body ⚠️ Basic (localhost:4040) ✅ Full
Bandwidth limit ✅ No limit ⚠️ 1 GB/month ⚠️ 5 GB ($0.10/GB over)
UDP support ✅ Yes ❌ No ❌ No
Custom subdomain ✅ Your choice ❌ Auto-assigned only ✅ Yes
Price See localtonet.com Free $8/month

Frequently Asked Questions

Do I need to keep Localtonet running while waiting for webhooks?

Yes. The Localtonet tunnel process must be running on your development machine for webhooks to be forwarded to your local server. If you stop the process, the public URL becomes unreachable and the provider's webhook delivery will fail. During active development, keep both your application server and the Localtonet process running. You can install Localtonet as a system service to have it start automatically, but remember your application server also needs to be running for the webhooks to be processed.

Can I use the same Localtonet URL for both development and production?

Technically yes, but it is not recommended. The tunnel forwards to your local machine, which means your local development server would be handling production webhook traffic. Keep development and production separate: use the Localtonet tunnel with Stripe test mode for development, and deploy your production app to a real server with its own domain for production webhook endpoints. Most providers (including Stripe) support separate test and live mode endpoints specifically for this reason.

Why does my webhook handler work with curl but fail when Stripe sends it?

The most common reason is the body parsing issue described earlier. When you test with curl and -d '{"test":1}', you are sending a raw string. When Stripe sends a webhook, it expects your handler to read the raw bytes before parsing, because the signature is computed against those exact bytes. If you have app.use(express.json()) as a global middleware, it intercepts the body before your Stripe route handler gets it, and the body that arrives at your route is already a parsed JavaScript object rather than a Buffer. The fix is to put express.raw() on the Stripe webhook route specifically, not as a global middleware.

How do I test webhooks when my laptop is closed?

You cannot receive webhooks on a machine that is sleeping or powered off. For webhooks that need to arrive at any hour (a Stripe payment at 3am, a scheduled GitHub action), you need a server that is always on. Options include running your development server on a dedicated machine like a Raspberry Pi or home server (with a persistent Localtonet tunnel), deploying to a staging VPS, or using a webhook capture service that stores payloads for later replay when you are back at your laptop. The Inspector in Localtonet stores recent requests so you can review what came in after reconnecting, as long as the tunnel was running when the request arrived.

Stop Updating Webhook URLs Every Time You Restart

Get a stable HTTPS URL for your local development server, inspect every incoming request in real time, and test Stripe, GitHub, and any other webhook provider against your actual local code. Set up your Localtonet tunnel in under two minutes.

Create Your Free Tunnel →

Localtonet is a secure multi-protocol tunneling and proxy platform designed to expose localhost, devices, private services, and AI agents to the public internet supporting HTTP/HTTPS tunnels, TCP/UDP forwarding, mobile proxy infrastructure, file server publishing, latency-optimized game connectivity, and developer-ready AI agent endpoint exposure from a single unified control plane.

support