Webhook testing is where many payment integrations break in production, yet most engineers have never done it properly in a local environment. Testing webhooks locally: a hands-on guide for engineers walks you through using the Stripe CLI to intercept, forward, and simulate real payment events on your machine—eliminating surprises when you deploy.
By the end of this guide, you'll have a repeatable local workflow that mirrors production behavior, complete with proper signature verification and event logging that helps you debug payment flows before they cause incidents.
Why local webhook testing matters
Webhooks are asynchronous events that Stripe sends to your server when payment states change. They're fire-and-forget from Stripe's perspective—there's no request/response cycle you can debug in your browser console. If your webhook handler breaks in production, you're left with:
- Payments processed but not recorded in your database
- Orders stuck in limbo
- No visibility into what went wrong until a customer complains
- Hours spent reconstructing event history from Stripe's dashboard
Testing webhooks locally before pushing to staging or production catches these issues immediately. The Stripe CLI makes this straightforward and fast, yet many teams skip it because they think it's complicated or because their local setup "just works" (until it doesn't).
Setting up stripe listen --forward-to localhost:3000/webhooks
The Stripe CLI's listen command is the foundation of local webhook testing. It creates a secure tunnel from Stripe's servers to your local machine, letting you receive real webhook events without deploying anywhere.
First, install the Stripe CLI if you haven't already:
# macOS with Homebrew
brew install stripe/stripe-cli/stripe
# Or download from https://github.com/stripe/stripe-cli/releases
Authenticate the CLI with your Stripe account:
stripe login
You'll be prompted to open a browser and authorize. The CLI stores credentials locally and you're ready to go.
Now start listening on your webhook endpoint. Replace 3000 with whatever port your local server runs on:
stripe listen --forward-to localhost:3000/webhooks
You should see output like:
> Ready! Your webhook signing secret is whsec_test_ABC123...
> Forwarding to http://localhost:3000/webhooks
That signing secret is critical—we'll use it in your environment variables next. The CLI is now sitting between Stripe and your local app, capturing every webhook event it receives and forwarding it to your endpoint.
In a real scenario, imagine you're building an e-commerce platform. Your product service is running on localhost:3000. When a customer completes a payment in your Stripe checkout flow, Stripe would normally try to reach your production URL. With the CLI listening, it reaches your local machine instead, and you see the event in real time on your terminal.
Triggering test events with stripe trigger
Listening is half the battle. The other half is sending events on demand so you can test your handlers without creating actual Stripe objects every time.
While your stripe listen session is running in one terminal, open another and trigger an event:
stripe trigger payment_intent.succeeded
Watch your stripe listen terminal—you'll see the event logged with its ID, timestamp, and payload. Your local server should receive the webhook immediately.
The most common test events you'll use are:
payment_intent.succeeded– Customer completed paymentpayment_intent.payment_failed– Payment declined or failedcharge.refunded– Refund processedcustomer.subscription.updated– Subscription changed (for recurring billing)invoice.payment_succeeded– Invoice paid
You can also trigger events with custom data. For example, to simulate a failed payment attempt:
stripe trigger payment_intent.payment_failed --override payment_intent:status=requires_payment_method
Or attach metadata that your code expects:
stripe trigger payment_intent.succeeded --override payment_intent:metadata:order_id=ORD-12345
This is invaluable when you need to test edge cases—a refund for a specific order, a subscription renewal that triggers dunning logic, or a payment that should reconcile against a legacy invoice ID stored in metadata.
Using the CLI's signing secret for local verification
Stripe signs every webhook with a secret key, and your handler must verify the signature before processing the event. This prevents spoofing—an attacker can't fake a payment success event without your secret.
Grab the signing secret from your stripe listen output (it starts with whsec_) and add it to your .env.local:
STRIPE_WEBHOOK_SECRET=whsec_test_ABC123xyz...
Now implement signature verification in your webhook handler. Here's a NestJS example:
import { Controller, Post, Req, RawBodyRequest } from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
@Controller('webhooks')
export class WebhookController {
constructor(private stripe: Stripe) {}
@Post()
async handleWebhook(@Req() req: RawBodyRequest) {
const sig = req.headers['stripe-signature'] as string;
const secret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
req.rawBody,
sig,
secret,
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return { received: false };
}
// Event is verified. Process it.
console.log(`Received webhook: ${event.type}`);
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return { received: true };
}
private async handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
console.log(`Payment succeeded: ${paymentIntent.id}`);
// Update your database, send confirmation email, etc.
}
private async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
console.log(`Payment failed: ${paymentIntent.id}, reason: ${paymentIntent.last_payment_error?.message}`);
// Mark order as failed, notify customer, retry logic, etc.
}
}
The critical line is this.stripe.webhooks.constructEvent(req.rawBody, sig, secret). This verifies that Stripe signed the payload with your secret. If verification fails, the method throws an error and you return early without processing.
Many teams skip signature verification in local development ("it slows me down"), then discover in production that their verification logic is broken. Always verify locally—the Stripe CLI signs events correctly, so you can catch bugs immediately.
Logging event types while developing
As you implement more webhook handlers, logging becomes your best debugging tool. You can't step through a webhook in a debugger (well, you can with breakpoints, but the request times out if it takes too long). You need clean, structured logs that tell you what happened.
Log every webhook you receive, its type, and the key fields you acted on:
@Post()
async handleWebhook(@Req() req: RawBodyRequest) {
const sig = req.headers['stripe-signature'] as string;
const secret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
req.rawBody,
sig,
secret,
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return { received: false };
}
// Log incoming event with context
console.log('[WEBHOOK_IN]', {
id: event.id,
type: event.type,
created: new Date(event.created * 1000).toISOString(),
api_version: event.api_version,
});
switch (event.type) {
case 'payment_intent.succeeded':
const pi = event.data.object as Stripe.PaymentIntent;
console.log('[PAYMENT_SUCCEEDED]', {
payment_intent_id: pi.id,
amount: pi.amount,
currency: pi.currency,
customer: pi.customer,
metadata: pi.metadata,
});
await this.handlePaymentSucceeded(pi);
break;
case 'payment_intent.payment_failed':
const failed_pi = event.data.object as Stripe.PaymentIntent;
console.log('[PAYMENT_FAILED]', {
payment_intent_id: failed_pi.id,
reason: failed_pi.last_payment_error?.message,
decline_code: failed_pi.last_payment_error?.decline_code,
});
await this.handlePaymentFailed(failed_pi);
break;
default:
console.log('[WEBHOOK_UNHANDLED]', event.type);
}
return { received: true };
}
When you trigger stripe trigger payment_intent.succeeded, you'll see logs that match exactly. This makes it trivial to spot whether your code ran, what it decided, and where it failed. Add logs inside your handlers too:
private async handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
try {
console.log('[HANDLER_START] payment_intent.succeeded', {
pi_id: paymentIntent.id,
});
// Find or create order
const order = await this.ordersService.findByStripePaymentIntentId(
paymentIntent.id,
);
if (!order) {
console.log('[HANDLER_ERROR] Order not found', { pi_id: paymentIntent.id });
return;
}
console.log('[HANDLER_UPDATE_ORDER]', {
order_id: order.id,
status_before: order.status,
});
order.status = 'paid';
order.paidAt = new Date();
await order.save();
console.log('[HANDLER_UPDATE_ORDER_COMPLETE]', {
order_id: order.id,
status_after: order.status,
});
// Send confirmation email
await this.mailer.sendOrderConfirmation(order);
console.log('[HANDLER_EMAIL_SENT]', { order_id: order.id });
console.log('[HANDLER_COMPLETE] payment_intent.succeeded', {
pi_id: paymentIntent.id,
});
} catch (err) {
console.error('[HANDLER_EXCEPTION]', {
type: 'payment_intent.succeeded',
pi_id: paymentIntent.id,
error: err.message,
stack: err.stack,
});
// Re-throw so you see it in your server logs
throw err;
}
}
Now when you run stripe trigger payment_intent.succeeded, you see the entire flow: webhook received → signature verified → handler started → order found → order updated → email sent → handler complete. If anything breaks, you see exactly where.
Common mistakes
Not including the raw body in your Express/NestJS middleware. Stripe signs the entire raw HTTP body, not the parsed JSON. If your framework parses JSON first and you try to verify the signature later, it fails. For Express, use express.raw({ type: 'application/json' }) on your webhook route. NestJS requires RawBodyRequest as shown above.
Using production webhook secrets locally. The test signing secret from stripe listen is different from your production secret. If you hardcode your production secret in code and commit it, you've leaked a sensitive credential. Always use environment variables and keep .env.local out of version control.
Assuming webhook event ordering. Webhooks arrive asynchronously and can arrive out of order. If a customer's payment succeeds then fails, you might receive the failure event before the success event hits your handler. Don't assume you can rely on event order; always query Stripe for the current state if you need certainty, or use idempotency keys to ensure operations don't run twice.
Not testing both success and failure paths. Most teams test happy paths: payment succeeds, order created. But they never trigger payment_intent.payment_failed and verify that their failure handler works. Test both, every time.
Leaving the CLI session running forever and forgetting about it. The tunnel stays active until you stop the CLI. If you close it and forget, you stop receiving webhooks locally. Make it a habit to start the CLI in a dedicated terminal window or tmux session and keep it visible.
Key takeaways
- Use
stripe listen --forward-to localhost:3000/webhooksto tunnel Stripe's webhooks to your local machine; it's the fastest way to test payment events without deploying. - Use
stripe trigger <event_type>in a second terminal to simulate events on demand; this lets you test edge cases and failure scenarios locally. - Always verify webhook signatures using the CLI's signing secret and Stripe's
webhooks.constructEventmethod; verification bugs often hide in production. - Log event types, IDs, and key fields at entry and exit points in your handlers; these logs are your debugger when webhooks don't behave as expected.
- Test both success and failure paths locally before pushing to any environment; a webhook handler that works for happy path but crashes on failure will cause production incidents.
- Keep your Stripe CLI session running in a visible terminal while developing; it's easy to forget it's off and spend time wondering why webhooks aren't reaching your code.






Comments
Loading comments…