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.
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:
Start ngrok, get a new random URL
Something like https://a4b2c9d1.ngrok-free.app. Different every time.
Open the Stripe dashboard, update the webhook URL
Developers → Webhooks → Edit endpoint → paste new URL → Save.
Do the same for every other service
GitHub repository settings, Shopify partner dashboard, Discord application settings. Each one needs the new URL.
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:
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.
Authenticate with your token
Copy your authtoken from the Localtonet dashboard and run: localtonet authtoken YOUR_TOKEN
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. mydev → mydev.localto.net).
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:
| Field | What it shows | Why 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:
Stripe-Signature. For GitHub this includes X-Hub-Signature-256, X-GitHub-Event, and X-GitHub-Delivery. See the exact values being sent.
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:
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.
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
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
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.
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).
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.
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
| Header | Value | Purpose |
|---|---|---|
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
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
Go to your repository Settings → Webhooks → Add webhook
Or for organization-level webhooks: Organization Settings → Webhooks → Add webhook.
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.
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.
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
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
$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
| Provider | Signature Header | Algorithm | Response Timeout | Retry 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
| Feature | Localtonet | ngrok 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 →