ProductHow it worksPricingBlogDocsLoginFind Your First Bug
How to test Stripe Checkout: diagram of a Playwright frameLocator reaching through a cross-origin iframe into Stripe's card input fields
TestingStripePayments

How to Test Stripe Checkout (Past the Bot Wall)

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

To automate a Stripe Checkout test, switch to test mode with test keys, load captcha test keys that always pass, drive the card iframe with frameLocator, pay with a test card, and assert on both the confirmation redirect and the webhook. The main obstacles are the cross-origin card iframe and Stripe's bot detection layer, both of which have official, supported workarounds for test environments.

Stripe's own documentation is candid about this: Checkout and Elements are designed to resist automation. That framing is not accidental. Stripe builds fraud protection into the payment form itself, and the same mechanisms that block real bots also block Playwright. Most teams discover this when their first E2E test stalls indefinitely trying to type a card number into a field that appears in the DOM but never accepts input.

The good news is that there is a documented path through. It requires coordinating three different moving parts (test mode, captcha test keys, and frameLocator), none of which is hard on its own, but together they are almost never documented in one place. This walkthrough puts them together. It is the Stripe-specific piece of a broader payment gateway testing strategy, and if you want the provider-agnostic version, see our guide to end-to-end checkout flow testing.

Autonoma-generated checkout tests still need those same test-mode boundaries. The difference is that the browser path and selector plan are derived from the codebase instead of hand-maintained as Stripe's hosted UI changes.

Why automated Stripe Checkout gets blocked

Two separate problems cause most Stripe automation failures. Understanding both makes the solution clearer.

The cross-origin iframe. Stripe renders its card input fields inside an iframe served from js.stripe.com, not your application's domain. That is a different origin. By default, browser automation frameworks cannot interact with elements inside cross-origin iframes using normal selectors because the browser's same-origin security policy restricts access. You can find the iframe element in the DOM, but targeting fields inside it with a plain page.locator() call will silently miss or throw. This is not a Playwright limitation specific to Stripe; it applies any time the iframe is on a different origin.

Bot detection on hosted Checkout. When you use Stripe's hosted Checkout page (the one Stripe redirects to at checkout.stripe.com), Stripe runs bot detection checks before allowing the payment flow to proceed. Depending on your account and configuration, this may include a captcha challenge. An automated browser without the right keys will either fail the challenge silently or get stuck indefinitely.

Both problems have test-environment solutions that Stripe and captcha providers publish officially. Neither is a hack.

Stripe Checkout architecture diagram showing Playwright crossing into the Stripe iframe, app server test keys, bot detection, and webhook assertions
The full Stripe Checkout test path crosses the hosted iframe, bot-detection layer, server-side test keys, and webhook event stream.

The walkthrough: 5 steps to a passing Stripe Checkout test

Five-step Stripe Checkout test setup diagram covering test mode keys, captcha keys, frameLocator, test cards, and redirect plus webhook assertions
The passing setup has five moving parts: test keys, captcha test credentials, frameLocator, Stripe test cards, and order-independent assertions.

Step 1: Switch to test mode with test API keys

All Stripe accounts have two key pairs: live and test. In test mode, Stripe's infrastructure processes payments using its own simulated network, no real charges happen, and test cards are accepted.

Use keys that begin with pk_test_ (publishable) and sk_test_ (secret) in your test environment. Your application should read these from environment variables, so switching is a matter of setting the right env vars when running E2E tests. Never hardcode live keys in test configuration and never accidentally use live keys in your test suite.

In test mode, Stripe also relaxes some fraud checks. That matters for making the iframe reachable in the next steps.

Step 2: Handle the captcha to test Stripe Checkout without getting blocked

If your Stripe Checkout integration uses reCAPTCHA or hCaptcha, the captcha challenge will block automated test runs. Both providers publish official test site keys and secret keys specifically for automated testing environments. These keys are designed to always return a passing token when used, without presenting a visual challenge.

For reCAPTCHA v2, Google documents a test site key that always passes verification. For hCaptcha, Cloudflare publishes an equivalent test credential set. The mechanism is the same in both cases: you configure your test environment to use the captcha provider's test credentials rather than your production credentials. The test key pair is recognized by the captcha backend as a testing signal and returns success unconditionally.

This is not bypassing or defeating real bot detection. You are using credentials the provider designed for exactly this purpose, in your own test environment, where no real users or real fraud risk exists. The production environment continues to run real captcha validation; only your test environment uses the test keys.

Check the current documentation for your captcha provider to get the exact key values. Key strings change occasionally, and printing a specific value here risks directing you to a stale credential.

Step 3: Drive the cross-origin iframe with Playwright frameLocator

This is the technique that unlocks the card fields. Playwright's frameLocator API lets you scope selectors into a specific iframe, including cross-origin ones, by matching the iframe element itself and then chaining locators within it.

The pattern looks like this at a high level: you locate the iframe that Stripe renders (typically matchable by a URL pattern or a title attribute), call frameLocator() on the page to get a scoped locator context, and then chain normal locator() and fill() calls within that context. Playwright handles the cross-origin traversal internally.

/**
 * Stripe Checkout E2E test using Playwright.
 *
 * Prerequisites:
 *   - Node 18+
 *   - STRIPE_PUBLISHABLE_KEY_TEST set in environment (pk_test_...)
 *   - STRIPE_SECRET_KEY set in environment (sk_test_...) — used to create a test session
 *   - Playwright installed: npx playwright install chromium
 *
 * Run: npx playwright test tests/stripe-checkout.spec.ts
 *
 * The test:
 *   1. Creates a Stripe Checkout Session via the API (server-side, no UI needed)
 *   2. Navigates to the hosted Checkout URL
 *   3. Uses frameLocator to reach into Stripe's cross-origin card iframe
 *   4. Fills the test card (4242 4242 4242 4242 — always succeeds)
 *   5. Submits and asserts on the success redirect URL
 *   6. Asserts the checkout.session.completed event was received (order-independently)
 */

import { test, expect } from "@playwright/test";
import Stripe from "stripe";

// ---------------------------------------------------------------------------
// Configuration — set these in your .env file or CI environment variables
// ---------------------------------------------------------------------------
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY ?? "";
const SUCCESS_URL = process.env.SUCCESS_URL ?? "http://localhost:4242/success";
const CANCEL_URL = process.env.CANCEL_URL ?? "http://localhost:4242/cancel";

// Webhook endpoint that the webhook/server.js listener exposes
const WEBHOOK_ENDPOINT = process.env.WEBHOOK_ENDPOINT ?? "http://localhost:4242/webhook/events";

// ---------------------------------------------------------------------------
// Helper: create a Stripe Checkout Session via the API
// ---------------------------------------------------------------------------
async function createCheckoutSession(stripe: Stripe): Promise<{ url: string; sessionId: string }> {
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ["card"],
    line_items: [
      {
        price_data: {
          currency: "usd",
          product_data: { name: "Test Product" },
          unit_amount: 1000, // $10.00
        },
        quantity: 1,
      },
    ],
    mode: "payment",
    success_url: `${SUCCESS_URL}?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: CANCEL_URL,
  });

  if (!session.url) {
    throw new Error("Stripe did not return a checkout URL for the created session.");
  }

  return { url: session.url, sessionId: session.id };
}

// ---------------------------------------------------------------------------
// Helper: poll the webhook listener's event log for the session
// ---------------------------------------------------------------------------
async function pollForWebhookEvent(
  sessionId: string,
  timeoutMs = 30_000,
  intervalMs = 500
): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    try {
      const response = await fetch(`${WEBHOOK_ENDPOINT}?session_id=${sessionId}`);
      if (response.ok) {
        const data = (await response.json()) as { received: boolean };
        if (data.received) {
          return true;
        }
      }
    } catch {
      // Webhook listener not yet ready or network blip — continue polling
    }

    await new Promise<void>((resolve) => setTimeout(resolve, intervalMs));
  }

  return false;
}

// ---------------------------------------------------------------------------
// Test
// ---------------------------------------------------------------------------
test.describe("Stripe Checkout E2E", () => {
  test("completes a payment with a test card and receives the webhook", async ({ page }) => {
    if (!STRIPE_SECRET_KEY.startsWith("sk_test_")) {
      test.skip(true, "STRIPE_SECRET_KEY is not set or is not a test key — skipping.");
    }

    const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-05-28.basil" });

    // Step 1: Create a Checkout Session server-side
    const { url: checkoutUrl, sessionId } = await createCheckoutSession(stripe);

    // Step 2: Navigate to hosted Checkout
    await page.goto(checkoutUrl, { waitUntil: "domcontentloaded" });

    // Step 3: Locate the Stripe card iframe.
    //
    // Stripe renders the card number, expiry, and CVC inside iframes served
    // from js.stripe.com. The iframes are cross-origin. Playwright's frameLocator
    // API lets you scope selectors inside them.
    //
    // The iframe selector below matches by the title attribute Stripe sets.
    // If Stripe changes the attribute, update this selector.
    const cardNumberFrame = page.frameLocator('iframe[title="Secure card number input frame"]');
    const expiryFrame = page.frameLocator('iframe[title="Secure expiration date input frame"]');
    const cvcFrame = page.frameLocator('iframe[title="Secure CVC input frame"]');

    // Step 4: Fill the test card details
    // Card 4242 4242 4242 4242 — always succeeds in Stripe test mode
    await cardNumberFrame.locator('[name="cardnumber"], [autocomplete="cc-number"], input').fill("4242424242424242");
    await expiryFrame.locator('[name="exp-date"], [autocomplete="cc-exp"], input').fill("12/28");
    await cvcFrame.locator('[name="cvc"], [autocomplete="cc-csc"], input').fill("123");

    // Some Stripe Checkout layouts include email and name fields outside iframes
    const emailField = page.locator('input[type="email"]');
    if (await emailField.isVisible({ timeout: 2_000 }).catch(() => false)) {
      await emailField.fill("test@example.com");
    }

    // Step 5: Assert on both the redirect URL and the webhook — order-independently
    //
    // The checkout.session.completed webhook and the success redirect do not
    // arrive in a guaranteed order. Using Promise.all lets both assertions
    // resolve concurrently regardless of which lands first.
    const submitButton = page.locator('button[type="submit"], button:has-text("Pay"), button:has-text("Subscribe")').first();
    await submitButton.click();

    const [webhookReceived] = await Promise.all([
      // Webhook assertion: poll the listener for the event
      pollForWebhookEvent(sessionId),

      // Redirect assertion: wait for the browser to reach the success URL
      page.waitForURL(`${SUCCESS_URL}**`, { timeout: 30_000 }),
    ]);

    // Confirm redirect
    expect(page.url()).toContain(SUCCESS_URL.replace("http://localhost:4242", ""));

    // Confirm webhook
    expect(webhookReceived).toBe(true);
  });
});

A note on Cypress: Cypress cannot natively traverse cross-origin iframes the same way. Cross-origin iframe access is restricted in Cypress by default, and teams typically reach for community plugins or workarounds to handle it. This is one of the practical reasons Playwright is the cleaner choice for Stripe Checkout automation. The plugin ecosystem exists, but it adds complexity and maintenance surface that frameLocator avoids entirely.

Step 4: Fill a test card

With the iframe reachable via frameLocator, filling the card number, expiry, and CVC follows the same pattern as any other form. Use Stripe's published test cards for specific scenarios: successful payment, insufficient funds, card declined, 3D Secure required, and so on.

Our guide to Stripe test cards covers the full set of card numbers for each scenario. Use that as your reference rather than enumerating them here. Each scenario number maps to a specific behavior in Stripe's test network.

Step 5: Assert on the confirmation redirect and queue the webhook assertion

After filling the card and submitting, Stripe redirects the browser to your success URL. That redirect is one assertion point: page.waitForURL() matching your expected success path confirms the browser-side flow completed.

The webhook is the other assertion point. Asserting on the redirect alone is not enough. The redirect tells you the browser flow succeeded. The webhook tells you Stripe actually processed the event and your backend received it. Both are necessary for a complete payment test.

The webhook assertion requires a different setup. See the next section.

Asserting the webhook (order-independently)

The checkout.session.completed and payment_intent.succeeded events and the browser success redirect do not arrive in a guaranteed order. Stripe fires the webhook as soon as the payment intent transitions state on its backend. Your browser redirect happens after the hosted Checkout page processes the result. In practice, the webhook often arrives before the redirect completes, but sometimes the redirect lands first. A test that asserts "redirect happened, then webhook arrived" will pass most of the time and fail occasionally. That is a flaky test.

The robust pattern is order-independent design: capture the webhook and the redirect independently, then assert on both without assuming which lands first.

For the webhook side, there are two practical approaches. The Stripe CLI's stripe listen command forwards webhooks from Stripe's infrastructure to a local endpoint, which works in local development and CI environments that can expose a port. Alternatively, you can record an expected webhook event at test setup time by querying the Stripe API directly (using the test secret key) to retrieve the most recent event for the session you triggered. This second approach does not require network exposure and works reliably in CI.

/**
 * Stripe webhook listener — validates Stripe-Signature and logs
 * checkout.session.completed events.
 *
 * Prerequisites:
 *   - Node 18+
 *   - STRIPE_SECRET_KEY  : your Stripe test secret key (sk_test_...)
 *   - STRIPE_WEBHOOK_SECRET : webhook signing secret from the Stripe Dashboard
 *                             or the output of `stripe listen`
 *
 * Run:
 *   node webhook/server.js
 *
 * Forward live Stripe events to this server in development:
 *   stripe listen --forward-to localhost:4242/webhook
 *
 * The server also exposes GET /webhook/events?session_id=<id> so that the
 * Playwright test can poll for receipt of a specific checkout.session.completed
 * event without coupling the order of the redirect and the webhook.
 */

import express from "express";
import Stripe from "stripe";

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4242;
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY ?? "";
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? "";

if (!STRIPE_SECRET_KEY.startsWith("sk_test_")) {
  console.error(
    "ERROR: STRIPE_SECRET_KEY is missing or not a test key.\n" +
    "Set STRIPE_SECRET_KEY=sk_test_... in your environment before starting the server."
  );
  process.exit(1);
}

if (!STRIPE_WEBHOOK_SECRET) {
  console.error(
    "ERROR: STRIPE_WEBHOOK_SECRET is missing.\n" +
    "Set STRIPE_WEBHOOK_SECRET to the signing secret from the Stripe Dashboard\n" +
    "or from the output of `stripe listen --forward-to localhost:4242/webhook`."
  );
  process.exit(1);
}

// ---------------------------------------------------------------------------
// State: in-memory log of received checkout.session.completed events
// In production you would persist this; for testing an in-memory map is fine.
// ---------------------------------------------------------------------------

/** @type {Map<string, import('stripe').Stripe.Checkout.Session>} */
const completedSessions = new Map();

// ---------------------------------------------------------------------------
// Stripe client
// ---------------------------------------------------------------------------
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-05-28.basil" });

// ---------------------------------------------------------------------------
// Express app
// ---------------------------------------------------------------------------
const app = express();

/**
 * POST /webhook
 *
 * Receives Stripe webhook events. Uses express.raw() for the body so that
 * stripe.webhooks.constructEvent can verify the Stripe-Signature header.
 * Never parse the body as JSON before passing it to constructEvent — the
 * raw bytes are required for signature verification.
 */
app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];

    if (!sig) {
      console.error("Webhook rejected: missing Stripe-Signature header.");
      return res.status(400).send("Missing Stripe-Signature header.");
    }

    let event;
    try {
      event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
    } catch (err) {
      console.error(`Webhook signature verification failed: ${err.message}`);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Handle the event
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;
        completedSessions.set(session.id, session);
        console.log(`checkout.session.completed — session ${session.id}, payment status: ${session.payment_status}`);
        break;
      }

      case "payment_intent.succeeded": {
        const paymentIntent = event.data.object;
        console.log(`payment_intent.succeeded — ${paymentIntent.id}, amount: ${paymentIntent.amount}`);
        break;
      }

      default:
        // Log other event types but do not act on them
        console.log(`Unhandled event type: ${event.type}`);
    }

    // Acknowledge receipt — always return 200 promptly
    res.status(200).json({ received: true });
  }
);

/**
 * GET /webhook/events?session_id=<id>
 *
 * Polling endpoint used by the Playwright test to check whether a specific
 * checkout.session.completed event has been received.
 *
 * Returns: { received: true | false, session?: object }
 */
app.get("/webhook/events", (req, res) => {
  const sessionId = req.query.session_id;

  if (!sessionId || typeof sessionId !== "string") {
    return res.status(400).json({ error: "Missing or invalid session_id query parameter." });
  }

  if (completedSessions.has(sessionId)) {
    return res.status(200).json({ received: true, session: completedSessions.get(sessionId) });
  }

  res.status(200).json({ received: false });
});

/**
 * GET /success  (convenience stub for local testing without a real success page)
 */
app.get("/success", (req, res) => {
  const sessionId = req.query.session_id ?? "unknown";
  res.status(200).send(`
    <!DOCTYPE html>
    <html lang="en">
    <head><title>Payment successful</title></head>
    <body>
      <h1>Payment successful</h1>
      <p>Session: ${sessionId}</p>
    </body>
    </html>
  `);
});

// ---------------------------------------------------------------------------
// Start server
// ---------------------------------------------------------------------------
app.listen(PORT, () => {
  console.log(`Stripe webhook listener running on http://localhost:${PORT}`);
  console.log(`Webhook endpoint: POST http://localhost:${PORT}/webhook`);
  console.log(`Event poll endpoint: GET http://localhost:${PORT}/webhook/events?session_id=<id>`);
  console.log();
  console.log("To forward Stripe events in development:");
  console.log(`  stripe listen --forward-to localhost:${PORT}/webhook`);
});

In either case, the assertion flow is: start listening or polling before triggering the payment, trigger the payment, await both the redirect URL assertion and the webhook event assertion, and do not couple the order. If your test framework supports Promise.all()-style parallel awaiting (Playwright does), use it: resolve both assertions concurrently and let whichever arrives first proceed without blocking the other.

The webhook listener reference implementation in the companion repo validates the Stripe-Signature header (always validate in tests, not just production, so the test reflects your real behavior) and logs checkout.session.completed events.

How Autonoma handles Stripe Checkout testing

The walkthrough above is a lot of coordinated setup. Test mode keys in the right env var, captcha test credentials wired into the right config layer, frameLocator chains matching the right iframe, order-independent webhook assertions that do not race. Each piece is individually manageable. Together, they form a test that is fragile in a specific way: Stripe changes its Checkout markup periodically, and when it does, the frameLocator selectors that reached into the card iframe stop matching. The test breaks silently (it times out) rather than loudly (a clear assertion failure), which makes diagnosing the cause slow.

Autonoma approaches this differently. Instead of writing and maintaining the selector logic manually, our agents read your codebase and generate the E2E tests from the code itself.

The Planner agent reads your routes, checkout components, and payment integration code to plan test cases, including what state the database needs for each scenario. The Executor agent runs those test cases by driving the actual checkout UI in a live preview environment, handling the iframe traversal as part of execution. The Reviewer agent classifies each result: real bug, agent error, or test/plan mismatch. When Stripe's Checkout markup changes, the Diffs Agent picks up the change on the next PR that touches your checkout code and updates the test cases to match.

Autonoma covers web E2E flows derived from your codebase. It is not a magic captcha bypass, and it does not replace the test-mode key setup that Stripe requires. What it removes is the ongoing maintenance burden of keeping the selector logic aligned as Stripe evolves its hosted Checkout page.

Frequently Asked Questions

Yes, with the right setup. You need test mode API keys, captcha test credentials from your captcha provider (reCAPTCHA or hCaptcha both publish official test keys that always pass), and Playwright's frameLocator API to reach into Stripe's cross-origin card iframe. Cypress requires additional plugins for the cross-origin iframe step. The walkthrough above covers all three pieces.

Stripe's hosted Checkout and Elements components include bot detection as part of their fraud prevention layer. The card input fields are served from a different origin (js.stripe.com or checkout.stripe.com) inside an iframe, which browser security policy restricts from direct selector access. Stripe also runs captcha checks on the hosted Checkout page. These mechanisms protect real users from fraud but also block naive automation. Stripe documents test-environment workarounds for both obstacles.

Use Playwright's frameLocator API. Call page.frameLocator() with a selector that matches the iframe element Stripe renders, then chain locator() and fill() calls within that scoped context. Playwright handles the cross-origin traversal internally. The iframe is typically matchable by its src URL pattern or a title attribute. See the companion repo for a reference implementation.

Load the captcha provider's official test keys in your test environment instead of your production credentials. Both reCAPTCHA (Google) and hCaptcha (Cloudflare) publish test site keys and secret keys designed to always return a passing token without presenting a visual challenge. Configure your test environment to use these keys. This is the officially supported approach for automated testing; it is not a bypass of real bot detection.

Assert on webhooks order-independently from the browser redirect. The checkout.session.completed webhook and the success redirect do not arrive in a guaranteed order. Use the Stripe CLI's stripe listen command to forward webhooks to a local endpoint, or query the Stripe API directly after triggering the payment to retrieve the event for the session. Assert on both the redirect URL and the webhook event using parallel awaiting (Promise.all) rather than assuming a fixed sequence.

Related articles

Diagram showing AI-generated auth code without a baseline: an agent writes login code on one side, while expected auth behavior (valid login, rejected password, protected route redirect) must be defined explicitly on the other

How to Test the Auth Code an AI Agent Wrote

When an AI agent writes your authentication, there is no baseline for correct behavior. Here is how to test AI-generated code for the auth bugs that compile, pass review, and lock users out.

Split diagram showing code that compiles cleanly on the left and a broken login flow at runtime on the right, illustrating what AI code review cannot see

Why AI Code Review Misses Auth Bugs

AI code review catches structure and style. It cannot catch a dropped auth wrapper or broken login flow. Here is what code review misses and why E2E testing fills the gap.

A dark dashboard showing a green CI status bar above a support queue full of red error tickets, representing the production lockout caused by a silent AI coding agent auth wrapper omission

When Vibe Coding Broke Authentication in Production

An AI coding agent silently omitted an auth wrapper during a refactor. CI stayed green. Every user was locked out. Here is the failure mode and the only fix that works.

Abstract diagram of an email envelope connecting to a browser click target, representing the magic-link authentication round-trip flow

How to Test Magic Link and Passwordless Login

How to test magic link authentication: capture the link from a test inbox API, assert it is single-use and expires, and tame the flakiest auth test you own.