A sandbox is very good at telling you your integration works. It is much worse at telling you your integration survives, because the happy path is the only one it produces unless you ask. This is the test plan we would want before taking real money: what the sandbox can simulate, what it claims to and did not, and what you have to fake yourself.
Everything marked as tested was run against the Paydude sandbox on 28 September 2026.
Test cards
| Brand | Number | CVC | Expiry |
|---|---|---|---|
| Visa | 4111 1111 1111 1111 | Any 3 digits | Any future date |
| Visa | 4900 7700 0000 0001 | Any 3 digits | Any future date |
| Mastercard | 5100 4000 0000 0000 | Any 3 digits | Any future date |
| Discover (US) | 6011 2345 6789 0123 | Any 3 digits | Any future date |
| American Express (US) | 3701 234567 89017 | Any 4 digits | Any future date |
Collect them through the hosted card fields exactly as in production. Posting a card number to the API from your own server works in the sandbox, but it is the one habit you must not carry into production — see PCI scope.
Declines are triggered by amount
The card number does not decide the outcome. The payment intent's amount does:
| Amount | Failure code |
|---|---|
| $7,777.01 | insufficient_funds ✓ tested |
| $7,777.02 | avs_check_failed |
| $7,777.03 | generic_decline |
| $7,777.04 | call_issuer |
| $7,777.05 | expired_card ✓ tested |
| $7,777.06 | pickup_card |
| $7,777.07 | invalid_number |
| $7,777.08 | limit_exceeded |
| $7,777.09 | not_permitted |
| $7,777.10 | incorrect_cvc |
| $7,777.11 | service_not_allowed |
| $7,777.12 | invalid_expiry |
| $7,777.13 | card_not_supported |
| $7,777.14 | restricted_card |
| $7,777.15 | fraudulent |
| $7,777.16 | processing_error |
The trigger amounts suit a test harness that creates its own payment intents. For a test that goes through your checkout, you need a product priced at $7,777.01 in your test catalogue, or a test-only hook that overrides the amount.
Disputes
Disputes are triggered the same way. These are from the testing guide and were not exercised for this article:
| Amount | Simulates | Ends in |
|---|---|---|
| $7,777.97 | An inquiry | warning_under_review |
| $7,777.98 | An inquiry that escalates to a chargeback | needs_response |
| $7,777.99 | A formal chargeback | needs_response |
When you submit evidence, an evidence_description of REVERSAL resolves the dispute as won and LOSS as lost, according to the same guide.
The scenarios worth a test each
| Scenario | How to produce it | What to assert |
|---|---|---|
| Card approved | Any ordinary amount | Order paid only after succeeded, not after a 2xx |
| Card declined | $7,777.01 | 201 with status: requires_payment_method; a useful message; order still open |
| Retry after a decline | Decline, then confirm the same intent with a new card | Two charges on one intent; the order pays once |
| Reusing a declined card | Confirm a new intent with the declined payment method | Your UI asks for the card again rather than surfacing a 400 |
| Token expiry | Tokenise, wait past expires_at, confirm | A clear retry, not a stuck checkout |
| Bank payment | An ACH debit | Order stays pending through processing; fulfils on the webhook |
| Subscription payment fails | A subscription priced at $7,777.01 ✓ tested | Your dunning opens a case when it goes past_due |
| Duplicate webhook | Replay a stored event to your own endpoint | Nothing happens twice |
| Out-of-order webhook | Deliver a later event before an earlier one | Final state is correct |
| Refund | Refund a succeeded payment | Order marked refunded on success, not on request |
| Dispute | $7,777.99 | Someone is alerted before closing_at |
Two of those deserve a note from the sandbox runs. A declined payment method cannot be reused — confirming with it again returns 400, "The payment method … is not chargeable" — so the retry test has to tokenise a fresh card. And an unattached payment method carries an expires_at: in our runs it was ten minutes after creation. Read the field rather than hard-coding a window.
Subscriptions do not bill at the exact anchor time. A subscription anchored at 22:42 UTC was billed at 23:00:00 — the sandbox appears to run renewals on the hour — so a test waiting on a renewal should poll for up to an hour rather than sleep until the anchor.
Bank payments are slow on purpose. In the sandbox an ACH debit reported processing and succeeded about fourteen and a half hours later, so an end-to-end ACH test either waits overnight or stops at processing and tests the webhook handler separately with a recorded payment_intent.succeeded payload.
What the sandbox did not simulate
The testing guide documents more simulations than the card-amount triggers. On the sandbox merchant we tested, these did not behave as described:
- Address verification prefixes. The guide says prefixing the street with a code such as
11makes the postal-code check fail. We charged cards with street prefixes10,11,12andA11; everyaddress_line1_checkandaddress_postal_code_checkcame backpass. - Failing CVC values. The guide says any CVC except
333or666passes, implying those two fail. Both produced successful payments withcvc_check: pass. - Filtering events by delivery status.
GET /v1/events?delivery_status=…returned a 500 for every documented value. See handling webhooks reliably for a sweep that does not need it.
Note also that the checks read unchecked on a freshly created payment method and are only filled in once it has been charged — so a test that inspects card.checks straight after tokenising sees nothing either way.
The practical answer is to test AVS and CVC handling at the unit level: record the response shapes, construct the failing ones yourself, and assert your code does the right thing with address_postal_code_check: "fail" without needing the sandbox to produce it.
What no sandbox can simulate
The most expensive production failure in payments is a request that succeeds on the server and whose response is lost on the way back. Your code sees a timeout; the customer has been charged. The sandbox will never do this to you on request, so wrap your API client and do it yourself:
// Test-only wrapper: performs the real call, then pretends it timed out.
// This is the exact failure an idempotency guard exists for.
function withLostResponses(api, { rate = 1 } = {}) {
return {
...api,
async post(path, body) {
const result = await api.post(path, body); // the write really happens
if (Math.random() < rate) {
throw Object.assign(new Error("ETIMEDOUT"), { code: "ETIMEDOUT" });
}
return result;
},
};
}
test("a lost response does not charge twice", async () => {
const flaky = withLostResponses(sandboxApi, { rate: 1 });
await expect(checkout(order, { api: flaky })).rejects.toThrow();
// Retry with the real client, as your retry logic would.
await checkout(order, { api: sandboxApi });
const intents = await sandboxApi.get(
`/v1/payment-intents?metadata[reference]=${order.reference}`);
expect(intents.items).toHaveLength(1);
});The Paydude API has no idempotency keys, so this test is the only evidence your own guard works — the pattern it exercises is in idempotency keys explained. Adjust the metadata query to however your client encodes nested parameters.
The other things beyond any sandbox: how real issuers distribute their declines, real 3D Secure challenges on real cards, and real settlement timing. Sandbox payouts ran at about 12:00 UTC each day in our runs; that is the sandbox's schedule, not a promise about production. Plan a small live test with a real card and a real refund before launch, and watch the first week of production declines closely.
Before you go live
- Every row of the test matrix above passes.
- The lost-response test passes and runs in CI.
- Test-only code paths — raw card posting, amount overrides, fault injection — cannot run in production.
- Production keys and the production account id are in production config only, and sandbox keys are nowhere near it.
- The webhook endpoint is registered for production, verified with the production signing secret, and alerting on status changes.
- One real payment and one real refund have been made and reconciled against a payout.
GOOD QUESTIONS
Frequently asked
What card number should I use to test payments?+
4111 1111 1111 1111 with any future expiry and any CVC works in the Paydude sandbox, along with test Mastercard, Discover and American Express numbers. The card number does not control the outcome — the payment amount does.
How do I simulate a declined card in the sandbox?+
Create the payment intent for one of the trigger amounts between $7,777.01 and $7,777.16. $7,777.01 declines with insufficient_funds, $7,777.05 with expired_card and $7,777.15 with fraudulent. Smaller amounts such as $77.01 succeed.
How do I test a dispute?+
Use $7,777.97 for an inquiry, $7,777.98 for an inquiry that escalates, or $7,777.99 for a formal chargeback. When submitting evidence, the testing guide says an evidence description of REVERSAL wins the dispute and LOSS loses it.
Can I test AVS and CVC failures in the sandbox?+
The testing guide documents street-address prefixes and CVC values for this, but on the sandbox merchant we tested every address and CVC check came back as a pass. Test that handling at the unit level with constructed responses instead.
How do I test what happens when an API response is lost?+
Wrap your API client so it performs the real request and then throws a timeout, and assert that your retry logic does not create a second payment. No sandbox produces this failure on request, and it is the one most likely to double-charge a customer.
How long do test ACH payments take?+
In our sandbox run an ACH debit stayed in processing for about fourteen and a half hours before succeeding. Sandbox timing is not production timing, but it is long enough that ACH tests should exercise the webhook handler rather than wait for the payment.
