Stripe Webhook Testing with Test Mode and Local Forwarding
Stripe webhooks are the backbone of payment processing — they notify your application when payments succeed, subscriptions change, and invoices are due. But webhook endpoints are notoriously hard to test: they need HTTPS, a signature verification step, and they handle async events that don't return values.
This guide covers testing Stripe webhooks: local development with stripe listen, unit testing signature verification, integration testing event handlers, and CI testing without network calls.
How Stripe Webhooks Work
When a payment event occurs, Stripe sends a POST request to your webhook endpoint containing:
{
"id": "evt_1234",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_1234",
"amount": 2999,
"currency": "usd",
"metadata": { "order_id": "ord_123" }
}
}
}Your endpoint must:
- Verify the
Stripe-Signatureheader (prevents spoofing) - Acknowledge with HTTP 200 within 30 seconds
- Process the event (preferably async, after returning 200)
- Handle duplicate events (Stripe retries on failure)
Local Development with stripe listen
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
stripe login
<span class="hljs-comment"># Forward webhooks to your local server
stripe listen --forward-to localhost:3000/webhooks/stripeThis prints a webhook signing secret like whsec_test_1234. Use it in your .env:
STRIPE_WEBHOOK_SECRET=whsec_test_1234Trigger test events:
# Trigger a specific event type
stripe trigger payment_intent.succeeded
<span class="hljs-comment"># Trigger with custom metadata
stripe trigger checkout.session.completed \
--add checkout_session:metadata.order_id=ord_123Implementing a Testable Webhook Handler
Structure your webhook handler for testability:
// src/webhooks/stripe-handler.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' });
export interface WebhookHandlerDeps {
orderService: {
markAsPaid: (orderId: string, paymentIntentId: string) => Promise<void>;
markAsFailed: (orderId: string, reason: string) => Promise<void>;
};
subscriptionService: {
activate: (customerId: string, subscriptionId: string) => Promise<void>;
cancel: (subscriptionId: string) => Promise<void>;
};
notificationService: {
sendPaymentReceipt: (customerId: string, amount: number) => Promise<void>;
};
}
export function createStripeWebhookHandler(deps: WebhookHandlerDeps) {
return {
async handleEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object as Stripe.PaymentIntent;
const orderId = pi.metadata.order_id;
if (!orderId) throw new Error(`payment_intent.succeeded: missing order_id in metadata`);
await deps.orderService.markAsPaid(orderId, pi.id);
await deps.notificationService.sendPaymentReceipt(
pi.customer as string,
pi.amount
);
break;
}
case 'payment_intent.payment_failed': {
const pi = event.data.object as Stripe.PaymentIntent;
const orderId = pi.metadata.order_id;
if (!orderId) break;
await deps.orderService.markAsFailed(
orderId,
pi.last_payment_error?.message ?? 'Payment failed'
);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
if (sub.status === 'active') {
await deps.subscriptionService.activate(
sub.customer as string,
sub.id
);
}
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await deps.subscriptionService.cancel(sub.id);
break;
}
default:
// Unknown event type — log and ignore
console.log(`Unhandled webhook event: ${event.type}`);
}
}
};
}Express endpoint:
// src/routes/webhook.ts
import express from 'express';
import Stripe from 'stripe';
import { createStripeWebhookHandler } from '../webhooks/stripe-handler';
const router = express.Router();
router.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }), // Must be raw body for signature verification
async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
res.status(400).send(`Webhook signature verification failed: ${err}`);
return;
}
// Return 200 immediately — process async
res.json({ received: true });
// Process event after responding
const handler = createStripeWebhookHandler({ orderService, subscriptionService, notificationService });
await handler.handleEvent(event).catch(console.error);
}
);Unit Testing Webhook Event Handlers
// tests/webhooks/stripe-handler.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type Stripe from 'stripe';
import { createStripeWebhookHandler } from '~/webhooks/stripe-handler';
function createMockEvent<T extends Stripe.Event['type']>(
type: T,
object: Record<string, unknown>
): Stripe.Event {
return {
id: `evt_test_${Date.now()}`,
type,
object: 'event',
api_version: '2023-10-16',
created: Math.floor(Date.now() / 1000),
data: { object } as any,
livemode: false,
pending_webhooks: 0,
request: null,
};
}
describe('Stripe webhook handler', () => {
const mockOrderService = {
markAsPaid: vi.fn().mockResolvedValue(undefined),
markAsFailed: vi.fn().mockResolvedValue(undefined),
};
const mockSubscriptionService = {
activate: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
};
const mockNotificationService = {
sendPaymentReceipt: vi.fn().mockResolvedValue(undefined),
};
let handler: ReturnType<typeof createStripeWebhookHandler>;
beforeEach(() => {
vi.clearAllMocks();
handler = createStripeWebhookHandler({
orderService: mockOrderService,
subscriptionService: mockSubscriptionService,
notificationService: mockNotificationService,
});
});
describe('payment_intent.succeeded', () => {
const successEvent = createMockEvent('payment_intent.succeeded', {
id: 'pi_test_123',
amount: 2999,
currency: 'usd',
customer: 'cus_test_456',
metadata: { order_id: 'ord_123' },
});
it('marks order as paid', async () => {
await handler.handleEvent(successEvent);
expect(mockOrderService.markAsPaid).toHaveBeenCalledWith('ord_123', 'pi_test_123');
});
it('sends payment receipt notification', async () => {
await handler.handleEvent(successEvent);
expect(mockNotificationService.sendPaymentReceipt).toHaveBeenCalledWith(
'cus_test_456',
2999
);
});
it('throws when order_id metadata is missing', async () => {
const eventWithoutOrderId = createMockEvent('payment_intent.succeeded', {
id: 'pi_test_123',
amount: 2999,
customer: 'cus_test_456',
metadata: {}, // No order_id
});
await expect(handler.handleEvent(eventWithoutOrderId)).rejects.toThrow('missing order_id');
});
});
describe('payment_intent.payment_failed', () => {
it('marks order as failed with error message', async () => {
const failedEvent = createMockEvent('payment_intent.payment_failed', {
id: 'pi_test_789',
metadata: { order_id: 'ord_456' },
last_payment_error: { message: 'Your card was declined.' },
});
await handler.handleEvent(failedEvent);
expect(mockOrderService.markAsFailed).toHaveBeenCalledWith(
'ord_456',
'Your card was declined.'
);
});
it('uses default message when no error details provided', async () => {
const failedEvent = createMockEvent('payment_intent.payment_failed', {
id: 'pi_test_789',
metadata: { order_id: 'ord_456' },
last_payment_error: null,
});
await handler.handleEvent(failedEvent);
expect(mockOrderService.markAsFailed).toHaveBeenCalledWith('ord_456', 'Payment failed');
});
});
describe('customer.subscription.created', () => {
it('activates subscription when status is active', async () => {
const event = createMockEvent('customer.subscription.created', {
id: 'sub_test_123',
customer: 'cus_test_456',
status: 'active',
});
await handler.handleEvent(event);
expect(mockSubscriptionService.activate).toHaveBeenCalledWith(
'cus_test_456',
'sub_test_123'
);
});
it('does not activate subscription when status is trialing', async () => {
const event = createMockEvent('customer.subscription.created', {
id: 'sub_test_123',
customer: 'cus_test_456',
status: 'trialing',
});
await handler.handleEvent(event);
expect(mockSubscriptionService.activate).not.toHaveBeenCalled();
});
});
describe('customer.subscription.deleted', () => {
it('cancels the subscription', async () => {
const event = createMockEvent('customer.subscription.deleted', {
id: 'sub_test_789',
customer: 'cus_test_456',
status: 'canceled',
});
await handler.handleEvent(event);
expect(mockSubscriptionService.cancel).toHaveBeenCalledWith('sub_test_789');
});
});
describe('unknown event types', () => {
it('handles unknown events without throwing', async () => {
const unknownEvent = createMockEvent('payment_method.attached' as any, {
id: 'pm_test_123',
});
await expect(handler.handleEvent(unknownEvent)).resolves.toBeUndefined();
});
});
});Testing Signature Verification
// tests/webhooks/signature-verification.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import Stripe from 'stripe';
import app from '~/app';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
function buildStripeWebhookRequest(payload: object) {
const payloadString = JSON.stringify(payload);
const timestamp = Math.floor(Date.now() / 1000);
const signature = stripe.webhooks.generateTestHeaderString({
payload: payloadString,
secret: webhookSecret,
timestamp,
});
return { payloadString, signature };
}
describe('Webhook signature verification', () => {
it('returns 200 for valid signature', async () => {
const event = { type: 'test.event', data: { object: {} } };
const { payloadString, signature } = buildStripeWebhookRequest(event);
const res = await request(app)
.post('/webhooks/stripe')
.set('Stripe-Signature', signature)
.set('Content-Type', 'application/json')
.send(payloadString);
expect(res.status).toBe(200);
expect(res.body).toEqual({ received: true });
});
it('returns 400 for missing signature header', async () => {
const res = await request(app)
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ type: 'test.event' }));
expect(res.status).toBe(400);
});
it('returns 400 for invalid signature', async () => {
const res = await request(app)
.post('/webhooks/stripe')
.set('Stripe-Signature', 'invalid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ type: 'test.event' }));
expect(res.status).toBe(400);
});
});Testing Idempotency
Stripe may send the same event twice. Your handler must be idempotent:
// tests/webhooks/idempotency.test.ts
describe('Webhook idempotency', () => {
it('handles duplicate events without side effects', async () => {
const successEvent = createMockEvent('payment_intent.succeeded', {
id: 'pi_dedup_123',
amount: 1000,
customer: 'cus_123',
metadata: { order_id: 'ord_dedup' },
});
// Process same event twice
await handler.handleEvent(successEvent);
await handler.handleEvent(successEvent);
// markAsPaid should handle the duplicate gracefully
// (most implementations check if already paid before updating)
expect(mockOrderService.markAsPaid).toHaveBeenCalledTimes(2);
// But the order should only be marked paid once in the actual DB
// (test your service layer's idempotency separately)
});
});Summary
Stripe webhook testing strategy:
- Use
stripe listenlocally — real webhook delivery to your local dev server - Structure handlers for testability — dependency injection for orderService, subscriptionService, etc.
- Unit test each event type —
payment_intent.succeeded,payment_intent.payment_failed, subscription lifecycle - Test signature verification separately — valid, missing, and invalid signatures
- Test idempotency — duplicate events should not cause double-charging
- Return 200 immediately, process async — prevents Stripe retry storms when processing is slow
Webhook testing is non-negotiable for payment systems. A missed payment_intent.succeeded means an unfulfilled order; a missed customer.subscription.deleted means a free user in your system.