At some point, every Stripe integration hits the same wall.
You add webhook handling, everything works in theory — and then you realize that Stripe requires a public HTTPS endpoint.
Your backend is running locally. Behind NAT. Probably behind a firewall.
Suddenly, every small change turns into:
deploy → wait → trigger event → read logs → repeat.
I wanted to keep my backend local, receive real Stripe events, and avoid turning webhook development into a deployment ritual.
This article shows how I do that.
The problem with local Stripe development
Stripe webhooks have a few non-negotiable requirements:
- public URL
- HTTPS
- stable endpoint
- real event delivery (with signatures)
That makes sense for production.
It’s much less convenient during development.
In practice, this often leads to:
- pushing half-baked code just to test a webhook
- debugging logic in staging instead of locally
- slowing down iteration for no good reason
A real use case: payment_intent.succeeded
Let’s use a concrete example.
A typical flow looks like this:
- Customer completes a payment
- Stripe emits
payment_intent.succeeded - Backend:
- verifies the webhook signature
- updates internal state
- triggers business logic
This logic is often:
- stateful
- timing-sensitive
- annoying to debug without real events
Mocks help, but they don’t fully replace real Stripe payloads.
A minimal NestJS webhook handler
Here’s a stripped-down NestJS example that handles a Stripe webhook properly.
Stripe requires access to the raw request body, so this matters.
@Post('webhooks/stripe')
async handleStripeWebhook(
@Req() req: Request,
@Headers('stripe-signature') signature: string,
) {
const payload = req.rawBody;
const event = this.stripe.webhooks.constructEvent(
payload,
signature,
this.configService.get('STRIPE_WEBHOOK_SECRET'),
);
if (event.type === 'payment_intent.succeeded') {
// handle successful payment
}
return { received: true };
}
At this point, everything works — except Stripe can’t reach your machine.
Why localhost is not enough
Stripe sends webhooks from their servers.
Your local server:
- lives behind NAT
- has no public IP
- often can’t accept inbound connections at all
Port forwarding might work at home, but:
- it’s not always possible
- it’s rarely acceptable on corporate networks
- it’s not something you want to rely on daily
Common workarounds (and why they fall short)
Deploying to staging
Works, but:
- slow feedback loop
- noisy logs
- unnecessary infrastructure work
stripe listen
Stripe provides an official CLI command:
stripe listen --forward-to localhost:3000/webhooks/stripe
It’s useful, but:
- it does not use a real public HTTPS endpoint
- it relies on an outbound tunnel initiated by the CLI
- it doesn’t reflect how webhooks behave in production
- it doesn’t help with other services that require HTTPS callbacks
Good for quick tests, not great for realistic debugging.
ngrok
Also works, but:
- URLs change
- requires extra setup
- can be blocked or restricted in some regions or networks
What we actually want
For local webhook development, the requirements are simple:
- real HTTPS endpoint
- stable public URL
- no DNS configuration
- no certificate management
- no redeploys
- backend keeps running locally
In other words:
Stripe should think it’s talking to a production server — while everything stays local.
Exposing a local NestJS server via HTTPS
The approach is straightforward:
- Run your NestJS app locally
- Expose it through a secure HTTPS tunnel
- Forward requests to
localhost
With a tunnel service, this can be done in one command:
npx start-pnode --port 3000
This gives you:
- a public HTTPS URL
- automatic TLS
- stable endpoint
- traffic forwarded to your local server
Your application code doesn’t change.
Connecting Stripe to the local endpoint
Now the fun part.
- Open the Stripe Dashboard
- Go to Developers → Webhooks
- Add a new endpoint
- Paste your public HTTPS URL:
https://your-subdomain.pnode.site/webhooks/stripe
- Select events (e.g.
payment_intent.succeeded) - Copy the signing secret
That’s it. Stripe is now talking directly to your local machine.
End-to-end test
Trigger a test event from the Stripe dashboard.
On your local machine, you’ll see:
- a real HTTPS request
- a real Stripe payload
- a valid signature
- your NestJS handler executing normally
No staging environment.
No redeploy.
No guessing.
This is the point where webhook development stops being painful.
Security notes
A few important reminders:
- Always verify the webhook signature
- Don’t rely on IP allowlists alone
- Treat exposed endpoints as temporary
- Don’t use this setup as-is for production traffic
This approach is about local development speed, not replacing production infrastructure.
When this approach works best
- Webhook-heavy backends
- Early-stage products
- Rapid iteration on payment logic
- Debugging real Stripe edge cases locally
If your workflow involves frequent webhook changes, keeping everything local is a big win.
Closing thoughts
Stripe webhooks don’t require you to deploy on every change — they just require HTTPS.
Once you remove the friction around that, local development becomes fast again.
And debugging payments where your code actually runs turns out to be much more pleasant.
Top comments (0)