Stripe Webhook Testing with Test Mode and Local Forwarding

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:

  1. Verify the Stripe-Signature header (prevents spoofing)
  2. Acknowledge with HTTP 200 within 30 seconds
  3. Process the event (preferably async, after returning 200)
  4. 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/stripe

This prints a webhook signing secret like whsec_test_1234. Use it in your .env:

STRIPE_WEBHOOK_SECRET=whsec_test_1234

Trigger 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_123

Implementing 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 listen locally — real webhook delivery to your local dev server
  • Structure handlers for testability — dependency injection for orderService, subscriptionService, etc.
  • Unit test each event typepayment_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.

Read more