The PayPal sandbox is a separate environment with its own accounts, its own credentials, and no real money. It is not your live account in a test mode — and that difference is where most first attempts go wrong.
This page covers setting it up, the cards and triggers it recognises, how Braintree differs, webhooks, and the places where the sandbox does not behave like production. For the same numbers alongside every other processor, the test card reference is the cross-gateway summary.
What the sandbox actually is
A parallel copy of PayPal. Sandbox accounts live in the Developer Dashboard, not in PayPal proper, so your normal login does not work and your live client ID and secret are different values from your sandbox ones. Money moves between sandbox accounts and nowhere else.
Two credentials pairs, two sets of endpoints, one integration. Almost every “the sandbox is broken” report resolves to a live client ID pointed at a sandbox endpoint or the reverse — worth checking before anything else, exactly as a test card failing in production is usually the wrong key rather than the wrong card.
Creating sandbox accounts
You need two, and the Dashboard’s accounts page creates both:
A business account — the merchant. This is where your sandbox client ID and secret come from, and it receives the payments in your tests.
A personal account — the buyer. This is the login you use in the PayPal popup during a test checkout, and it is worth setting a balance on it when you create it. A sandbox buyer with no funds behaves exactly like a real one with no funds, which produces a failure that looks like an integration bug and is not.
Each sandbox account has a generated email address and a password you can view or change in the Dashboard. Note both somewhere your team can find them — a shared test account whose password only one person knows is a recurring small tax on everyone else.
Sandbox test cards
For card payments that do not go through the PayPal login, the sandbox recognises a published set of numbers. The card selects the brand; it does not select the outcome.
| Card number | Brand |
|---|---|
| 4012 8888 8888 1881 | Visa |
| 4005 5192 0000 0004 | Visa |
| 2223 0000 4840 0011 | Mastercard |
| 3714 496353 98431 | American Express |
| 3646 1510 0000 39 | Diners Club |
| 6304 0000 0000 0000 | Maestro |
| 3636 5000 0000 0260 | JCB |
| 6200 6800 0000 0004 | UnionPay |
Generated numbers do not work here and are not meant to. Use the generator for your own form’s validation layer, and these for anything that reaches PayPal.
Rejection triggers
This is PayPal’s genuinely unusual design, and the thing most worth knowing on this page. The outcome is selected by the cardholder name, not by the card number:
| Trigger value | Simulates |
|---|---|
CCREJECT-REFUSED | Card refused |
CCREJECT-IF | Insufficient funds |
CCREJECT-EC | Expired card |
CCREJECT-LS | Lost or stolen card |
CCREJECT-SF | Suspected fraud |
CCREJECT-CVV_F | Security code failure |
CCREJECT-IRC | Invalid card |
CCREJECT-IA | Invalid account |
CCREJECT-BANK_ERROR | Generic decline |
The values are case-sensitive and go in the first name or name-on-card field. Every other sandbox of this kind — Stripe, Adyen, Braintree — picks the outcome some other way, so a team arriving from one of those will look for decline cards that do not exist.
Source: PayPal — Card testing.
Testing the Checkout integration
The JavaScript SDK renders the buttons and hands you an order to capture server-side. The shape of a minimal integration:
paypal.Buttons({
createOrder: (data, actions) =>
actions.order.create({
purchase_units: [{ amount: { value: '10.00', currency_code: 'USD' } }],
}),
onApprove: async (data) => {
// Capture on YOUR server, never in the browser — the browser can lie.
const res = await fetch(`/api/orders/${data.orderID}/capture`, { method: 'POST' });
const details = await res.json();
if (details.status === 'COMPLETED') showSuccess(details);
},
onError: (err) => {
// Fires for SDK and network failures, not for a declined card.
reportToMonitoring(err);
},
}).render('#paypal-button-container');
Three things to test that this snippet makes easy to skip. onCancel — the buyer closing
the popup is the single most common non-success path and it is not an error. onError
versus a declined capture — they are different branches and only one of them means “try
another card”. And the capture call itself failing after onApprove succeeded, which leaves
an approved order with no capture and is the state that produces support tickets.
The Checkout integration guide and the SDK reference document the full callback set.
Braintree, which is the same company and a different product
Braintree is PayPal-owned with its own sandbox, its own dashboard, and its own conventions. If your integration is Braintree, PayPal’s triggers do not apply.
The useful difference is that the transaction amount selects the processor response:
| Amount | Result |
|---|---|
| 0.01 – 1,999.99 | Authorised and settled |
| 2,000.00 – 2,999.99 | Processor declined |
| 3,000.00 – 3,000.99 | Failed |
| 5,001.00 | Gateway rejected — incomplete application |
That is the cleanest decline mechanism in any sandbox. You change one number in a test
fixture and the same card produces a different outcome, which makes decline paths easy to
parameterise rather than requiring a table of cards. Braintree also publishes card numbers
that decline on verification, such as 4000 1111 1111 1115.
Source: Braintree — Testing.
Negative testing scenarios
Success paths get tested because they are the ones people demo. These are the ones that reach real customers:
The buyer cancels. They close the popup or click cancel. This fires onCancel, not
onError, and it is not a failure — the cart should survive intact and the customer should
be able to try again without re-entering anything.
Insufficient funds. Use CCREJECT-IF for a card flow, or a sandbox buyer account with a
balance below the order total for a wallet flow. The two produce different responses, and
code that handles only one of them will surprise you.
Expired card. CCREJECT-EC. Worth testing separately from a generic decline, because
the right customer-facing message differs: an expired card is worth telling someone about,
where a generic refusal is not.
A payment left pending. Some payments do not resolve immediately. Your order state machine needs a pending state that is neither success nor failure, and the webhook that later resolves it needs to move the order without double-fulfilling.
Refunds, full and partial. A partial refund against a captured payment is where amount arithmetic goes wrong, especially with tax and shipping split across line items. Test that the refunded total can never exceed the captured total, including across several partial refunds.
Disputes. The sandbox can simulate a dispute so you can exercise the webhook and the internal state change. Even if your process is manual, the event should be recorded rather than dropped.
Each of these has a customer-visible consequence, and none of them is exercised by a successful test payment.
Webhooks in the sandbox
Sandbox webhooks are configured per application in the Developer Dashboard, and the Dashboard includes a simulator that fires a chosen event type at your endpoint without a real transaction. That is useful for wiring, and it is not sufficient — a simulated event is not identical to one produced by an actual order, so exercise both.
Verify the signature on every delivery. PayPal’s verification is a server-side API call rather than a local HMAC, which means your handler depends on PayPal being reachable to validate a message from PayPal:
// Verify before trusting the payload. This is an API call, not a local check.
const verification = await fetch(
'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature',
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({
auth_algo: req.headers['paypal-auth-algo'],
cert_url: req.headers['paypal-cert-url'],
transmission_id: req.headers['paypal-transmission-id'],
transmission_sig: req.headers['paypal-transmission-sig'],
transmission_time: req.headers['paypal-transmission-time'],
webhook_id: process.env.PAYPAL_WEBHOOK_ID,
webhook_event: req.body,
}),
}
).then((r) => r.json());
if (verification.verification_status !== 'SUCCESS') {
return res.status(400).send('signature verification failed');
}
// PayPal retries. Record event id and return early if already handled.
if (alreadyHandled(req.body.id)) return res.sendStatus(200);
Test the failure branch deliberately: alter one header and confirm the request is rejected, then deliver the same event twice and confirm the order is fulfilled once. Both are covered in the webhooks documentation.
Sandbox limitations, honestly
The sandbox is a good environment and it is not production:
- Some features behave differently or are missing. Newer products in particular reach the sandbox later than the live platform.
- Not every country and currency combination is supported. A market that works live may have no sandbox equivalent, and the failure looks like a configuration error.
- The sandbox has its own outages, independent of production, and they are not always announced promptly. An integration that worked yesterday and fails today with no code change is worth checking against status before debugging.
- Timing differs. Settlement, disputes and some asynchronous events do not run on production’s timetable.
The practical conclusion: a green sandbox run means your integration is correct, not that it will work live. Before launch, run one small real transaction and refund it. That step catches the account configuration problems no sandbox can model.
It is worth doing that final check with a colleague’s card rather than your own, from a different device and network. A merchant testing their own checkout while signed in to their own business account exercises a path no customer will ever take, and several classes of problem — account linkage, currency handling, risk rules applied to first-time buyers — only appear when the buyer is genuinely someone else.
Common mistakes
- Live credentials against sandbox endpoints, or the reverse. Check the keys before anything else; it is the cause more often than not.
- Using your real PayPal login for the buyer. Sandbox buyers are separate accounts created in the Developer Dashboard.
- Forgetting to fund the buyer account. A balance-based flow fails for a reason that is not your code.
- A country mismatch. A sandbox business account in one country and a test flow assuming another produces behaviour that is correct and confusing.
- Testing webhooks only in the sandbox. Configure and verify them in production too, before the first real order rather than after.
- Trying generated card numbers. They pass your form and stop at PayPal — the payment form checklist covers which layer each kind of number belongs to, and the Stripe reference is the comparison if you work with both.
The card numbers, triggers and amount ranges above were last checked against provider documentation on . Providers do change what they publish — the official link beside each claim is authoritative.