Testing Stripe Payment Flows Without Live Keys

A card payment is not one call but a conversation: the application creates a checkout session, the customer pays on a hosted page, Stripe sends a webhook when the payment settles, and only then does the order get fulfilled. Most test suites cover the first step and assume the rest, which is backwards — fulfilment on the webhook is where double-shipping, missed orders and unrefunded cancellations come from. This guide covers the whole conversation with no live keys anywhere: stripe-mock for the adapter’s API calls, genuinely signed test webhooks for the fulfilment path, Stripe’s test card numbers for declines, and a nightly job against test mode to catch API drift. It sits under third-party SDK isolation.

Root Cause Analysis

The step teams test is the one that least needs it. Creating a checkout session is a single API call with a predictable response, and its failure modes are few. The step teams skip — reacting to checkout.session.completed or payment_intent.succeeded — carries all the business consequences: it decides whether goods ship, whether a subscription activates, whether a confirmation email goes out.

That fulfilment step is hard to test naively because it is triggered from outside. Stripe calls your endpoint, signs the payload with a secret, may deliver the same event more than once, and may deliver related events out of order. A test that calls the fulfilment function with a hand-built object skips the signature check and never sees duplicates, so it passes while the production handler double-ships on Stripe’s first retry.

Finally, declines and asynchronous payment methods are where the edge cases live. A card that requires authentication, a payment that succeeds later via bank debit, a dispute opened weeks afterwards — each produces a different sequence of events, and each needs the handler to do something specific. Stripe’s test mode can produce most of these deliberately, but only if the tests ask for them.

A payment as a conversation, and which step carries the risk The application creates a session, the customer pays on a hosted page, Stripe sends a signed webhook, and the application fulfils the order; the fulfilment step on the webhook is where business consequences and duplicate deliveries occur. create session one API call hosted payment Stripe's page signed webhook maybe twice fulfil order ship, email, activate usually tested usually not — and where the money is
The risk concentrates at the end of the conversation, where most suites stop looking.

Reproducible Setup

stripe-mock is Stripe’s own OpenAPI-driven mock server: it accepts every endpoint with correctly-shaped responses, which makes it ideal for verifying that the adapter’s requests are well formed.

# docker-compose.test.yml
services:
  stripe-mock:
    image: stripe/stripe-mock:v0.186.0
    ports: ["12111:12111"]
// src/adapters/stripe-client.ts — the endpoint is configurable, the key never live in tests
import Stripe from 'stripe';

export function createStripe() {
  const key = process.env.STRIPE_SECRET_KEY!;
  if (process.env.NODE_ENV !== 'production' && key.startsWith('sk_live_')) {
    throw new Error('Refusing to use a live Stripe key outside production');
  }
  return new Stripe(key, {
    apiVersion: '2024-06-20',
    ...(process.env.STRIPE_API_HOST ? { host: process.env.STRIPE_API_HOST, port: 12111, protocol: 'http' as const } : {}),
  });
}

The live-key guard is a few lines of production code and the most valuable line in this whole guide: it makes the worst possible test misconfiguration impossible rather than merely unlikely.

Implementation

Step 1 — Verify the adapter’s requests against stripe-mock. The mock validates parameters against Stripe’s schema, so a malformed request fails the same way it would against the real API.

// src/adapters/stripe-payments.integration.test.ts
import { test, expect, beforeAll } from 'vitest';
import { stripePayments } from './stripe-payments';

beforeAll(() => {
  process.env.STRIPE_SECRET_KEY = 'sk_test_123';
  process.env.STRIPE_API_HOST = 'localhost';
});

test('creates a checkout session stripe accepts', async () => {
  const session = await stripePayments().createCheckout({
    orderId: 'o1', amountPence: 4999, currency: 'GBP', customerEmail: 'a@example.test',
  });
  expect(session.id).toMatch(/^cs_/);
});

Step 2 — Sign test webhooks with the SDK’s own helper. Stripe’s library generates test signature headers, so the fulfilment endpoint’s verification runs exactly as in production.

// test/stripe/webhook.ts
import Stripe from 'stripe';

const stripe = new Stripe('sk_test_123', { apiVersion: '2024-06-20' });
export const WEBHOOK_SECRET = 'whsec_test_secret';

export function stripeWebhook(event: { id: string; type: string; data: { object: Record<string, unknown> } }) {
  const payload = JSON.stringify({ object: 'event', api_version: '2024-06-20', created: Math.floor(Date.now() / 1000), ...event });
  const header = stripe.webhooks.generateTestHeaderString({ payload, secret: WEBHOOK_SECRET });
  return { payload, header };
}

Step 3 — Test fulfilment through the real endpoint. A completed session must mark the order paid and trigger fulfilment exactly once.

// src/routes/stripe-webhook.test.ts
import request from 'supertest';
import { test, expect, vi, beforeEach } from 'vitest';
import { app } from '../app';
import { stripeWebhook, WEBHOOK_SECRET } from '../../test/stripe/webhook';

beforeEach(() => vi.stubEnv('STRIPE_WEBHOOK_SECRET', WEBHOOK_SECRET));

const completed = (orderId: string, id = 'evt_1') => stripeWebhook({
  id, type: 'checkout.session.completed',
  data: { object: { id: 'cs_1', client_reference_id: orderId, payment_status: 'paid', amount_total: 4999 } },
});

test('marks the order paid and fulfils it', async () => {
  const { payload, header } = completed('o1');
  await request(app).post('/webhooks/stripe').set('stripe-signature', header)
    .set('content-type', 'application/json').send(payload).expect(200);

  expect(await orders.get('o1')).toMatchObject({ status: 'paid' });
  expect(fulfilment.dispatched()).toEqual(['o1']);
});

Step 4 — Test the duplicate delivery. Stripe retries if your endpoint is slow or errors, so the same event arriving twice must not ship twice.

test('a redelivered completion does not fulfil twice', async () => {
  for (let i = 0; i < 2; i++) {
    const { payload, header } = completed('o2', 'evt_same');
    await request(app).post('/webhooks/stripe').set('stripe-signature', header)
      .set('content-type', 'application/json').send(payload).expect(200);
  }
  expect(fulfilment.dispatched()).toEqual(['o2']);
});

Step 5 — Cover asynchronous and failed payments. A session can complete with payment_status: 'unpaid' for delayed methods; the order must wait for the later success event rather than ship immediately.

test('a completed-but-unpaid session waits for the async success event', async () => {
  const pending = stripeWebhook({
    id: 'evt_a', type: 'checkout.session.completed',
    data: { object: { id: 'cs_3', client_reference_id: 'o3', payment_status: 'unpaid' } },
  });
  await request(app).post('/webhooks/stripe').set('stripe-signature', pending.header)
    .set('content-type', 'application/json').send(pending.payload).expect(200);
  expect(await orders.get('o3')).toMatchObject({ status: 'awaiting_payment' });
  expect(fulfilment.dispatched()).toEqual([]);
});
Event sequences the fulfilment handler must handle A card payment completes paid and fulfils immediately, a delayed payment method completes unpaid and fulfils only on the later async success, an async failure cancels the order, and a duplicate of any event changes nothing. Sequence Expected outcome completed (paid) fulfil now completed (unpaid) → async_payment_succeeded wait, then fulfil completed (unpaid) → async_payment_failed cancel, notify customer any event delivered twice no second effect
Four sequences, four tests — each maps directly onto a production incident teams have had.

A pattern worth adopting while writing these tests is to key fulfilment on the order, not the event. Stripe can legitimately send two different events that both mean “this order is paid” — a completed session and a succeeded payment intent — and deduplicating on event identifiers alone lets both through. Recording that an order has been fulfilled, and checking that record before dispatching, makes the handler correct regardless of which combination of events arrives.

Step 6 — Run a nightly contract against test mode. A scheduled job with a genuine test-mode key creates a session, confirms it with a test card, and checks the webhook arrives — proving the adapter still matches Stripe’s current API.

# .github/workflows/stripe-contract.yml
on: { schedule: [{ cron: '0 4 * * *' }], workflow_dispatch: {} }
jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx vitest run test/contract/stripe
        env: { STRIPE_SECRET_KEY: '${{ secrets.STRIPE_TEST_KEY }}' }

Verification

Confirm no live key can be used by any test path. Run the suite with a live-looking key and expect the guard to stop it immediately.

STRIPE_SECRET_KEY=sk_live_fake npx vitest run src/adapters
# Error: Refusing to use a live Stripe key outside production

Then confirm the webhook tests exercise signature verification by corrupting the secret: set a different STRIPE_WEBHOOK_SECRET and every fulfilment test should fail with a 400. If any still passes, it is bypassing the endpoint’s verification.

Three tools, three purposes stripe-mock verifies that adapter requests are well formed, signed test webhooks verify fulfilment and idempotency, and a nightly test-mode run verifies the integration still matches the live API. stripe-mock requests are well formed every commit signed webhooks fulfilment, duplicates, async every commit test mode still matches the real API nightly
None of the three uses a live key, and together they cover the whole conversation.

Troubleshooting

Symptom: every webhook test fails signature verification. Diagnosis: a JSON body parser consumed the raw body before the Stripe route. Fix: mount express.raw on the webhook route before any global JSON middleware — Stripe’s verification needs the exact bytes it signed.

Symptom: stripe-mock returns success for requests that fail against real Stripe. Diagnosis: stripe-mock validates shape, not business rules — it will not reject an amount below the minimum charge or a currency your account does not support. Fix: that is the nightly test-mode job’s purpose; keep business-rule checks there rather than expecting the mock to enforce them.

Symptom: fulfilment runs before payment settles for some customers. Diagnosis: the handler checks for checkout.session.completed but not payment_status. Fix: fulfil only on paid, and handle checkout.session.async_payment_succeeded for delayed methods, as in Step 5.

Symptom: the nightly contract job is flaky. Diagnosis: it waits for webhooks with a fixed sleep, and delivery latency in test mode varies. Fix: poll the test endpoint for the expected event with a generous timeout, and treat a failure as a prompt to investigate rather than as a blocking red build.

FAQ

Is stripe-mock better than MSW for the adapter?

For Stripe specifically, yes, because it validates requests against Stripe’s real OpenAPI specification and returns fully-shaped objects. MSW is the better general tool when a vendor offers no such mock, and it remains useful here for scripting specific error responses that stripe-mock does not produce.

Should unit tests use the Stripe SDK at all?

No — business logic should depend on your own payments interface and a recording fake, as described in third-party SDK isolation. The SDK appears only in the adapter and its tests, and in the webhook signature helper.

How do I test disputes and refunds?

Refunds are an API call — test the adapter against stripe-mock and the business logic against the fake. Disputes arrive as webhooks days later; sign a charge.dispute.created event the same way as a completion and assert on what the handler does, such as flagging the order and notifying support.

What about Stripe’s test card numbers?

Use them in the nightly contract job and in end-to-end tests that drive the hosted checkout page. They trigger specific outcomes — declines, authentication challenges, insufficient funds — deterministically. They are irrelevant to unit and adapter tests, which never reach Stripe’s real card processing.