Every modern Laravel app talks to outside services. Stripe confirms payments, PayPal renews subscriptions, Twilio reports delivered SMS, and GitHub triggers deployments. All of them do it the same way: by sending a webhook, an HTTP POST request, to a public URL on your server.
The problem is that your laptop does not have a public URL. Stripe cannot reach http://localhost:8000. So many teams end up testing webhooks the slow way: change code, deploy to staging, make a test payment, read the logs, and repeat. One small bug can eat an entire afternoon.
In 2026, almost every product we build has at least one payment or messaging integration, so this problem shows up on every project. In this blog, I will show how we use ngrok to receive real webhooks on a local Laravel app, debug them in seconds, and do it without exposing the rest of the app to the internet. It is written for Laravel and full-stack developers, and for tech leads who want a faster, safer webhook workflow for their team.
The Problem: Webhooks Need a Public URL, Your Laptop Does Not Have One
Real-world scenario
Take a typical subscription product: a Laravel 11 back end with Stripe for card payments and PayPal for renewals. When a payment succeeds, Stripe sends a payment_intent.succeeded event. Our app must mark the order as paid, generate an invoice PDF, send an email and unlock the customer's plan.
If this flow has a bug, a customer either pays and gets nothing, or gets the product without paying. Neither is acceptable, and both are hard to reproduce without a real webhook.
Business impact
- Lost revenue and support load: a missed webhook means a paying customer who cannot access what they bought.
- Duplicate actions: providers retry failed webhooks, which can create duplicate orders or emails.
- Slow delivery: deploying to staging for every small change makes integration work two to three times slower.
Technical challenges
- Providers require a public HTTPS URL; localhost is not reachable.
- Each request is signed, and a wrong signature check silently rejects real events.
- Providers retry on slow or failed responses, so the same event can arrive more than once.
- Laravel runs behind a proxy when tunnelled, which breaks HTTPS detection and signed URLs.
- Opening your local app to the internet can expose admin panels and debug tools.
The Solution: A Secure Local Webhook Setup with ngrok
Architecture overview
ngrok is a reverse proxy. A small agent on your laptop opens an outbound connection to ngrok's cloud. ngrok gives you a public HTTPS address and forwards every request through that tunnel to your local app. Because the connection is outbound, you do not need to open any ports on your router or firewall.
Tools used
- ngrok agent (v3): the free plan gives each account one fixed dev domain (like your-name.ngrok-free.app), so the URL stays the same between restarts.
- Laravel 11 + Redis queue: to accept webhooks fast and process them in the background.
- ngrok inspector + Xdebug: to see every request and replay it while stepping through code.
Step 1: Start the tunnel
ngrok config add-authtoken <YOUR_TOKEN> # one-time setup
php artisan serve --port=8000
ngrok http 8000 --traffic-policy-file=ngrok-policy.yml
Copy your dev domain into APP_URL in .env and register https://your-name.ngrok-free.app/webhooks/stripe as a test-mode webhook endpoint in the Stripe dashboard.
Step 2: Make Laravel proxy-aware
Behind ngrok, Laravel sees plain HTTP from the agent unless you tell it to trust the proxy headers. Webhook routes must also skip CSRF protection, because providers cannot send a CSRF token. In Laravel 11 both settings live in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*'); // local dev only
$middleware->validateCsrfTokens(except: ['webhooks/*']);
})
Step 3: Build an idempotent webhook handler
The most important design rule: verify, record, respond fast, and do the real work in a queue. Recording each event ID makes the handler idempotent, so a retried event is safely ignored.
public function stripe(Request $request)
{
$event = Webhook::constructEvent( // throws on bad signature
$request->getContent(), $request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
$record = WebhookEvent::firstOrCreate(
['provider' => 'stripe', 'event_id' => $event->id],
['type' => $event->type, 'payload' => $request->getContent()]
);
if ($record->wasRecentlyCreated) {
ProcessStripeEvent::dispatch($record); // heavy work happens here
}
return response()->json(['received' => true]); // 200 in milliseconds
}
A unique index on (provider, event_id) guarantees that two retries arriving at the same moment cannot both create a record.
Step 4: Lock the tunnel down with a traffic policy
A tunnel exposes your whole local app by default, including Telescope, admin pages and test routes. We use an ngrok traffic policy that only allows webhook paths and verifies the Stripe signature at ngrok's edge, before the request even reaches the laptop:
# ngrok-policy.yml
on_http_request:
- expressions:
- "!req.url.path.startsWith('/webhooks/')"
actions:
- type: deny # everything else gets blocked
- expressions:
- "req.url.path.startsWith('/webhooks/stripe')"
actions:
- type: verify-webhook # invalid signatures get 403
config:
provider: stripe
secret: "${secrets.get('webhook-secrets', 'stripe-secret')}"
ngrok supports signature verification for more than 70 providers out of the box. We still verify in Laravel too, because the same code runs in production where there is no ngrok.
Step 5: Inspect and replay instead of re-testing
ngrok runs a local inspector at http://127.0.0.1:4040. It shows the headers, body and response of every webhook, and has a Replay button. You trigger a real payment once, then replay that exact request as many times as you need while you fix the code.
Key decisions
- One dev domain per developer. Each developer has their own ngrok account and their own Stripe test endpoint, so nobody receives someone else's events.
- Allowlist, not blocklist. Only /webhooks/* is reachable. New routes are private unless we decide otherwise.
- ngrok for development only. Production webhooks hit our real servers; ngrok is a local testing tool in our setup.
Real Experience: What We Learned in Production
Mistake 1: The 12-second webhook that created duplicate orders
Our first handler generated the invoice PDF and sent the email inside the request. On a busy day it took about 12 seconds. Stripe treated that as a failure and retried, so some customers got two orders and two emails. The inspector showed the same event ID arriving twice. Moving the work to a queued job and adding the event ID check (Step 3) cut the response to under 300 ms and stopped duplicates completely.
Mistake 2: Signed URLs broke behind the tunnel
Email verification links generated during webhook tests failed with "Invalid signature". Laravel thought it was serving HTTP while the link said HTTPS, so the signatures did not match. Adding trustProxies fixed it. We now document this as a required local setting.
Mistake 3: A shared tunnel exposed Telescope
A teammate shared his ngrok link with a client for a quick demo. The same link also exposed Laravel Telescope, which showed requests, queries and logs. Nothing leaked, but it was a close call. That is why every tunnel in our team now uses the traffic policy from Step 4 by default.
Mistake 4: Blank pages in browser demos
When showing a page over ngrok, the browser tried to load assets from the Vite dev server on localhost:5173, which the client cannot reach. For demos we run npm run build first. Also note that the free plan shows an ngrok warning page to browser visitors; it does not affect webhooks or API calls.
Measurable results
Here are the numbers from one subscription project after the team switched to this setup:
| Metric | Before | After | Change |
| Time per webhook debug attempt | ~10 min | ~30 s | −95% |
| Webhook response time | ~12 s | < 300 ms | −97% |
| Duplicate orders from retries (per month) | 3-4 | 0 | Eliminated |
| Staging deploys just to test webhooks (per week) | ~15 | 0 | Eliminated |
Conclusion
Webhooks are where payments, messaging and automation meet your code, and they are too important to test blindly on staging. With ngrok, a proxy-aware Laravel config, an idempotent handler and a strict traffic policy, your team can receive real webhooks locally, replay them in seconds and ship integrations faster, without exposing the rest of the app.
Key takeaway: verify, record, respond fast, and queue the rest. ngrok makes it easy to test, and your handler design makes it safe.
If you are building a Laravel product with Stripe, PayPal or other integrations and want them to be reliable from day one, talk to us. Our Laravel development services team builds payment and subscription systems, and our API integration services cover webhook-driven workflows end to end. Reliable webhooks mean fewer support tickets, no lost revenue and happier customers.