Skip to main content
skill CX Codex

payment-testing — Claude Code skill: what it does, how to install

A testing skill for end-to-end payment flows against Stripe, Adyen, and PayPal sandboxes. Helps automate card declines, 3DS challenges, and webhook verification.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add petrkindlmann/qa-skills --skill payment-testing -a claude-code
Open on GitHub

What payment-testing does

This skill guides test suites through PSP sandboxes without ever exposing live keys or real card numbers. Its primary focus is Stripe, with reusable patterns extending to Adyen, Braintree, and PayPal. It provides exact test card numbers to assert outcomes such as card_declined, insufficient_funds, or mandatory SCA verification.

For browser automation with Playwright, the skill documents how to chain frameLocator calls to reach Stripe 3DS inputs inside nested cross-origin iframes. Direct selectors fail on these elements. For recurring billing, it prescribes server-side test clocks rather than mocked process time to simulate subscription renewals. It also covers signature verification on raw request bodies.

Fulfilling orders directly on client redirects creates security loopholes, so this skill enforces confirming payments solely through signature-verified webhooks with idempotent processing.

What it helps with

  • automate Stripe 3DS challenge authentication inside nested Playwright iframes
  • test card decline scenarios with specific codes like insufficient_funds
  • simulate multi-year subscription renewals using Stripe test clocks
  • verify webhook signature parsing on raw request bodies with idempotent storage

Example prompts for the agent

  • Write a Playwright test for Stripe Checkout handling a 3DS challenge modal
  • Set up a test clock scenario to verify subscription renewal failure and retry logic
  • Audit our webhook handler to ensure raw body signature check and event idempotency

How to install payment-testing

1Skills CLI (Vercel)

npx skills add petrkindlmann/qa-skills --skill payment-testing -a claude-code

2Manually: put the files in place

~/.claude/skills/payment-testing/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/payment-testing/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about payment-testing

How to install payment-testing in Claude Code?

Run npx skills add petrkindlmann/qa-skills --skill payment-testing -a claude-code. Alternatively, save the file to ~/.claude/skills/payment-testing/SKILL.md.

What does payment-testing do?

It instructs your coding agent on creating accurate end-to-end tests against payment provider sandboxes. It covers deterministic card outcomes, iframe traversal, and billing lifecycle events.

Why does 3DS challenge testing fail with standard Playwright selectors?

The 3DS modal lives inside a nested cross-origin iframe. Standard page.locator queries cannot pierce cross-domain boundaries, requiring chained frameLocator calls to locate authentication inputs.

Source text

SKILL.md · MIT
---
name: payment-testing
description: >-
  Test payment and checkout flows end to end against PSP sandboxes — Stripe first, with
  the general pattern for Adyen/Braintree/PayPal. Covers Stripe test-mode card numbers and
  their decline codes, the 3DS/SCA challenge flow and its nested-iframe handling in
  Playwright, test clocks for subscription/billing-cycle simulation, webhook testing
  (stripe listen/trigger, signature verification, idempotency), failed/retried payments and
  refunds, and never using real cards. Use when: "test Stripe checkout," "payment test,"
  "3DS test," "test webhook signature," "test subscription renewal," "test clock,"
  "refund test," "decline card test," "checkout E2E."
  Not for: General API contract testing of non-payment endpoints — api-testing. PCI-DSS/regulatory
  compliance audit — compliance-testing.
  Related: api-testing, playwright-automation, compliance-testing, test-data-management, qa-project-context.
license: MIT
metadata:
  author: kindlmann
  version: "1.1"
  category: specialized
---

<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](#stripe-test-cards-and-outcomes) | `references/stripe-test-cards.md` |
| A 3DS/SCA challenge that pops a modal | [3DS challenge](#3ds--sca-the-nested-iframe-challenge) | `references/playwright-3ds.md` |
| A subscription renewal months/years out | [Test clocks](#test-clocks-server-side-time-travel) | `references/webhooks-and-clocks.md` |
| Webhooks reaching localhost + signatures | [Webhooks](#webhooks-local-delivery-signatures-idempotency) | `references/webhooks-and-clocks.md` |
| A non-Stripe or in-house webhook protocol | [Webhooks](#webhooks-local-delivery-signatures-idempotency), "Another provider" | the provider's or your contract's docs |
| A failed renewal then a refund | [Failed payments](#failed-payments-dunning-and-refunds) | `references/webhooks-and-clocks.md` |
| Fulfillment only after real payment | [Reconciliation](#reconciliation-fulfill-on-the-webhook-not-the-redirect) | `references/webhooks-and-clocks.md` |
| Adyen / PayPal / Braintree sandboxes | [Multi-PSP](#multi-psp-adyen-paypal-braintree) | `references/multi-psp.md` |

## 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](#multi-psp-adyen-paypal-braintree).
- **One-time payments, subscriptions, or both?** Subscriptions pull in test clocks, dunning,
  and the `invoice.*` 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
  `return_url`, that's the bug to test for — fulfillment must wait for the verified webhook.
- **Where does the webhook handler run in tests?** Local (`stripe listen`) vs a deployed
  preview env changes how you deliver events.

## Core Principles

1. **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 (`pk_test_`/`sk_test_`) with Stripe's published test cards is the
   only correct answer. Masking or encrypting a real number does not fix it; removing it
   does.

2. **Money is confirmed server-side, never client-side.** A redirect, an `onApprove`
   callback, or a `?status=success` query param can be premature, replayed, or forged.
   Fulfill an order only after a signature-verified `payment_intent.succeeded` webhook,
   re-confirmed with an API `retrieve`.

3. **Verify the signature before you parse the body.** Parsing JSON first destroys the raw
   bytes `constructEvent` needs. The webhook route gets the raw body; everything else can
   parse JSON.

4. **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.

5. **Assume every webhook is delivered more than once.** Stripe retries. Idempotency keyed
   on `event.id` 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 |
|-----|---------|------|
| `4242424242424242` | Succeeds | — |
| `4000000000000002` | Declined | `card_declined` (generic_decline) |
| `4000000000009995` | Declined | `insufficient_funds` |
| `4000000000003220` | 3DS always challenges | — |
| `4000000000000341` | Attaches, then fails on charge | `card_declined` |

Use test keys (`pk_test_…`/`sk_test_…`) in the app under test and assert that in setup.
**Do not use `4111111111111111`** — 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 `frameLocator`, never
`page.locator` directly. Assert outcomes on UI copy for a smoke test, or more robustly on
the server-side `last_payment_error.decline_code` from `paymentIntents.retrieve`. Full
Playwright tests for success / `card_declined` / `insufficient_funds`:
`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 `frameLocator` cannot reach it. Chain
`frameLocator` outer → inner, then click **Complete authentication**.

What fails, and why:

- `page.locator('#card')` → the input is cross-origin; the locator matches nothing.
- `page.frames()[1]` → 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();
```

`4000002760003184` 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 (`jest.useFakeTimers`, sinon, mocking `Date`) do
nothing to Stripe's billing engine.

Rules that bite if missed:
- Create the clock at a `frozen_time`, then attach the **customer at creation** with
  `test_clock: clock.id`. 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 `ready`, 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. `stripe listen` tunnels test events to localhost natively; `stripe trigger` fires
them on demand:

```bash
stripe listen --forward-to localhost:3000/webhooks   # prints whsec_… ONCE at startup
stripe trigger payment_intent.succeeded
```

Copy that `whsec_…` into `STRIPE_WEBHOOK_SECRET`. It is the **signing secret**, a different
value from `STRIPE_SECRET_KEY` (`sk_test_…`) — do not conflate them.

**Signature verification.** Mount `express.raw` on the webhook route **before** any global
`express.json()`, so `constructEvent` gets the raw body. With Stripe, a forged or tampered
event is rejected with **400**, the status Stripe's own handlers return; never hand-roll a
`=== signature` string comparison.

**Another provider or an in-house protocol.** Take the header name, the signed input and the
rejection status from that provider's or your contract's documentation, not from the Stripe
examples: many document 401 or 403 for a bad signature. The invariants carry over unchanged
and are what the tests should pin: a missing, wrong or tampered signature is refused and
records nothing; the signature covers the exact raw bytes, so the same event re-serialized
and correctly signed over its new bytes is judged on those bytes; a duplicate or concurrent
delivery fulfils once; a failure after the dedup marker leaves the order retryable; and the
result survives a restart.

```ts
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res, next) => {
  const sig = req.headers['stripe-signature'] as string;
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
  }
  try { return await handleEvent(event, res); }
  catch (err) { return next(err); } // processing failures remain retryable 5xx
});
app.use(express.json()); // everything else, after the webhook route
```

**Idempotency.** Stripe retries delivery, so the same `event.id` arrives twice. Request-side
idempotency keys (for outbound API calls) do not dedup inbound webhooks. Store `event.id`
with a **UNIQUE** constraint, in the **same database transaction** as fulfillment.
Return **200** only after commit. If processing fails after inserting the marker,
roll back both changes and return a retryable **5xx**. Otherwise the retry sees the
marker and silently discards an unpaid/unfulfilled order. Use the transaction-bound
handler in `references/webhooks-and-clocks.md`; an in-memory set cannot survive restart.

For external side effects, commit an outbox job and make its consumer idempotent.
Also enforce a business-operation key such as order/payment ID: two distinct event
IDs can describe the same operation. Do not claim exactly-once delivery.

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). Add a failure after dedup insertion, retry after restart, concurrent
duplicates, distinct event IDs for one payment, and out-of-order delivery. A sequential
duplicate test alone does not exercise the crash window.

## Failed Payments: Dunning and Refunds

To test a failed recurring charge end to end, subscribe with `4000000000000341` (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:
1. Subscribe the customer (on a test clock) with the attach-then-fail card.
2. `advance` the clock past the renewal date → Stripe attempts the charge.
3. The charge fails → Stripe emits **`invoice.payment_failed`** and the subscription goes
   **`past_due`**. Assert both.
4. Resolve by issuing a refund with **`refunds.create`** (fires `charge.refunded`) — 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 `payment_intent.succeeded` webhook,
re-confirmed against the API — never on the `return_url` 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 `pending` after the redirect and only
`paid` 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. `4212345678910014` for 3DS2); many declines are driven by
  the **transaction amount** (`.13` refused, `.51` 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 `onApprove`.
- **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: `references/multi-psp.md`.

## Anti-Patterns

### 1. Reaching for `4111111111111111`
That Luhn-valid number is a Braintree/PayPal-era generic PAN, not a Stripe test card. Use
`4242424242424242` for success and the specific decline cards (`4000000000000002`,
`4000000000009995`).

### 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 `frameLocator`.

### 3. Selecting iframes by index
`page.frames()[1]` breaks the instant Stripe reorders frames. Match the frame by a stable
name prefix (`iframe[name^="__privateStripeFrame"]`) and chain `frameLocator` for the nested
3DS challenge.

### 4. `waitForTimeout` 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
`jest.useFakeTimers` / sinon / mocking `Date` 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; `stripe
trigger` fires them. No public tunnel, no status polling.

### 7. Parsing the body before verifying the signature
A global `express.json()` ahead of the webhook route destroys the raw body
`constructEvent` needs, so verification can never pass. Mount `express.raw` 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 `Set` dies on restart.
Persist `event.id` with a UNIQUE constraint.

### 9. Fulfilling on the redirect / client success flag
The `return_url` can be premature, replayed, or forged. Fulfill only on the verified
`payment_intent.succeeded` webhook.

### 10. "Fixing" a failed renewal by deleting the subscription
The correct resolution is a refund via `refunds.create`, leaving the dunning lifecycle
(`invoice.payment_failed` → `past_due`) 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 `whsec_…` and shows events
  arriving when you run `stripe trigger payment_intent.succeeded`.
- Running the 3DS test with `4000000000003220` 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 `Stripe-Signature` returns **400**; a header from
  `generateTestHeaderString` returns **200**.
- The idempotency test: delivering the same `event.id` twice fulfills **once**.
- `grep -rE 'pk_live|sk_live|4111111111111111'` over the test suite returns nothing.

## Done When

- Checkout suite covers success (`4242424242424242`), `card_declined`
  (`4000000000000002`), and `insufficient_funds` (`4000000000009995`), each asserting the
  matching outcome, using `pk_test_`/`sk_test_` keys.
- A 3DS test fills `4000000000003220`, reaches the nested challenge frame via chained
  `frameLocator`, clicks **Complete authentication**, and asserts the succeeded state — no
  `frames()[index]`, no `waitForTimeout`.
- A subscription-renewal test uses a Stripe test clock (`testHelpers.testClocks.create` +
  `advance`, forward-only, customer attached at creation) instead of any client-side time mock.
- Local webhooks are received via `stripe listen --forward-to` / `stripe trigger`, with the
  `whsec_…` wired into `STRIPE_WEBHOOK_SECRET` (distinct from `STRIPE_SECRET_KEY`).
- The webhook handler verifies the signature with `constructEvent` on the **raw** body
  before parsing, returns 400 on a forged event (or the status a non-Stripe contract
  documents), records nothing for it, and a test proves it.
- Idempotency is enforced by persisting `event.id` with a UNIQUE constraint; a duplicate
  delivery returns 200 and fulfills exactly once, proven by a test.
- A failed-renewal test drives `invoice.payment_failed` → `past_due` → `refunds.create` via
  a test clock and the attach-then-fail card `4000000000000341`.
- A reconciliation test confirms the order is `paid` only after the verified
  `payment_intent.succeeded` webhook (re-checked with `paymentIntents.retrieve`), not on the
  redirect.
- `grep -rE 'pk_live|sk_live|4111111111111111'` finds no live key or banned PAN in the test
  suite. (A bare `[0-9]{16}` scan would false-positive on every legitimate test card —
  match live-key prefixes and the banned `4111…` 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 `references/`)

- **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** — `stripe listen`/`trigger`, raw-body signature verification,
  idempotency by `event.id`, test clocks, failed-renewal dunning + refunds, and
  reconciliation.
- **multi-psp.md** — Adyen, PayPal, and Braintree sandbox patterns and what differs from
  Stripe.

© kindlmann · MIT · petrkindlmann/qa-skills

Similar skills

All skills for Testing and QA →

From the same set49

testing-in-production A skill for testing during rollout: feature flags, canary analysis, progressive rollouts and guardrail metrics. Useful when a release needs to ramp instead of hitting everyone at once. CX Codex +1 1,000installs test-suite-curation Audits a whole regression suite: finds duplicates, dead and flaky tests, splits it into smoke/core/extended. Deletion needs quarantine and human sign-off. CX Codex +1 1,000installs qa-project-bootstrap Builds a 30-day onboarding plan for QA engineers and audits existing test architecture. Useful when ramping up a new tester or reviewing an inherited test suite. CX Codex +1 1,000installs qa-dashboard Builds test dashboards and reports with Allure, Grafana and ReportPortal. Useful when run results need to reach the team or leadership. CX Codex +1 1,100installs contract-testing A skill for consumer-driven contract testing with Pact-JS: writes consumer tests, verifies providers, gates deploys on can-i-deploy. CX Codex +1 1,100installs selector-drift-recovery Bulk-regenerates broken Playwright selectors after a UI redesign. Diffs aria snapshots and packages updated locators into a reviewable pull request. CX Codex +1 993installs analytics-tracking-testing This skill checks that GA4 events and Meta or TikTok pixels actually leave the browser, instead of only landing in dataLayer. CX Codex +1 998installs email-testing An e2e testing skill for email-dependent flows: signup confirmation, password reset, magic-link login, OTP codes and notifications. It reads the real email instead of faking the endpoint call. CX Codex +1 991installs

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor