DEV Community

Pavel
Pavel

Posted on

Testing Stripe Webhooks Locally Without Deploying Your App

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:

  1. Customer completes a payment
  2. Stripe emits payment_intent.succeeded
  3. 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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Run your NestJS app locally
  2. Expose it through a secure HTTPS tunnel
  3. Forward requests to localhost

With a tunnel service, this can be done in one command:

npx start-pnode --port 3000
Enter fullscreen mode Exit fullscreen mode

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.

  1. Open the Stripe Dashboard
  2. Go to Developers → Webhooks
  3. Add a new endpoint
  4. Paste your public HTTPS URL:
   https://your-subdomain.pnode.site/webhooks/stripe
Enter fullscreen mode Exit fullscreen mode
  1. Select events (e.g. payment_intent.succeeded)
  2. 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.

Stripe documentation
PNode documentation

Top comments (0)