stripe-test-cards-and-webhooks

Use whenever code that takes payments with Stripe needs tests: test-mode keys, test cards for success, declines and 3D Secure, stripe listen to forward webhooks locally, stripe trigger fixtures, and Stripe-Signature verification.

/plugin install qa-payment@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)
Skill
Version
v1.6.1

Overview

Stripe's test-mode is feature-complete: every API call works against test data, no real money moves. Per docs.stripe.com/testing (opens in new window), test keys (sk_test_* / pk_test_*) accept canonical test cards that deterministically produce success / decline / 3DS challenge.

When to use

  • Tests for code that integrates Stripe.
  • Verifying webhook handling.
  • 3DS challenge flow tests per the 3DS flows reference in payment-flow-test-author (its payment-states reference).

Authoring

Install

npm install stripe
pip install stripe

Test keys come from the Stripe Dashboard (Developers → API keys → toggle to "View test data").

Initialize

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_TEST_KEY!);

Canonical test cards

Per docs.stripe.com/testing (opens in new window):

CardBehaviour
4242 4242 4242 4242Success (Visa)
5555 5555 5555 4444Success (Mastercard)
4000 0000 0000 0002Decline (generic_decline)
4000 0000 0000 9995Decline (insufficient_funds)
4000 0000 0000 9987Decline (lost_card)
4000 0000 0000 0069Expired card
4000 0027 6000 31843DS authentication required (challenge)
4000 0000 0000 30553DS supported but frictionless
4100 0000 0000 0019Fraud-prevention block

Test PAN any future expiry; any 3-digit CVC.

PaymentIntent end-to-end

test('successful payment via 4242', async () => {
  const intent = await stripe.paymentIntents.create({
    amount: 1000,
    currency: 'usd',
    payment_method: 'pm_card_visa',
    confirm: true,
    return_url: 'https://example.com/return',
  });
  expect(intent.status).toBe('succeeded');
});

test('declined payment', async () => {
  await expect(
    stripe.paymentIntents.create({
      amount: 1000,
      currency: 'usd',
      payment_method: 'pm_card_chargeDeclined',
      confirm: true,
      return_url: 'https://example.com/return',
    })
  ).rejects.toThrow(/card was declined/);
});

Webhook handler test

import { buffer } from 'micro';
import handler from './stripe-webhook';

test('webhook handler validates signature', async () => {
  const payload = JSON.stringify({ type: 'payment_intent.succeeded', data: {...} });
  const signature = stripe.webhooks.generateTestHeaderString({
    payload,
    secret: process.env.STRIPE_WEBHOOK_SECRET!,
  });

  const req = { headers: { 'stripe-signature': signature }, body: Buffer.from(payload) };
  const res = await handler(req as any);
  expect(res.statusCode).toBe(200);
});

Per docs.stripe.com/webhooks/signatures (opens in new window): the Stripe CLI's generateTestHeaderString produces a valid HMAC-SHA256 signature for testing.

Stripe CLI local forwarding

Per docs.stripe.com/stripe-cli (opens in new window):

stripe listen --forward-to http://localhost:3000/webhooks/stripe
# Forwards real Stripe webhook events to your local endpoint
stripe trigger payment_intent.succeeded
# Sends a synthetic event to test your handler

The CLI also exposes a signing secret (different from your prod webhook secret) for local testing.

Idempotency

test('idempotency key prevents duplicate charges', async () => {
  const key = 'order-12345';
  const r1 = await stripe.paymentIntents.create({...}, { idempotencyKey: key });
  const r2 = await stripe.paymentIntents.create({...}, { idempotencyKey: key });
  expect(r1.id).toBe(r2.id);  // Same PaymentIntent
});

Running

npm test

For webhook-integration tests with stripe listen running:

stripe listen --forward-to http://localhost:3000/webhooks/stripe &
npm test

CI integration

jobs:
  stripe-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
      - run: npm ci && npm test
        env:
          STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
          STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}

Anti-patterns

Anti-patternWhy it failsFix
Mocking Stripe SDK directlyLoses signature verification, error mappingTest against real test-mode API
Hardcoded test cards in many testsUpdate breakage when Stripe changesUse named constants for the card PANs
Skip webhook signature verificationWebhook replay attackAlways verify
Tests without idempotency keyRetried tests duplicate-createAlways set idempotency
Mix prod + test keysReal money riskStrict per-env key separation
Test webhook handler without raw bodybody-parser strips bytes; signature mismatchUse raw body middleware
One-off webhook event testsPer-event-type integration coverage missedTest the full event matrix relevant to your flow
Long-running stripe listen in CIHangs buildsUse stripe listen --print-secret then background

Limitations

  • Test mode is real API surface. Rate limits apply; long CI runs can hit them.
  • Dispute timelines compress in test mode. Use Stripe CLI trigger to fast-forward dispute states.
  • Webhook delivery has ~30s SLA. Tests waiting for webhooks need timeouts.
  • Doesn't validate platform-side bookkeeping. Stripe reports are separate.

References

  • Sign-off wanted today on a contractor's "stabilise the payments suite" PR
  • Five asks about the decline messages, and I do not trust all five
  • The nightly run creates two payments for every order it retries
  • Staging 400s about a fifth of our deliveries and the suite is still green
  • The payments job burned a runner for six weeks and now it lies in ninety seconds