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

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:
- Endpoint URL:
https://abc123.ngrok.io/api/webhooks/stripe(adjust the path to match your webhook handler) - 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:
- Wrong webhook secret: Ensure you're using the correct secret for your testing method
- Body parsing issues: Stripe webhooks require the raw request body for signature verification
- Missing headers: The
stripe-signatureheader 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:
- Paid ngrok account: Provides persistent subdomains
- Automated webhook updates: Use Stripe's API to programmatically update webhook endpoints
- 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:
- Returning proper status codes: Always return 200 for successfully processed webhooks
- Implementing idempotency: Handle duplicate webhook deliveries gracefully
- 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


