<objective>
Payment flows fail in ways generic E2E tests miss: a card field in a cross-origin iframe
that `page.locator` silently never reaches, a renewal that won't trigger for a year, a
webhook handler that "works" until Stripe retries and fulfills an order twice, an order
marked paid on a redirect that the browser forged. This skill makes you test payments the
way they actually break — against PSP sandboxes, with the real test cards, the nested 3DS
challenge, server-side test clocks, signature-verified webhooks, and idempotent
fulfillment. Never a real PAN, never a live key.
</objective>
Quick Route
| You need to test… | Go to | Reference |
|---|
| Success / decline / insufficient-funds outcomes | Test cards | references/stripe-test-cards.md
|
| A 3DS/SCA challenge that pops a modal | 3DS challenge | references/playwright-3ds.md
|
| A subscription renewal months/years out | Test clocks | references/webhooks-and-clocks.md
|
| Webhooks reaching localhost + signatures | Webhooks | references/webhooks-and-clocks.md
|
| A failed renewal then a refund | Failed payments | references/webhooks-and-clocks.md
|
| Fulfillment only after real payment | Reconciliation | references/webhooks-and-clocks.md
|
| Adyen / PayPal / Braintree sandboxes | Multi-PSP | |
Discovery Questions
First, check
.agents/qa-project-context.md
in the project root and skip anything it
already answers (PSP, stack, test framework, existing fixtures). Then clarify:
- Which PSP, and is it Stripe? Stripe is the default here. Other PSPs share the pattern
but have their own sandbox cards/accounts — see Multi-PSP.
- One-time payments, subscriptions, or both? Subscriptions pull in test clocks, dunning,
and the lifecycle; one-time payments don't.
- Is SCA/3DS in play? EU/UK card flows almost always challenge. If yes you need the
nested-iframe pattern, not plain locators.
- Do you fulfill on a webhook or on the redirect today? If fulfillment happens on
, that's the bug to test for — fulfillment must wait for the verified webhook.
- Where does the webhook handler run in tests? Local () vs a deployed
preview env changes how you deliver events.
Core Principles
-
Never touch a real card or a live key — and this is non-negotiable, not a preference.
Real PANs in any environment violate Stripe's Services Agreement and drag your repo into
PCI scope. Test mode (
/
) with Stripe's published test cards is the
only correct answer. Masking or encrypting a real number does not fix it; removing it
does.
-
Money is confirmed server-side, never client-side. A redirect, an
callback, or a
query param can be premature, replayed, or forged.
Fulfill an order only after a signature-verified
webhook,
re-confirmed with an API
.
-
Verify the signature before you parse the body. Parsing JSON first destroys the raw
bytes
needs. The webhook route gets the raw body; everything else can
parse JSON.
-
Time is a server-side construct for billing. Stripe's billing engine runs on
Stripe's servers. Faking the clock in your test process changes nothing. Use test clocks.
-
Assume every webhook is delivered more than once. Stripe retries. Idempotency keyed
on
in durable storage is mandatory; an in-memory set is not idempotency.
Stripe Test Cards and Outcomes
Drive each outcome with the card that deterministically produces it. The four you need most:
| PAN | Outcome | Code |
|---|
| Succeeds | — |
| Declined | (generic_decline) |
| Declined | |
| 3DS always challenges | — |
| Attaches, then fails on charge | |
Use test keys (
/
) in the app under test and assert that in setup.
Do not use — that is a generic Braintree/PayPal-era Luhn number,
not a Stripe test card, and it does not deterministically decline.
The card field is in a cross-origin Stripe iframe, so fill it through
, never
directly. Assert outcomes on UI copy for a smoke test, or more robustly on
the server-side
last_payment_error.decline_code
from
. Full
Playwright tests for success /
/
:
references/stripe-test-cards.md
.
3DS / SCA: The Nested-Iframe Challenge
This is the hardest part to get right. The Stripe 3DS challenge is
a frame nested inside
the Stripe modal frame — a single
cannot reach it. Chain
outer → inner, then click
Complete authentication.
What fails, and why:
- → the input is cross-origin; the locator matches nothing.
- → frame index shifts when Stripe adds/reorders frames. Never select
frames by index.
await page.waitForTimeout(5000)
→ guessing the challenge duration. Wait on the element.
The correct shape (full test, including the fail-authentication variant, in
references/playwright-3ds.md
):
ts
// 3DS-required card so the challenge always appears.
await card.getByPlaceholder('Card number').fill('4000000000003220');
await page.getByRole('button', { name: /pay/i }).click();
// Nested: outer Stripe challenge frame → inner ACS frame. One frameLocator is not enough.
const inner = page
.frameLocator('iframe[name^="__privateStripeFrame"]')
.frameLocator('iframe#challengeFrame, iframe[name="acsFrame"]');
await inner.getByRole('button', { name: /complete authentication|complete|authorize/i }).click();
await expect(page).toHaveURL(/\/success/); // assert the succeeded state
await expect(page.getByText(/payment succeeded/i)).toBeVisible();
is the alternative SCA card for setup-intent / first-use flows; the eval
and docs accept it where a one-time-payment 3DS card is wanted.
Test Clocks: Server-Side Time Travel
To test an annual renewal without waiting a year, use a Stripe
test clock — a
server-side construct. Client-side fakes (
, sinon, mocking
) do
nothing to Stripe's billing engine.
Rules that bite if missed:
- Create the clock at a , then attach the customer at creation with
. You cannot attach an existing customer to a clock afterward.
testHelpers.testClocks.advance
moves time forward only — you cannot rewind. Advance
at most two billing cycles per call.
- After advancing, poll the clock to , then assert the renewal invoice and webhooks.
ts
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000), name: 'annual-renewal',
});
const customer = await stripe.customers.create({ test_clock: clock.id /* … */ });
// …create subscription, then advance ~12 months forward:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: oneYearLater });
Full create/advance/assert flow:
references/webhooks-and-clocks.md
(section 4).
Webhooks: Local Delivery, Signatures, Idempotency
Local delivery. Do not expose your endpoint with ngrok and do not poll the API for
status.
tunnels test events to localhost natively;
fires
them on demand:
bash
stripe listen --forward-to localhost:3000/webhooks # prints whsec_… ONCE at startup
stripe trigger payment_intent.succeeded
Copy that
into
. It is the
signing secret, a different
value from
(
) — do not conflate them.
Signature verification. Mount
on the webhook route
before any global
, so
gets the raw body. A forged or tampered event must be
rejected with
400; never hand-roll a
string comparison.
ts
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'] as string;
try {
const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
return handleEvent(event, res);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
}
});
app.use(express.json()); // everything else, after the webhook route
Idempotency. Stripe retries delivery, so the same
arrives twice. Request-side
idempotency keys (for outbound API calls) do not dedup inbound webhooks. Store
with a
UNIQUE constraint and short-circuit on conflict; an in-memory set is lost on
restart and useless across instances. The handler returns
200 for a duplicate so Stripe
stops retrying, and fulfillment runs exactly
once.
ts
const inserted = await db.query(
`INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id`, [event.id]);
if (inserted.rowCount === 0) return res.status(200).send('duplicate ignored');
The signature test (valid accepted, forged → 400) and the "deliver the same event twice,
assert fulfilled once" idempotency test are in
references/webhooks-and-clocks.md
(sections 2–3).
Failed Payments: Dunning and Refunds
To test a failed recurring charge end to end, subscribe with
(SDK token
pm_card_chargeCustomerFail
) — it
attaches to the customer but
fails on the later
charge, which is what the renewal needs. Cards that decline at attach time can't be saved,
so they can't model a renewal failure.
Drive the lifecycle with a test clock:
- Subscribe the customer (on a test clock) with the attach-then-fail card.
- the clock past the renewal date → Stripe attempts the charge.
- The charge fails → Stripe emits and the subscription goes
. Assert both.
- Resolve by issuing a refund with (fires ) — do
not "fix" it by deleting the subscription.
Full driver in
references/webhooks-and-clocks.md
(section 5).
Reconciliation: Fulfill on the Webhook, Not the Redirect
Mark an order paid
only after a signature-verified
webhook,
re-confirmed against the API — never on the
redirect or a client-side success
flag, and never by polling with a sleep.
ts
if (event.type === 'payment_intent.succeeded') {
const verified = await stripe.paymentIntents.retrieve(event.data.object.id);
if (verified.status === 'succeeded' && verified.amount_received === expected) {
await markOrderPaid(verified.metadata.orderId); // fulfillment happens HERE
}
}
The reconciliation test asserts the order is still
after the redirect and only
after the verified webhook:
references/webhooks-and-clocks.md
(section 6).
Multi-PSP: Adyen, PayPal, Braintree
Stripe test cards do not work on other PSPs. Each has its own sandbox cards and sandbox
buyer accounts. Port the structure of your Stripe tests; swap in the PSP's sandbox values.
Never reuse Stripe PANs or live/production keys.
- Adyen — own test cards (e.g. for 3DS2); many declines are driven by
the transaction amount ( refused, referral), not the card. Events arrive as
HMAC-signed notifications.
- PayPal — log in with a sandbox buyer account (sandbox personal email/password), not
a card. Confirm server-side via the Orders API / webhooks, not the client .
- Braintree — own sandbox test card numbers via Drop-in UI / Hosted Fields; amount
drives transaction outcome, card number drives verification.
What stays the same: separate test/sandbox credentials, no real card, and fulfillment on the
verified server-side event/notification. Details:
.
Anti-Patterns
1. Reaching for
That Luhn-valid number is a Braintree/PayPal-era generic PAN, not a Stripe test card. Use
for success and the specific decline cards (
,
).
2. Treating the card field as a normal input
page.locator('#card-number')
silently matches nothing because the field is in a
cross-origin iframe. Use
.
3. Selecting iframes by index
breaks the instant Stripe reorders frames. Match the frame by a stable
name prefix (
iframe[name^="__privateStripeFrame"]
) and chain
for the nested
3DS challenge.
4. to "wait for the challenge"
Flaky on slow CI, wasteful on fast CI. Wait on the element (
expect(...).toBeVisible()
/
auto-waiting locator actions), never the clock.
5. Client-side time mocking for billing
/ sinon / mocking
cannot move Stripe's server-side billing.
Use a test clock.
6. ngrok or polling for local webhooks
stripe listen --forward-to localhost:3000/webhooks
tunnels events natively;
fires them. No public tunnel, no status polling.
7. Parsing the body before verifying the signature
A global
ahead of the webhook route destroys the raw body
needs, so verification can never pass. Mount
on the webhook
route first.
8. Confusing the request idempotency key with webhook dedup, or using an in-memory set
Outbound idempotency keys don't dedup inbound webhooks; an in-memory
dies on restart.
Persist
with a UNIQUE constraint.
9. Fulfilling on the redirect / client success flag
The
can be premature, replayed, or forged. Fulfill only on the verified
webhook.
10. "Fixing" a failed renewal by deleting the subscription
The correct resolution is a refund via
, leaving the dunning lifecycle
(
→
) intact and testable.
11. Rationalizing a real card "just in CI"
A hardcoded real PAN is a PCI/compliance violation regardless of environment. The fix is a
test card in test mode — plus removing the secret from the repo and git history and rotating
any exposed key. Masking or encrypting it does not make it acceptable.
Verification
stripe listen --forward-to localhost:3000/webhooks
prints a and shows events
arriving when you run stripe trigger payment_intent.succeeded
.
- Running the 3DS test with reaches and clicks the Complete
authentication button (the test fails loudly, not silently, if the nested frame isn't
found).
- The signature test: a tampered returns 400; a header from
returns 200.
- The idempotency test: delivering the same twice fulfills once.
grep -rE 'pk_live|sk_live|4111111111111111'
over the test suite returns nothing.
Done When
- Checkout suite covers success (),
(), and (), each asserting the
matching outcome, using / keys.
- A 3DS test fills , reaches the nested challenge frame via chained
, clicks Complete authentication, and asserts the succeeded state — no
, no .
- A subscription-renewal test uses a Stripe test clock (
testHelpers.testClocks.create
+
, forward-only, customer attached at creation) instead of any client-side time mock.
- Local webhooks are received via
stripe listen --forward-to
/ , with the
wired into (distinct from ).
- The webhook handler verifies the signature with on the raw body
before parsing, returns 400 on a forged event, and a test proves it.
- Idempotency is enforced by persisting with a UNIQUE constraint; a duplicate
delivery returns 200 and fulfills exactly once, proven by a test.
- A failed-renewal test drives → → via
a test clock and the attach-then-fail card .
- A reconciliation test confirms the order is only after the verified
webhook (re-checked with ), not on the
redirect.
grep -rE 'pk_live|sk_live|4111111111111111'
finds no live key or banned PAN in the test
suite. (A bare scan would false-positive on every legitimate test card —
match live-key prefixes and the banned number, not all 16-digit strings.)
Related Skills
- api-testing — General REST/GraphQL endpoint testing, schema validation, and auth
flows for non-payment endpoints. Go there when the target isn't a PSP checkout/webhook.
- playwright-automation — Page Object Model, fixtures, and general browser E2E
mechanics that the 3DS flow here builds on.
- compliance-testing — PCI-DSS, GDPR, and regulatory audit work. This skill keeps you
out of PCI scope by using test cards; go there for a formal compliance audit.
- test-data-management — Seeding customers, subscriptions, and fixtures; managing the
test-clock-bound customers this skill creates.
- qa-project-context — The universal first stop: PSP, stack, and fixture conventions
that every question above should defer to.
Reference Files (in )
- stripe-test-cards.md — Full test-card catalogue with decline codes and the Playwright
success/decline/insufficient-funds tests.
- playwright-3ds.md — Nested-iframe 3DS challenge handling, complete and fail variants,
and selector notes.
- webhooks-and-clocks.md — /, raw-body signature verification,
idempotency by , test clocks, failed-renewal dunning + refunds, and
reconciliation.
- multi-psp.md — Adyen, PayPal, and Braintree sandbox patterns and what differs from
Stripe.