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 numberBrand
4012 8888 8888 1881Visa
4005 5192 0000 0004Visa
2223 0000 4840 0011Mastercard
3714 496353 98431American Express
3646 1510 0000 39Diners Club
6304 0000 0000 0000Maestro
3636 5000 0000 0260JCB
6200 6800 0000 0004UnionPay

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 valueSimulates
CCREJECT-REFUSEDCard refused
CCREJECT-IFInsufficient funds
CCREJECT-ECExpired card
CCREJECT-LSLost or stolen card
CCREJECT-SFSuspected fraud
CCREJECT-CVV_FSecurity code failure
CCREJECT-IRCInvalid card
CCREJECT-IAInvalid account
CCREJECT-BANK_ERRORGeneric 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:

AmountResult
0.01 – 1,999.99Authorised and settled
2,000.00 – 2,999.99Processor declined
3,000.00 – 3,000.99Failed
5,001.00Gateway 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

  1. Live credentials against sandbox endpoints, or the reverse. Check the keys before anything else; it is the cause more often than not.
  2. Using your real PayPal login for the buyer. Sandbox buyers are separate accounts created in the Developer Dashboard.
  3. Forgetting to fund the buyer account. A balance-based flow fails for a reason that is not your code.
  4. A country mismatch. A sandbox business account in one country and a test flow assuming another produces behaviour that is correct and confusing.
  5. Testing webhooks only in the sandbox. Configure and verify them in production too, before the first real order rather than after.
  6. 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.

Frequently Asked Questions

A parallel copy of PayPal with its own accounts, its own credentials, and no real money. Sandbox accounts are created in the Developer Dashboard rather than being your live account in a test mode, which is the first thing that surprises people: your normal PayPal login does not work there, and the client ID and secret are different values entirely.
By typing a trigger value into the cardholder name field. CCREJECT-REFUSED forces a refusal, CCREJECT-IF insufficient funds, CCREJECT-EC an expired card, CCREJECT-LS lost or stolen, CCREJECT-SF suspected fraud, and CCREJECT-CVV_F a security code failure. The values are case-sensitive. This is unusual — most sandboxes select the outcome with the card number — and it catches people who assume PayPal works like Stripe.
No. The sandbox recognises its own published numbers and rejects everything else, because the outcome is scripted rather than computed from the digits. Generated numbers are the right tool for your own form’s validation and the wrong tool the moment a request leaves your application.
Braintree is PayPal-owned but a separate product with its own sandbox and conventions. The most useful difference is that Braintree selects the processor response by transaction amount rather than by card or name — anything up to 1,999.99 is authorised, 2,000.00 to 2,999.99 is processor-declined, and 5,001.00 is gateway-rejected. That makes it the cleanest way to test a decline without changing the card on file.
For PayPal-balance flows, yes — a personal sandbox account with no funds behaves like a real one with no funds, and a test that should succeed will fail for a reason that has nothing to do with your code. Set the balance when you create the account in the Developer Dashboard rather than debugging it later.
No, and planning around that is part of using it. Some features behave differently or are unavailable, certain country and currency combinations are not supported, and the sandbox has its own outages. Treat a green sandbox run as evidence your integration is correct, not as proof it will work live — a small real transaction, refunded, is the last step.