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:
- Subscription checkout → receipt email (Stripe sends
invoice.payment_succeededand a receipt) - Customer email verification (your app sends a “confirm your email” mail after Stripe Checkout completes)
- Failed-payment dunning email (Stripe sends
invoice.payment_failedafter a card decline you simulated with4000000000000341)
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:
- Email template rendering breaks. Real Stripe emails go through Stripe’s template engine. If your
customer_emailhad a Unicode char that broke their template, mocked tests pass but real flow fails. - Stripe metadata round-trips matter. What you put in
metadataon the Checkout Session shows up in the webhook payload AND on the receipt PDF. A typo there shows up on real customers’ receipts. - Spam filtering varies by domain. If your
from_addresssometimes gets caught by Gmail’s spam filter, mocked tests don’t catch that. Tests against a real disposable address do.
Setup
You need three things:
- A Stripe test-mode secret key + a working Stripe Checkout endpoint in your app.
- A MailSink API key. Sign up free for 50 inboxes/month, or try anonymously first to feel it out.
- 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:
- 1 inbox per test
- 5 Stripe-related tests in the suite
- 30 PRs/month, each triggering 5 tests = 150 inboxes/month
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
- Verify OTP in Playwright (no regex) - the broader pattern
- Cypress version of this guide
- MCP email server for Claude Code agents
- MailSink API reference
- Stripe test cards for failure scenarios