TEST RUNNERS

Test Stripe Checkout email verification end-to-end (no IMAP)

Stripe Checkout flows are easy to test up to the payment step. Once you trigger Stripe’s hosted page, fill the test card, and submit, the next thing you need is the receipt or verification email Stripe sends. That’s where most CI suites stub out - and where bugs hide that bite production.

This post shows the full path: real Playwright test against Stripe Checkout test mode, real disposable inbox, real wait-for-email, real assertions on the receipt content. No IMAP, no Gmail OAuth, no manual steps.

What we’re testing

Three flows show up most often in indie SaaS:

  1. Subscription checkout → receipt email (Stripe sends invoice.payment_succeeded and a receipt)
  2. Customer email verification (your app sends a “confirm your email” mail after Stripe Checkout completes)
  3. Failed-payment dunning email (Stripe sends invoice.payment_failed after a card decline you simulated with 4000000000000341)

The pattern is identical for all three. We’ll walk through #1 and call out the differences for #2 and #3 inline.

Why stubbing falls short here

The standard CI shortcut: mock Stripe’s webhook events with the Stripe CLI trigger command, then assert your app’s webhook handler did the right thing.

That’s good for unit-testing your webhook handler. It misses three failure modes:

Setup

You need three things:

  1. A Stripe test-mode secret key + a working Stripe Checkout endpoint in your app.
  2. A MailSink API key. Sign up free for 50 inboxes/month, or try anonymously first to feel it out.
  3. Playwright installed in your repo.
npm install -D @playwright/test
npx playwright install chromium

Playwright fixture

tests/fixtures.ts:

import { test as base } from "@playwright/test";

const MAILSINK_API_KEY = process.env.MAILSINK_API_KEY!;
const API = "https://api.mailsink.dev";

type Inbox = { id: string; address: string };

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    const create = await fetch(`${API}/v1/inboxes`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${MAILSINK_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ local_part: "stripe-test", ttl: 600 }),
    });
    const inbox = await create.json();

    await use({ id: inbox.id, address: inbox.address });

    // Cleanup - keeps your monthly inbox count low even on flaky reruns.
    await fetch(`${API}/v1/inboxes/${inbox.id}`, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${MAILSINK_API_KEY}` },
    });
  },
});

export { expect } from "@playwright/test";

The fixture creates one inbox per test, makes it available as inbox, and tears it down regardless of pass/fail.

The actual test

tests/stripe-checkout-receipt.spec.ts:

import { test, expect } from "./fixtures";

const API = "https://api.mailsink.dev";
const MAILSINK_API_KEY = process.env.MAILSINK_API_KEY!;

test("Stripe Checkout receipt arrives + has correct line items", async ({ page, inbox }) => {
  // 1. Hit your app's checkout endpoint with the disposable inbox address.
  await page.goto("https://your-app.test/pricing");
  await page.fill('input[name="email"]', inbox.address);
  await page.click('[data-testid="subscribe-pro"]');

  // 2. Land on Stripe-hosted Checkout. Fill the test card.
  await page.waitForURL(/checkout\.stripe\.com/);
  await page.fill('input[name="cardNumber"]', "4242424242424242");
  await page.fill('input[name="cardExpiry"]', "12/30");
  await page.fill('input[name="cardCvc"]', "123");
  await page.fill('input[name="billingName"]', "Test Customer");
  await page.click('button[type="submit"]');

  // 3. Wait for Stripe to redirect back to your success page.
  await page.waitForURL(/your-app\.test\/success/);

  // 4. Long-poll MailSink for the receipt email. Stripe usually sends
  //    within 10-30s of payment succeeding.
  const waitFor = await fetch(
    `${API}/v1/inboxes/${inbox.id}/wait-for-code?timeout=60`,
    { headers: { Authorization: `Bearer ${MAILSINK_API_KEY}` } },
  );
  // wait-for-code blocks until ANY message arrives even if no OTP code.
  // For receipt assertions, use list_messages directly:
  const list = await fetch(`${API}/v1/inboxes/${inbox.id}/messages`, {
    headers: { Authorization: `Bearer ${MAILSINK_API_KEY}` },
  });
  const { messages } = await list.json();
  expect(messages.length).toBeGreaterThan(0);

  // 5. Assert on receipt content.
  const receipt = messages[0];
  expect(receipt.from_address).toMatch(/@stripe\.com$/);
  expect(receipt.subject).toMatch(/receipt|payment|invoice/i);
  expect(receipt.text_preview).toContain("Pro plan"); // your plan name
  expect(receipt.text_preview).toContain("$15.00"); // your price
});

That’s the whole loop. Real signup → real card submit → real Stripe → real email → real assertions.

Variations

Email verification (your app sends, not Stripe)

Same fixture. Just change the assertion:

const verify = messages.find((m) => m.subject.includes("Verify your email"));
expect(verify).toBeTruthy();
expect(verify.extracted_link).toMatch(/your-app\.test\/verify\?token=/);
// Click the link directly:
await page.goto(verify.extracted_link);
await expect(page.locator('[data-testid="verified-banner"]')).toBeVisible();

extracted_link is the first URL MailSink finds in the message body. For magic-link auth and verification tokens, this is what you assert against.

Failed payment dunning email

Use Stripe’s failed-payment test card: 4000000000000341 (succeeds initially, fails on the second invoice).

// Trigger a failed payment via Stripe CLI in CI setup:
// $ stripe trigger invoice.payment_failed
const failure = messages.find((m) => /payment.*failed|action required/i.test(m.subject));
expect(failure).toBeTruthy();

Multiple emails in one test

Stripe might send both a receipt AND a 3DS verification challenge depending on test card. Loop:

const emails = messages;
const receipt = emails.find((m) => /receipt/i.test(m.subject));
const verify3ds = emails.find((m) => /authenticate|verify your purchase/i.test(m.subject));

CI integration (GitHub Actions)

.github/workflows/e2e.yml:

name: e2e

on: [pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          MAILSINK_API_KEY: ${{ secrets.MAILSINK_API_KEY }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}

Two secrets in GitHub repo settings: MAILSINK_API_KEY (from your dashboard) and STRIPE_TEST_SECRET_KEY (from Stripe Dashboard → Developers → API keys, test mode).

Cost: how many inboxes per month?

Back of envelope:

Free tier (50/month) covers 10 PRs. Pro ($15/mo) covers 13 PRs/day or 400/month, comfortable for active product teams.

If you parallelize: each Playwright worker creates its own inbox via the fixture. 4 parallel workers × 5 tests × 30 PRs = 600 inboxes/month. Still inside Pro.

Common follow-up questions

Does the test work if Stripe takes longer than 60s to send the receipt?

Bump the timeout. wait-for-code?timeout=300 gives 5 minutes. In practice Stripe receipts arrive in 10-30s for test mode.

Can I use the same inbox for multiple tests?

You can but don’t. Test isolation is the point. The fixture creates+destroys per test.

What about Stripe’s Hosted Invoice Page (hosted_invoice_url)?

Same pattern. Submit payment via the hosted invoice URL, then poll MailSink for the invoice.payment_succeeded email Stripe sends.

Can I test webhooks too?

Yes, separately. Use Stripe CLI stripe listen --forward-to localhost:3000/webhook for webhook handler tests. Use MailSink for the email side. They cover different layers.

What if Stripe blocks the disposable domain?

Stripe doesn’t validate customer_email against disposable-email blocklists in test mode. Production they do, but you wouldn’t be using a test inbox there anyway.

Try it

Pick one Stripe flow you’ve been testing with mocks. Replace the mock with a MailSink fixture using the code above. Run it. The first time it catches a real bug - most likely a typo in your metadata showing up on the receipt - that’s when this earns its keep.

If something’s broken or unclear, ping [email protected].

Next