osmoto.
Case StudiesBlogBook Consultation

Services

Stripe IntegrationSubscription BillingPayment Automation & AINext.js OptimizationAudit & Fix

Solutions

For FoundersFor SaaS CompaniesFor E-Commerce StoresFor Marketplaces

Resources

Implementation GuideWebhook Best PracticesPCI Compliance GuideStripe vs Alternatives
Case StudiesBlog
Book Consultation
osmoto.

Professional Stripe integration services

Services

  • Stripe Integration
  • Subscription Billing
  • E-Commerce Integration
  • Next.js Optimization
  • Audit & Fix

Solutions

  • For Founders
  • For SaaS
  • For E-Commerce
  • For Marketplaces
  • Integration as a Service

Resources

  • Implementation Guide
  • Webhook Guide
  • PCI Compliance
  • Stripe vs Alternatives

Company

  • About
  • Case Studies
  • Process
  • Pricing
  • Contact
© 2026 Osmoto · Professional Stripe Integration Services
Back to Blog
Stripe Integration12 min read

How to Test Stripe Webhooks Locally with ngrok and the Stripe CLI

Testing Stripe webhooks during development can be frustrating. You make a change to your webhook handler, deploy to a staging environment, trigger a test paymen...

Osmoto Team

Senior Software Engineer

February 1, 2026
How to Test Stripe Webhooks Locally with ngrok and the Stripe CLI

Testing Stripe webhooks during development can be frustrating. You make a change to your webhook handler, deploy to a staging environment, trigger a test payment, and wait to see if it works. This cycle is slow, expensive, and makes debugging nearly impossible when something goes wrong.

The solution is testing webhooks locally using your development environment. This approach gives you immediate feedback, full debugging capabilities, and faster iteration cycles. However, since Stripe needs to send HTTP requests to your application, and your local server isn't publicly accessible, you need tools to bridge this gap.

In this guide, we'll cover two proven methods for testing Stripe webhooks locally: using ngrok to expose your local server and using the Stripe CLI to forward webhook events directly. We'll also explore when to use each approach, common pitfalls to avoid, and best practices for maintaining a robust webhook testing workflow.

Understanding Webhook Testing Requirements

Before diving into the tools, it's important to understand what makes webhook testing challenging. Stripe webhooks are HTTP POST requests sent from Stripe's servers to your application's endpoint. During development, your application runs on localhost (typically http://localhost:3000), which isn't accessible from the internet.

Additionally, Stripe webhooks include cryptographic signatures that verify the request's authenticity. Your webhook handler needs to validate these signatures to ensure security, which means you need genuine webhook payloads, not just mock data.

The two primary solutions address these challenges differently:

  • ngrok: Creates a secure tunnel to your local server, giving you a public URL
  • Stripe CLI: Forwards webhook events from Stripe directly to your local endpoint

Method 1: Testing with ngrok

ngrok is a tunneling service that creates a secure, temporary public URL pointing to your local development server. This approach closely mimics production behavior since Stripe sends actual HTTP requests to your application.

Setting Up ngrok

First, install ngrok from ngrok.com. After creating a free account, install the CLI tool:

# macOS with Homebrew brew install ngrok/ngrok/ngrok # Or download directly from ngrok.com

Authenticate ngrok with your account token:

ngrok config add-authtoken YOUR_AUTH_TOKEN

Exposing Your Local Server

Start your local development server (assuming it runs on port 3000):

npm run dev # or yarn dev

In a separate terminal, expose your local server:

ngrok http 3000

ngrok will display output similar to this:

ngrok by @inconshreveable

Session Status                online
Account                       your-email@example.com
Version                       3.1.0
Region                        United States (us)
Forwarding                    https://abc123.ngrok.io -> http://localhost:3000

The https://abc123.ngrok.io URL is your public endpoint that Stripe can reach.

Configuring Stripe Webhooks

In your Stripe Dashboard, navigate to Developers > Webhooks and create a new webhook endpoint:

  1. Endpoint URL: https://abc123.ngrok.io/api/webhooks/stripe (adjust the path to match your webhook handler)
  2. Events to send: Select the events your application needs (e.g., payment_intent.succeeded, invoice.payment_succeeded)

Copy the webhook signing secret from the Stripe Dashboard - you'll need this to verify webhook signatures.

Implementing the Webhook Handler

Here's a Next.js API route that properly handles Stripe webhooks:

// pages/api/webhooks/stripe.ts (Pages Router) // or app/api/webhooks/stripe/route.ts (App Router) import { NextApiRequest, NextApiResponse } from 'next'; import Stripe from 'stripe'; import { buffer } from 'micro'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', }); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; export const config = { api: { bodyParser: false, }, }; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method !== 'POST') { return res.status(405).json({ message: 'Method not allowed' }); } const buf = await buffer(req); const signature = req.headers['stripe-signature'] as string; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(buf, signature, webhookSecret); } catch (err) { console.error('Webhook signature verification failed:', err); return res.status(400).json({ message: 'Webhook signature verification failed' }); } // Handle the event switch (event.type) { case 'payment_intent.succeeded': const paymentIntent = event.data.object as Stripe.PaymentIntent; console.log('Payment succeeded:', paymentIntent.id); // Process the successful payment await handlePaymentSuccess(paymentIntent); break; case 'invoice.payment_succeeded': const invoice = event.data.object as Stripe.Invoice; console.log('Invoice payment succeeded:', invoice.id); // Handle subscription payment await handleSubscriptionPayment(invoice); break; default: console.log(`Unhandled event type: ${event.type}`); } res.status(200).json({ received: true }); } async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) { // Your business logic here console.log(`Processing payment: ${paymentIntent.id}`); } async function handleSubscriptionPayment(invoice: Stripe.Invoice) { // Your subscription logic here console.log(`Processing subscription payment: ${invoice.id}`); }

For App Router, the structure is slightly different:

// app/api/webhooks/stripe/route.ts import { NextRequest } from 'next/server'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', }); export async function POST(req: NextRequest) { const body = await req.text(); const signature = req.headers.get('stripe-signature')!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error('Webhook signature verification failed:', err); return new Response('Webhook signature verification failed', { status: 400 }); } // Handle events (same logic as above) return new Response('OK', { status: 200 }); }

Testing the Setup

Create a test payment in your Stripe Dashboard or through your application. You should see webhook events arrive at your local endpoint, and you can debug them in real-time using your development tools.

Method 2: Using Stripe CLI

The Stripe CLI provides a more direct approach by forwarding webhook events from Stripe to your local endpoint without requiring a public URL. This method is often faster and doesn't require managing ngrok tunnels.

Installing Stripe CLI

Install the Stripe CLI from the official documentation:

# macOS with Homebrew brew install stripe/stripe-cli/stripe # Or download from GitHub releases

Authenticating with Stripe

Login to your Stripe account:

stripe login

This opens a browser window to authenticate and links the CLI to your Stripe account.

Forwarding Webhooks

Start your local development server, then use the Stripe CLI to forward webhooks:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI will output a webhook signing secret:

Ready! Your webhook signing secret is whsec_1234567890abcdef...

Add this secret to your environment variables:

# .env.local STRIPE_WEBHOOK_SECRET=whsec_1234567890abcdef...

Triggering Test Events

The Stripe CLI can trigger specific webhook events for testing:

# Trigger a payment_intent.succeeded event stripe trigger payment_intent.succeeded # Trigger an invoice.payment_succeeded event stripe trigger invoice.payment_succeeded # Trigger multiple events stripe trigger payment_intent.succeeded invoice.payment_succeeded

You can also trigger events with specific data:

# Create a payment intent and trigger succeeded event stripe trigger payment_intent.succeeded --add payment_intent:amount=2000 --add payment_intent:currency=usd

Comparing ngrok vs Stripe CLI

Both methods have distinct advantages depending on your development workflow:

ngrok Advantages:

  • Production-like behavior: Stripe sends actual HTTP requests to your application
  • Full request inspection: You can see complete HTTP headers and request details
  • Integration testing: Test the entire flow including your frontend triggering webhooks
  • Webhook endpoint validation: Ensures your endpoint is properly configured and accessible

Stripe CLI Advantages:

  • Faster setup: No need to manage public URLs or configure webhook endpoints
  • Event simulation: Trigger specific events on demand without creating real Stripe objects
  • Offline development: Works without internet connectivity to your local server
  • Consistent webhook secrets: The same secret works across development sessions

When to Use Each Method

Use ngrok when:

  • Testing end-to-end payment flows
  • Validating webhook endpoint configuration
  • Testing with real payment data
  • Debugging HTTP-level issues

Use Stripe CLI when:

  • Rapidly iterating on webhook handler logic
  • Testing specific event scenarios
  • Working offline or with limited connectivity
  • Focusing purely on webhook processing logic

Handling Environment Configuration

Proper environment configuration is crucial for webhook testing. Create separate environment files for different testing scenarios:

# .env.local (for ngrok testing) STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... # From Stripe Dashboard # .env.cli (for Stripe CLI testing) STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... # From stripe listen command

You can switch between configurations using different scripts in your package.json:

{ "scripts": { "dev": "next dev", "dev:ngrok": "cp .env.ngrok .env.local && next dev", "dev:cli": "cp .env.cli .env.local && next dev" } }

Common Pitfalls and Solutions

Webhook Signature Verification Failures

The most common issue is webhook signature verification failing. This usually happens when:

  1. Wrong webhook secret: Ensure you're using the correct secret for your testing method
  2. Body parsing issues: Stripe webhooks require the raw request body for signature verification
  3. Missing headers: The stripe-signature header must be present and correctly formatted

Always verify signatures in development to catch these issues early:

// Add detailed error logging try { event = stripe.webhooks.constructEvent(buf, signature, webhookSecret); } catch (err) { console.error('Webhook verification failed:', { error: err.message, signature: signature?.substring(0, 20) + '...', secretLength: webhookSecret?.length, bodyLength: buf.length }); return res.status(400).json({ message: 'Webhook verification failed' }); }

ngrok URL Changes

Free ngrok accounts generate new URLs each time you restart the tunnel. This means you need to update your webhook endpoint in Stripe Dashboard frequently. Consider:

  1. Paid ngrok account: Provides persistent subdomains
  2. Automated webhook updates: Use Stripe's API to programmatically update webhook endpoints
  3. Development-specific webhooks: Create separate webhook endpoints for development that you can easily modify

Handling Webhook Retries

Stripe retries failed webhooks with exponential backoff. During development, failed webhooks can accumulate and create confusion. Handle this by:

  1. Returning proper status codes: Always return 200 for successfully processed webhooks
  2. Implementing idempotency: Handle duplicate webhook deliveries gracefully
  3. Monitoring webhook attempts: Check the Stripe Dashboard for failed webhook deliveries
// Implement idempotency using the event ID const processedEvents = new Set<string>(); export default async function handler(req: NextApiRequest, res: NextApiResponse) { // ... signature verification ... // Check if we've already processed this event if (processedEvents.has(event.id)) { console.log(`Event ${event.id} already processed`); return res.status(200).json({ received: true }); } try { // Process the event await handleWebhookEvent(event); // Mark as processed processedEvents.add(event.id); res.status(200).json({ received: true }); } catch (error) { console.error('Error processing webhook:', error); res.status(500).json({ error: 'Processing failed' }); } }

Database Connection Issues

Webhook handlers often need database access to update records. Ensure your database connections are properly configured for the webhook context:

// Example with proper error handling and connection management async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) { try { // Update user's payment status await updateUserPaymentStatus(paymentIntent.metadata.userId, 'paid'); // Send confirmation email await sendPaymentConfirmation(paymentIntent.receipt_email); console.log(`Successfully processed payment: ${paymentIntent.id}`); } catch (error) { console.error(`Failed to process payment ${paymentIntent.id}:`, error); throw error; // Re-throw to trigger webhook retry } }

Best Practices for Local Webhook Testing

1. Use Structured Logging

Implement comprehensive logging to track webhook processing:

const webhookLogger = { info: (message: string, data?: any) => { console.log(`[WEBHOOK] ${message}`, data ? JSON.stringify(data, null, 2) : ''); }, error: (message: string, error?: any) => { console.error(`[WEBHOOK ERROR] ${message}`, error); } }; // Usage in webhook handler webhookLogger.info('Received webhook event', { type: event.type, id: event.id });

2. Implement Comprehensive Error Handling

Handle different types of errors appropriately:

export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { // ... webhook processing ... } catch (error) { if (error instanceof Stripe.errors.StripeError) { // Handle Stripe-specific errors console.error('Stripe error:', error.message); return res.status(400).json({ error: 'Stripe error' }); } else if (error.name === 'ValidationError') { // Handle validation errors console.error('Validation error:', error.message); return res.status(400).json({ error: 'Validation failed' }); } else { // Handle unexpected errors console.error('Unexpected error:', error); return res.status(500).json({ error: 'Internal server error' }); } } }

3. Test Event Ordering

Webhooks can arrive out of order. Test scenarios where events might be processed in unexpected sequences:

// Add timestamp checking for event ordering const eventTimestamp = new Date(event.created * 1000); const now = new Date(); if (now.getTime() - eventTimestamp.getTime() > 300000) { // 5 minutes console.warn(`Processing old webhook event: ${event.id} (${eventTimestamp})`); }

4. Validate Event Data

Always validate webhook event data before processing:

function validatePaymentIntent(paymentIntent: Stripe.PaymentIntent) { if (!paymentIntent.id || !paymentIntent.amount || !paymentIntent.currency) { throw new Error('Invalid payment intent data'); } if (paymentIntent.status !== 'succeeded') { throw new Error(`Unexpected payment intent status: ${paymentIntent.status}`); } }

Security Considerations

Even in development, maintain security best practices:

1. Always Verify Signatures

Never skip webhook signature verification, even in development. This ensures your production code is secure and helps catch configuration issues early.

2. Use Test Keys Only

Ensure you're using Stripe test keys (sk_test_ and pk_test_) in development. Test keys prevent accidental charges and provide a safe environment for experimentation.

3. Secure Environment Variables

Store webhook secrets and API keys securely:

# .env.local (never commit to version control) STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_...

Add .env.local to your .gitignore file to prevent accidental commits.

4. Implement Rate Limiting

Even in development, consider implementing basic rate limiting to prevent abuse:

const rateLimiter = new Map<string, number>(); export default async function handler(req: NextApiRequest, res: NextApiResponse) { const clientIP = req.headers['x-forwarded-for'] || req.connection.remoteAddress; const now = Date.now(); const lastRequest = rateLimiter.get(clientIP as string) || 0; if (now - lastRequest < 1000) { // 1 second between requests return res.status(429).json({ error: 'Too many requests' }); } rateLimiter.set(clientIP as string, now); // ... rest of webhook handler ... }

Conclusion

Testing Stripe webhooks locally is essential for efficient development and debugging. Both ngrok and the Stripe CLI offer valuable approaches: ngrok provides production-like behavior with real HTTP requests, while the Stripe CLI offers rapid iteration with event simulation.

The key to successful webhook testing is choosing the right tool for your specific needs, implementing proper error handling and logging, and maintaining security best practices even in development. Start with the Stripe CLI for rapid development of webhook logic, then use ngrok for end-to-end testing and validation.

For complex webhook implementations involving multiple event types, subscription management, or marketplace scenarios, consider our comprehensive webhook implementation guide or our professional Stripe integration services to ensure your webhook handling is robust, secure, and production-ready.

Remember that webhook testing is just one part of a complete Stripe integration. Proper error handling, idempotency, and security measures are equally important for building reliable payment systems that your users can trust.

Related Articles

Why Stripe Integration Is More Expensive Than Developers Quote (Hidden Complexity Explained)
Stripe Integration
Why Stripe Integration Is More Expensive Than Developers Quote (Hidden Complexity Explained)
Your developer quotes you two weeks to "hook up Stripe" for your SaaS product. Six weeks later, you're still not live. Payments work in testing but fail randoml...
Stripe Payment Intent vs Charge API: When to Use Each
Stripe Integration
Stripe Payment Intent vs Charge API: When to Use Each
You're building a payment integration and staring at Stripe's documentation, wondering: should you use Payment Intents or the Charges API? This isn't just an ac...
How to Handle Stripe Webhook Retries Without Duplicate Orders
Stripe Integration
How to Handle Stripe Webhook Retries Without Duplicate Orders
Picture this: your e-commerce store processes a $500 order, but due to a temporary network glitch, your webhook endpoint times out. Stripe automatically retries...

Need Expert Implementation?

I provide professional Stripe integration and Next.js optimization services with fixed pricing and fast delivery.