Testland
Browse all skills & agents

payment-gateway-sandboxes

Wraps the vendor-generic payment-gateway sandbox pattern - test credentials, sandbox base URLs / environment switches, deterministic test-card matrices, and gateway-native webhook simulators - with per-gateway references for Adyen test mode, PayPal Sandbox, and Braintree sandbox. Use when testing code integrated with Adyen, PayPal, or Braintree; for Stripe use stripe-test-cards-and-webhooks (one-time payments) or stripe-subscription-billing-test-author (recurring billing).

Install with skills.sh (any agent)

npx skills add testland/qa --skill payment-gateway-sandboxes
View source

payment-gateway-sandboxes

Overview

Every major payment gateway ships the same four sandbox primitives, each under a different name:

  1. Test credentials - a sandbox API key / merchant account that can never move real money.
  2. An environment switch - a sandbox base URL or an SDK environment flag that routes calls to the test stack.
  3. A deterministic test-card matrix - specific PANs (or amounts) that trigger specific outcomes: success, decline, 3DS challenge.
  4. A webhook simulator / resend surface - a way to fire or replay asynchronous events at your handler on demand.

This skill is the single entry point for the non-Stripe gateways. The body covers the shared pattern; the per-gateway mechanics live in references/.

Routing table

GatewayReferenceDistinctive sandbox trait
Adyenreferences/adyen.mdExplicit sync-API / async-notification duality; HMAC-validated notifications
PayPalreferences/paypal.mdSandbox Business + Personal accounts; dashboard + API webhook simulator
Braintreereferences/braintree.mdAmount-based magic values; sandbox-only gateway.testing.settle
Stripestripe-test-cards-and-webhooksStripe CLI (stripe listen / stripe trigger); test clocks in stripe-subscription-billing-test-author

When to use

  • Authoring tests for code integrated with Adyen, PayPal, or Braintree.
  • Wiring a gateway sandbox into CI (credentials, env switch, webhook secrets).
  • Porting an existing single-gateway suite to a second gateway.

The shared sandbox test pattern

Regardless of gateway, a payment integration suite has the same skeleton:

  1. Initialize the SDK against the sandbox environment - never a prod key; the environment switch is an env var, not a hardcode.
  2. Drive each documented test-card outcome - one test per outcome row (success, decline variants, 3DS frictionless, 3DS challenge), asserting the gateway's own result-code vocabulary.
  3. Verify webhook signatures - every gateway signs its events (HMAC-SHA256 for Adyen, SHA256-with-RSA for PayPal, bt_signature parsing for Braintree); the suite must reject unsigned and wrong-secret payloads.
  4. Prove handler idempotency under redelivery - all three gateways redeliver unacknowledged events; process-exactly-once is the contract. The full replay workflow is payment-flow-test-author.
  5. Map the state machine - each gateway names the same lifecycle differently; translate via payment-flow-states-reference before asserting on states.

CI integration

Sandbox credentials are secrets like any other:

jobs:
  gateway-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
      - run: npm ci && npm test
        env:
          ADYEN_TEST_API_KEY: ${{ secrets.ADYEN_TEST_API_KEY }}
          PAYPAL_SANDBOX_CLIENT_ID: ${{ secrets.PAYPAL_SANDBOX_CLIENT_ID }}
          BT_SANDBOX_MERCHANT_ID: ${{ secrets.BT_SANDBOX_MERCHANT_ID }}

Per-gateway variable lists are in each reference.

Anti-patterns

Anti-patternWhy it failsFix
Prod credentials in testsReal settlement / real chargesSandbox-only keys, per-env config
Skip webhook signature validationSpoofed events trigger fulfillmentVerify per the gateway's scheme
Treat the sync API return as finalAll three gateways finalize asyncWebhook-driven state, per payment-flow-states-reference
One happy-path testDecline / 3DS / fraud paths untestedOne test per documented outcome row
Hardcoded merchant/account IDsPer-env accounts driftEnv vars
One suite for all gatewaysResult-code vocabularies differPer-gateway test directory

Limitations

  • Sandbox fidelity varies. Braintree settles synchronously via a test-only API; prod settles overnight. PayPal sandbox webhooks can lag.
  • Client-side surfaces need a browser. Drop-in / Hosted Fields / PayPal-button flows require Playwright against the sandbox UI.
  • Region-specific payment methods (iDEAL, Sofort, etc.) have separate test flows not covered here.

References

  • Adyen test mode, test cards, result codes, HMAC notifications: references/adyen.md.
  • PayPal Sandbox accounts, Orders v2, webhook simulator: references/paypal.md.
  • Braintree sandbox, amount-based magic values, webhook parser: references/braintree.md.
  • Companion catalog: payment-flow-states-reference (state machines + 3DS flows).
  • Flow suites on top of the sandboxes: payment-flow-test-author.
  • Stripe: stripe-test-cards-and-webhooks, stripe-subscription-billing-test-author.

Adyen test mode

Adyen's test environment is fully API-compatible with prod. Per docs.adyen.com/development-resources/testing (opens in new window), test API keys + test merchant accounts produce deterministic results based on input.

The notable difference from Stripe: Adyen separates the synchronous payment API from the asynchronous notification webhook more explicitly. Both surfaces need tests.

Install

npm install @adyen/api-library
pip install Adyen

Initialize

import { Client, CheckoutAPI } from '@adyen/api-library';

const client = new Client({
  apiKey: process.env.ADYEN_TEST_API_KEY!,
  environment: 'TEST',                  // vs 'LIVE'
});
const checkout = new CheckoutAPI(client);

Test cards

Per docs.adyen.com/development-resources/test-cards-and-credentials/test-card-numbers (opens in new window):

CardBehaviour
4111 1111 1111 1111Authorised (Visa)
5555 4444 3333 1111Authorised (Mastercard)
4000 0000 0000 0119Refused (general)
5444 5555 5555 5557Refused (insufficient funds)
5454 5454 5454 54543DS 2 frictionless
4917 6100 0000 00003DS 2 challenge
4012 8888 8888 18813DS 1 (deprecated)

Test expiry: any future date. Test CVC: 737 (special "3DS") or any 3-digit.

Payment

const paymentResponse = await checkout.payments({
  amount: { currency: 'EUR', value: 1000 },
  paymentMethod: {
    type: 'scheme',
    encryptedCardNumber: 'test_4111111111111111',
    encryptedExpiryMonth: 'test_03',
    encryptedExpiryYear: 'test_2030',
    encryptedSecurityCode: 'test_737',
  },
  reference: 'order-' + uuidv4(),
  merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT!,
});

expect(paymentResponse.resultCode).toBe('Authorised');

Result codes per docs.adyen.com/online-payments/build-your-integration/payment-result-codes (opens in new window):

resultCodeMeaning
AuthorisedApproved
RefusedDeclined
CancelledCancelled
PendingAsync; final result via notification
RedirectShopper3DS / redirect required
IdentifyShopper3DS device-fingerprint step
ChallengeShopper3DS 2 challenge
ErrorFailure

Webhook (notification) handling

Adyen sends webhooks ("notifications") for every state change. Per docs.adyen.com/development-resources/webhooks/secure-webhooks/verify-hmac-signatures (opens in new window): validate via HMAC-SHA256 over a canonical-string of the payload.

import { hmacValidator } from '@adyen/api-library';
const validator = new hmacValidator();

test('webhook signature valid', () => {
  const notification = { /* notification payload */ };
  const hmacSignature = notification.additionalData.hmacSignature;
  const isValid = validator.validateHMAC(notification, process.env.ADYEN_HMAC_KEY!);
  expect(isValid).toBe(true);
});

Notification idempotency

Adyen redelivers notifications until acknowledged with HTTP 200

  • [accepted] body. Tests should verify the handler is idempotent under redelivery:
test('redelivered notification handled idempotently', async () => {
  const notif = makeTestNotification();
  await handler(notif);
  const before = await db.payments.count();
  await handler(notif);  // re-delivered
  const after = await db.payments.count();
  expect(after).toBe(before);
});

Notifications test mode

Per Adyen docs: in Customer Area, you can re-send notifications on demand for testing. Also exposes a webhook-events endpoint for replay.

CI integration

jobs:
  adyen-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
      - run: npm ci && npm test
        env:
          ADYEN_TEST_API_KEY: ${{ secrets.ADYEN_TEST_API_KEY }}
          ADYEN_MERCHANT_ACCOUNT: ${{ secrets.ADYEN_TEST_MERCHANT_ACCOUNT }}
          ADYEN_HMAC_KEY: ${{ secrets.ADYEN_TEST_HMAC_KEY }}

Anti-patterns

Anti-patternWhy it failsFix
Use prod API key in testsReal settlement riskStrict TEST environment
Skip HMAC signature validationWebhook spoofingAlways validate
Treat resultCode=Pending as failureAsync; final via notificationWait for webhook
One unit test for "happy path" onlyRefused / RedirectShopper paths untestedPer-resultCode test
Hardcoded merchant account in codePer-env account; tests pass against wrong accountEnv var
Reply [accepted] even on processing-errorAdyen stops retryingReply only on successful processing
Skip 3DS 2 challenge testRequired by EU regulationsTest 4917 6100 0000 0000 path
Encrypted-data fields wrong formatAdyen rejects with cryptic errorUse Adyen-SDK encryption helpers

Limitations

  • Adyen test mode uses encrypted data placeholders. Real encryption uses Adyen Web SDK; tests use test_* placeholders.
  • Notification SLA varies. Real-time webhooks but retried-on-failure can be hours later.
  • Recurring API has separate test cards. Tokenisation scenarios need their own surface.
  • Region-specific payment methods. iDEAL / Sofort / etc. have separate test flows.

Sources

Braintree sandbox

Per developer.paypal.com/braintree/docs/reference/general/testing (opens in new window), the Braintree (PayPal-owned) sandbox accepts the same API as production with deterministic test-card responses.

The notable distinction: Braintree's Transaction state machine includes an explicit submitted_for_settlementsettled transition with simulated settlement in sandbox.

Setup

Get sandbox credentials at braintreepayments.com/sandbox (opens in new window) - merchant ID + public + private keys.

Install

npm install braintree
pip install braintree

Initialize

import braintree from 'braintree';

const gateway = new braintree.BraintreeGateway({
  environment: braintree.Environment.Sandbox,
  merchantId: process.env.BT_SANDBOX_MERCHANT_ID!,
  publicKey: process.env.BT_SANDBOX_PUBLIC_KEY!,
  privateKey: process.env.BT_SANDBOX_PRIVATE_KEY!,
});

Test cards

Per developer.paypal.com/braintree/docs/reference/general/testing/node (opens in new window):

CardBehaviour
4111 1111 1111 1111Authorized + settled
5555 5555 5555 4444Authorized + settled (Mastercard)
4000 1111 1111 1115Processor declined (general)
4000 0000 0000 0002Processor declined
4000 0000 0000 11093DS frictionless
4000 0000 0000 10913DS challenge

By amount:

AmountBehaviour
$2000.00Processor declined (insufficient funds)
$2999.00Fraud failure
$3000.00Bank failure

This amount-based behaviour is unique to Braintree.

Transaction

const result = await gateway.transaction.sale({
  amount: '10.00',
  paymentMethodNonce: 'fake-valid-nonce',  // From Braintree client SDK
  options: { submitForSettlement: true },
});

expect(result.success).toBe(true);
expect(result.transaction.status).toBe('submitted_for_settlement');

fake-valid-nonce is a sandbox-only nonce that represents a successful tokenization. Real flow uses Drop-in or Hosted Fields to produce a real nonce.

Settle in sandbox

Sandbox transactions don't auto-settle; you can force settlement via the testing API:

await gateway.testing.settle(transactionId);
const result = await gateway.transaction.find(transactionId);
expect(result.status).toBe('settled');

Per developer.paypal.com/braintree/docs/reference/general/testing/node#settle-transaction (opens in new window): the testing methods are sandbox-only.

Refund

const refundResult = await gateway.transaction.refund(transactionId);
expect(refundResult.transaction.type).toBe('credit');

Refunds can only happen after settlement; submit-for-settlement then settle (testing) then refund.

Webhook handling

Per developer.paypal.com/braintree/docs/guides/webhooks/parse/node (opens in new window):

const webhookNotification = await gateway.webhookNotification.parse(
  request.body.bt_signature,
  request.body.bt_payload,
);

expect(webhookNotification.kind).toBeDefined();
// e.g., 'transaction_settled', 'transaction_settlement_declined'

The parser validates the signature; an invalid one throws.

Drop-in / Hosted Fields flow

Client-side (browser):

braintree.dropin.create({
  authorization: clientToken,
  selector: '#dropin-container',
}, (err, instance) => {
  // ...
  instance.requestPaymentMethod((err, payload) => {
    // payload.nonce - send to server
    fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ nonce: payload.nonce }) });
  });
});

Tests for this layer need Playwright + Drop-in's test mode.

CI integration

jobs:
  braintree-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
      - run: npm ci && npm test
        env:
          BT_SANDBOX_MERCHANT_ID: ${{ secrets.BT_SANDBOX_MERCHANT_ID }}
          BT_SANDBOX_PUBLIC_KEY: ${{ secrets.BT_SANDBOX_PUBLIC_KEY }}
          BT_SANDBOX_PRIVATE_KEY: ${{ secrets.BT_SANDBOX_PRIVATE_KEY }}

Anti-patterns

Anti-patternWhy it failsFix
Use prod credentials in testsReal chargesSandbox-only
Skip submitForSettlement: trueTransaction stays in authorized stateSet explicitly
Test refund without settlementRefund requires settled stateSettle first via testing API
Hardcoded amounts ignoring magic valuesTrip amount-based behaviours unexpectedlyDocument amount-vs-behaviour
Use fake-valid-nonce in production code pathSandbox-onlyReal nonces from Drop-in
Skip webhook signature validationSpoof riskgateway.webhookNotification.parse validates
Long-polling settled-state in testsSlowgateway.testing.settle synchronously
Test only success pathDecline / fraud / bank-failure paths matterTest amount-based magic values

Limitations

  • Amount-based test behaviour is sandbox-specific. Production doesn't use these magic values.
  • Settlement is real-time in test via testing API. Prod settlement is overnight batch.
  • Drop-in / Hosted Fields require browser context. Server unit tests use fake nonces; full flow needs Playwright.
  • gateway.testing.* methods don't exist in production. Be deliberate about test-only code paths.

Sources

PayPal Sandbox

PayPal Sandbox is a parallel environment that mirrors the prod PayPal API. Per developer.paypal.com/tools/sandbox (opens in new window), sandbox accounts (Business + Personal) are created in the developer dashboard; tests use sandbox client credentials.

The current canonical API is Orders v2 (developer.paypal.com/docs/api/orders/v2 (opens in new window)); older Payments API (v1) is deprecated.

Setup

  1. Create developer account at developer.paypal.com (opens in new window).
  2. Create Business + Personal sandbox accounts (one Business for merchant; one or more Personal for buyers).
  3. Get sandbox client ID + secret.

Install

npm install @paypal/checkout-server-sdk
pip install paypalserversdk

OAuth2 client credentials

import paypal from '@paypal/checkout-server-sdk';

const env = new paypal.core.SandboxEnvironment(
  process.env.PAYPAL_SANDBOX_CLIENT_ID!,
  process.env.PAYPAL_SANDBOX_SECRET!,
);
const client = new paypal.core.PayPalHttpClient(env);

Create order

const request = new paypal.orders.OrdersCreateRequest();
request.requestBody({
  intent: 'CAPTURE',
  purchase_units: [{ amount: { currency_code: 'USD', value: '10.00' } }],
});

const order = await client.execute(request);
expect(order.result.status).toBe('CREATED');
expect(order.result.id).toBeTruthy();

Capture order (after buyer approval)

const captureRequest = new paypal.orders.OrdersCaptureRequest(order.result.id);
captureRequest.requestBody({});

const capture = await client.execute(captureRequest);
expect(capture.result.status).toBe('COMPLETED');

In test code, you need a sandbox buyer to approve the order via the PayPal checkout UI - for fully-automated tests, this requires Playwright + a sandbox Personal account login.

Sandbox test cards

Per developer.paypal.com/tools/sandbox/card-testing (opens in new window):

CardBehaviour
4111 1111 1111 1111Visa Sandbox success
5555 5555 5555 4444Mastercard success
4032 0359 8001 0008Decline

PayPal Sandbox is more PayPal-balance-oriented than card- oriented; sandbox buyers also have fake "PayPal balance."

Webhook simulator

Per developer.paypal.com/api/rest/webhooks/event-names (opens in new window): the developer dashboard exposes a Webhook Simulator that sends any event type to your registered URL.

For automated tests, use the simulator's API:

curl -X POST 'https://api-m.sandbox.paypal.com/v1/notifications/simulate-event' \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -d '{
    "url": "https://example.com/webhook",
    "event_type": "PAYMENT.CAPTURE.COMPLETED",
    ...
  }'

Webhook signature verification

Per developer.paypal.com/api/rest/webhooks/rest (opens in new window): PayPal webhooks include PAYPAL-TRANSMISSION-SIG and related headers; verify via PayPal's verification endpoint or local SDK helper.

import { verifyWebhookSignature } from '@paypal/checkout-server-sdk';

const isValid = await verifyWebhookSignature({
  authAlgo: headers['paypal-auth-algo'],
  certUrl: headers['paypal-cert-url'],
  transmissionId: headers['paypal-transmission-id'],
  transmissionSig: headers['paypal-transmission-sig'],
  transmissionTime: headers['paypal-transmission-time'],
  webhookId: process.env.PAYPAL_WEBHOOK_ID!,
  webhookEvent: notificationPayload,
});
expect(isValid).toBe(true);

CI integration

jobs:
  paypal-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
      - run: npm ci && npm test
        env:
          PAYPAL_SANDBOX_CLIENT_ID: ${{ secrets.PAYPAL_SANDBOX_CLIENT_ID }}
          PAYPAL_SANDBOX_SECRET: ${{ secrets.PAYPAL_SANDBOX_SECRET }}

Anti-patterns

Anti-patternWhy it failsFix
Use live PayPal credentials in testsReal moneySandbox-only
Test with manual buyer approvalSlow; not CI-suitablePlaywright + sandbox buyer login
Skip webhook signature verificationSpoofableAlways verify
Hardcode sandbox account IDsFragile to account changesPer-env IDs
Test only the API pathReal flow requires checkout UIPlaywright e2e
Legacy Payments v1 APIDeprecatedMigrate to Orders v2
Treat CREATED as finalOrder needs captureTest the full lifecycle
One-shot test for refundsRefunds are asyncWait for webhook

Limitations

  • Sandbox UI is slower than prod. Playwright e2e against sandbox is flaky-prone.
  • Sandbox accounts can be rate-limited. CI parallelism may conflict.
  • Card sandbox testing less first-class than balance-based testing; PayPal expects wallet flows.
  • Webhook delivery in sandbox sometimes delayed; tests need timeouts.
  • Legacy Payments v1 still works but is deprecated; new code should use Orders v2.

Sources

Related skills

payment-flow-states-reference

Pure-reference catalog of payment lifecycle state machines across Stripe, Adyen, PayPal, and Braintree: canonical states (created / requires_action / processing / succeeded / requires_capture / canceled / failed), authorisation vs capture, asynchronous webhook states, refund / dispute / chargeback transitions, and the 3-D Secure (EMVCo 3DS 2.x) frictionless / challenge flow paths with per-gateway 3DS test cards (references/3ds-flows.md). Use when designing tests for payment flows, auditing state-handling code, or covering a 3DS round-trip; this is the state model, not a builder - to author suites on it use payment-flow-test-author (refunds, disputes, webhook replay).

payment-flow-test-author

Build-an-X workflow that authors the full payment-flow test suite in three phases: the refund matrix (full / partial / multiple-partials / over-refund / already-refunded, per-gateway APIs for Stripe, Adyen, PayPal, Braintree), the chargeback / dispute suite (Visa + Mastercard reason codes, evidence submission windows, won / lost / accepted dispositions), and webhook replay + recovery via gateway-native simulators (Stripe CLI trigger / resend, Adyen Customer Area resend, PayPal Webhook Simulator, Braintree sampleNotification). Driven by the state model in payment-flow-states-reference. Use when building refund, dispute, or payment-webhook-robustness coverage for a payment integration; for generic (non-payment) webhook receiver testing use webhook-delivery-tester in the qa-notifications plugin.

stripe-subscription-billing-test-author

Builds test suites for Stripe recurring-billing flows: trial-to-paid conversion, proration on plan upgrade and downgrade, dunning on failed renewal, cancel and reactivation, and the full subscription webhook event matrix (invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, invoice.paid). Uses Stripe Billing test clocks (POST /v1/test_helpers/test_clocks) to time-travel through billing cycles without calendar delay. Distinct from stripe-test-cards-and-webhooks (one-time PaymentIntents) and payment-flow-test-author (idempotency + replay robustness). Does not cover single-event CLI replay or handler idempotency testing (see payment-flow-test-author for those). Use when authoring tests for subscription or recurring-billing integrations.

stripe-test-cards-and-webhooks

Wraps Stripe API testing patterns: test-mode initialization, the canonical test cards (4242 success; 4000 0000 0000 0002 declined; 4000 0027 6000 3184 3DS challenge per the 3DS flows reference in payment-flow-states-reference), the Stripe CLI webhook flow (`stripe listen --forward-to`), the Stripe CLI fixture commands (`stripe trigger payment_intent.succeeded`), and the webhook signature verification (Stripe-Signature header + HMAC-SHA256). Use when testing Stripe-integrated code.