Test Stripe, GitHub and PayPal Webhooks on Localhost with a TLS Tunnel
Stripe won't deliver webhook events to a plain http:// URL. Neither will GitHub Actions, PayPal IPN, or virtually any payment or CI platform built after 2020. They require a valid HTTPS endpoint which means your localhost:3000 is invisible to them. This guide walks through setting up a TLS tunnel with Localtonet so you can receive live webhook events on your local machine in under five minutes, without touching your router and without a static IP.
Why Localhost Webhook Testing Breaks
Webhooks are HTTP POST requests that an external service fires at your server when an event occurs a payment succeeds, a pull request is merged, a subscription lapses. The problem is that your development server runs on 127.0.0.1, which is only reachable from your own machine. Stripe cannot route to it. GitHub cannot either.
Port forwarding is the classic workaround, but most ISPs now deploy CGNAT your router doesn't own a public IP, a carrier NAT does. Even when port forwarding works, you still deal with dynamic IPs, firewall rules, and the fact that every teammate needs their own setup from scratch.
A TLS tunnel solves all of this. It creates an outbound connection from your machine to a relay server that owns a public HTTPS URL. Stripe posts to that URL. The relay forwards the request through the tunnel to your local port. No inbound ports need to be open.
| Provider | Requires HTTPS? | Signature validation? | Retry on failure? |
|---|---|---|---|
| Stripe | Yes — HTTPS only | Yes — HMAC-SHA256 | Yes — up to 3 days |
| GitHub | Yes — HTTPS only | Yes — HMAC-SHA256 | Yes — up to 3 attempts |
| PayPal IPN | Yes — HTTPS only | Via IPN verification POST | Yes — multiple retries over 4 days |
| Shopify | Yes — HTTPS only | Yes — HMAC-SHA256 | Yes — 19 retries over 48 hours |
What Is a TLS Tunnel?
A TLS tunnel is a tunnel type where the relay terminates TLS at the edge and forwards decrypted traffic to your local process. The relay presents a trusted certificate to Stripe. Stripe delivers the POST over HTTPS. Your local Node.js, Python, or Laravel app receives a plain HTTP request on localhost:3000 no certificate configuration required on your end.
Localtonet supports HTTP, HTTPS, TCP, UDP, and TLS tunnels. The TLS type handles certificate management automatically. You don't provision a cert or configure an ACME client. You get a stable subdomain that persists across agent restarts on paid plans, so you register your webhook URL once and never update it.
Step-by-Step Setup with Localtonet
Create a free account and copy your token
Sign up at Register page. After logging in, go to My Tokens and copy your personal auth token you'll need it in step 3.
Download and install the Localtonet agent
Head to Downloads and grab the binary for your OS. Works on Windows, Linux, macOS, Android, and Docker.
chmod +x localtonet
./localtonet authtoken YOUR_TOKEN_HERE
.\localtonet.exe authtoken YOUR_TOKEN_HERE
Create and Start a TLS tunnel in the dashboard
In your Localtonet dashboard, click TLS Tunnel. Set Local Port to the port your app listens on (e.g., 3000), choose a subdomain, and click Save. Your public URL is instantly live something like https://yourname.localto.net
Verify the tunnel before registering it anywhere
Confirm the tunnel reaches your local server before pasting the URL into Stripe or GitHub.
curl -I https://yourname.localto.net
Configuring Stripe Webhooks
In the Stripe Dashboard, go to Developers → Webhooks → Add endpoint. Paste your Localtonet HTTPS URL and append your webhook handler path:
https://yourname.localto.net/webhooks/stripe
Select the events you care about payment_intent.succeeded, customer.subscription.deleted, and so on then click Add endpoint. Stripe immediately sends a test event. Check your local server logs to confirm receipt.
Always validate the Stripe-Signature header in your handler. Stripe signs every event with your endpoint's secret, visible in the webhook dashboard after creation.
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body, // must be raw Buffer, not parsed JSON
sig,
'whsec_YOUR_ENDPOINT_SECRET'
);
Configuring GitHub Webhooks
In your GitHub repository, go to Settings → Webhooks → Add webhook. Set the Payload URL to your tunnel URL with your handler path, set Content type to application/json, and choose a secret.
https://yourname.localto.net/webhooks/github
GitHub sends a ping event immediately on creation. If your local server returns 200, the webhook is working. Validate every incoming request against the HMAC-SHA256 signature in the X-Hub-Signature-256 header.
import hmac, hashlib
sig = request.headers.get('X-Hub-Signature-256', '')
expected = 'sha256=' + hmac.new(
SECRET.encode(), request.data, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
return 'Unauthorized', 401
Configuring PayPal IPN and Webhooks
PayPal offers two notification systems: the legacy IPN and the newer Webhooks API available under REST apps. Both require a publicly reachable HTTPS URL exactly what your Localtonet tunnel provides.
For Webhooks (REST): log into the PayPal Developer Dashboard, open your app, and add a webhook URL pointing at your tunnel. For IPN: in your PayPal sandbox account go to Profile → My Selling Tools → Instant Payment Notifications and paste the URL.
https://yourname.localto.net/webhooks/paypal
PayPal's IPN system sends a URL-encoded POST to your handler. Your server must echo the entire payload back to PayPal with cmd=_notify-validate prepended before processing the event. If you skip this step, PayPal marks the delivery as failed and retries flooding your handler for up to 4 days.
Stripe, GitHub, and PayPal all validate webhook signatures against the raw request body. Express's express.json() middleware parses the body into a JS object and discards the original bytes. When you call stripe.webhooks.constructEvent(req.body, sig, secret) with a parsed object instead of a Buffer, signature verification fails every time and you'll spend an hour debugging the wrong thing.
Fix: use express.raw({ type: 'application/json' }) for your webhook routes specifically, and keep express.json() for all other routes.
// Webhook routes — raw Buffer, not parsed JSON
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));
// All other routes — normal JSON parsing
app.use(express.json());Tips for Webhook Debugging
Frequently Asked Questions
Do I need a paid Localtonet plan to test webhooks?
No. The free plan is enough to get a working HTTPS tunnel and receive webhook events. The main limitation is that your subdomain may change after each agent restart. For a project where you register a URL in Stripe once and leave it, a paid plan's persistent subdomain saves repetitive dashboard updates.
Stripe says my endpoint URL is invalid — what's wrong?
Stripe requires a fully qualified HTTPS URL with a valid certificate. Make sure you're using the https:// version of your Localtonet subdomain, not http://. Also confirm the tunnel agent is running before adding the endpoint Stripe sends a test ping immediately after registration and marks the endpoint as failed if it gets no response.
GitHub returns a failed delivery with a timeout error. Why?
GitHub's webhook timeout is 10 seconds. If your handler does heavy processing synchronously cloning a repo, running tests, sending emails it will time out. Return 200 immediately and push the work to a background job. GitHub marks the delivery as successful as long as you acknowledge within 10 seconds.
Can multiple teammates share the same tunnel?
Not directly a tunnel forwards to a single local port on the machine running the agent. Each developer needs their own tunnel. On team plans you can manage multiple tunnels under one Localtonet account and give each tunnel a distinct subdomain for different services or team members.
Can I use this for PayPal's production webhooks, not just sandbox?
You can register a Localtonet URL in PayPal's production webhook settings, but you shouldn't. Production webhooks should point at a stable, permanently hosted endpoint. Use the tunnel for sandbox testing during development, then deploy your handler and update PayPal to the real URL before going live.
Does Localtonet support multiple tunnels at once?
Yes. You can run several tunnels simultaneously from one agent process. Define multiple tunnel configurations in the dashboard and start the agent all tunnels activate at once. This is useful when you're testing a webhook handler alongside a local API server that a frontend also needs to reach over HTTPS.
Is it safe to expose my development server to the internet via a tunnel?
For short testing sessions, yes. Don't leave a tunnel running overnight pointing at a server with admin routes or unprotected database endpoints. Localtonet tunnels use TLS 1.3 for transport encryption, but your app's own authentication is still your responsibility. Add at least HTTP basic auth to sensitive routes if the tunnel will be long-lived.
Ready to Receive Live Webhooks on Localhost?
Get a free Localtonet account and have your first TLS tunnel running in under four minutes. No credit card required. Works on Windows, Linux, macOS, and Docker.
Get Started Free →