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-sandboxespayment-gateway-sandboxes
Overview
Every major payment gateway ships the same four sandbox primitives, each under a different name:
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
| Gateway | Reference | Distinctive sandbox trait |
|---|---|---|
| Adyen | references/adyen.md | Explicit sync-API / async-notification duality; HMAC-validated notifications |
| PayPal | references/paypal.md | Sandbox Business + Personal accounts; dashboard + API webhook simulator |
| Braintree | references/braintree.md | Amount-based magic values; sandbox-only gateway.testing.settle |
| Stripe | stripe-test-cards-and-webhooks | Stripe CLI (stripe listen / stripe trigger); test clocks in stripe-subscription-billing-test-author |
When to use
The shared sandbox test pattern
Regardless of gateway, a payment integration suite has the same skeleton:
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-pattern | Why it fails | Fix |
|---|---|---|
| Prod credentials in tests | Real settlement / real charges | Sandbox-only keys, per-env config |
| Skip webhook signature validation | Spoofed events trigger fulfillment | Verify per the gateway's scheme |
| Treat the sync API return as final | All three gateways finalize async | Webhook-driven state, per payment-flow-states-reference |
| One happy-path test | Decline / 3DS / fraud paths untested | One test per documented outcome row |
| Hardcoded merchant/account IDs | Per-env accounts drift | Env vars |
| One suite for all gateways | Result-code vocabularies differ | Per-gateway test directory |
Limitations
References
Adyen test mode
View source (opens in new window)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 AdyenInitialize
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
| Card | Behaviour |
|---|---|
| 4111 1111 1111 1111 | Authorised (Visa) |
| 5555 4444 3333 1111 | Authorised (Mastercard) |
| 4000 0000 0000 0119 | Refused (general) |
| 5444 5555 5555 5557 | Refused (insufficient funds) |
| 5454 5454 5454 5454 | 3DS 2 frictionless |
| 4917 6100 0000 0000 | 3DS 2 challenge |
| 4012 8888 8888 1881 | 3DS 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):
| resultCode | Meaning |
|---|---|
Authorised | Approved |
Refused | Declined |
Cancelled | Cancelled |
Pending | Async; final result via notification |
RedirectShopper | 3DS / redirect required |
IdentifyShopper | 3DS device-fingerprint step |
ChallengeShopper | 3DS 2 challenge |
Error | Failure |
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
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-pattern | Why it fails | Fix |
|---|---|---|
| Use prod API key in tests | Real settlement risk | Strict TEST environment |
| Skip HMAC signature validation | Webhook spoofing | Always validate |
Treat resultCode=Pending as failure | Async; final via notification | Wait for webhook |
| One unit test for "happy path" only | Refused / RedirectShopper paths untested | Per-resultCode test |
| Hardcoded merchant account in code | Per-env account; tests pass against wrong account | Env var |
Reply [accepted] even on processing-error | Adyen stops retrying | Reply only on successful processing |
| Skip 3DS 2 challenge test | Required by EU regulations | Test 4917 6100 0000 0000 path |
| Encrypted-data fields wrong format | Adyen rejects with cryptic error | Use Adyen-SDK encryption helpers |
Limitations
Sources
Braintree sandbox
View source (opens in new window)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_settlement → settled 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 braintreeInitialize
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):
| Card | Behaviour |
|---|---|
| 4111 1111 1111 1111 | Authorized + settled |
| 5555 5555 5555 4444 | Authorized + settled (Mastercard) |
| 4000 1111 1111 1115 | Processor declined (general) |
| 4000 0000 0000 0002 | Processor declined |
| 4000 0000 0000 1109 | 3DS frictionless |
| 4000 0000 0000 1091 | 3DS challenge |
By amount:
| Amount | Behaviour |
|---|---|
| $2000.00 | Processor declined (insufficient funds) |
| $2999.00 | Fraud failure |
| $3000.00 | Bank 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-pattern | Why it fails | Fix |
|---|---|---|
| Use prod credentials in tests | Real charges | Sandbox-only |
Skip submitForSettlement: true | Transaction stays in authorized state | Set explicitly |
| Test refund without settlement | Refund requires settled state | Settle first via testing API |
| Hardcoded amounts ignoring magic values | Trip amount-based behaviours unexpectedly | Document amount-vs-behaviour |
Use fake-valid-nonce in production code path | Sandbox-only | Real nonces from Drop-in |
| Skip webhook signature validation | Spoof risk | gateway.webhookNotification.parse validates |
| Long-polling settled-state in tests | Slow | gateway.testing.settle synchronously |
| Test only success path | Decline / fraud / bank-failure paths matter | Test amount-based magic values |
Limitations
Sources
PayPal Sandbox
View source (opens in new window)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
Install
npm install @paypal/checkout-server-sdk
pip install paypalserversdkOAuth2 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):
| Card | Behaviour |
|---|---|
| 4111 1111 1111 1111 | Visa Sandbox success |
| 5555 5555 5555 4444 | Mastercard success |
| 4032 0359 8001 0008 | Decline |
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-pattern | Why it fails | Fix |
|---|---|---|
| Use live PayPal credentials in tests | Real money | Sandbox-only |
| Test with manual buyer approval | Slow; not CI-suitable | Playwright + sandbox buyer login |
| Skip webhook signature verification | Spoofable | Always verify |
| Hardcode sandbox account IDs | Fragile to account changes | Per-env IDs |
| Test only the API path | Real flow requires checkout UI | Playwright e2e |
| Legacy Payments v1 API | Deprecated | Migrate to Orders v2 |
Treat CREATED as final | Order needs capture | Test the full lifecycle |
| One-shot test for refunds | Refunds are async | Wait for webhook |
Limitations
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.