Testing
View as MarkdownTest mode lets you build and verify your whole integration without moving real money. It runs the same engine as live (fraud, routing, 3-D Secure) without touching a live acquirer.
Test mode is selected entirely by your key. A
*_test_*key is test mode; a*_live_*key is live. There is no separate flag.
The card numbers on this page are ones that actually behaved as described against
https://api.worthypayments.com and NMI's sandbox, not a copy of a generic Stripe table.
Stripe's numbers (lost/stolen, expired, incorrect CVC, 3-D Secure) are left out on
purpose: the sandbox refuses the ones we tried (see
Test cards).
Your own NMI sandbox key#
Test mode runs on your own NMI sandbox account. Every test call that reaches the gateway (a charge, a payment intent or setup intent confirm, saving a card, a capture, a void, a refund) uses the sandbox security key you put in the dashboard. Without one, the call is refused before anything reaches the gateway:
{
"error": {
"type": "invalid_request_error",
"code": "sandbox_key_required",
"message": "Test mode needs your own NMI sandbox security key. Add it in the dashboard under Settings > Integrations, on your NMI card, while the dashboard is in test mode. No NMI sandbox account yet? Create one at https://guide.nmi.com/ (Create Sandbox Account), then copy its API security key."
}
}
The status is 403. Set it up once:
- Create an NMI sandbox account at guide.nmi.com (Create Sandbox Account). NMI gives it sandbox API keys and a sandbox merchant portal.
- Copy that account's API security key.
- Switch the dashboard to test mode, open Settings > Integrations, and paste the key into Security key on the NMI card. The card asks for it while it is missing.
- Retry. The refusal is not stored under your
Idempotency-Key, so the same request now runs.
Your test charges and saved cards then live in your own sandbox account, and you can see them
in NMI's sandbox merchant portal. While your account is not yet live, the test-mode transactions
we sync back from NMI come from that account too; without a key nothing is synced back. NMI caps a sandbox account at 25 saved cards (Customer
Vault entries). Remove the test cards you no longer need with
DELETE /v1/customers/{customerId}/payment_methods/{pmId}; the card is deleted at NMI too.
Test cards saved before 2026-10-18 sit in a sandbox account the platform shared between every account, not in yours, and they can no longer be charged. Remove them (the platform still deletes them from the shared account), then save the test cards again under your own key. Nothing changes in live mode.
Cloudflare User-Agent#
A request with a missing or empty User-Agent is answered by Cloudflare 1010
(an HTML interstitial, not a JSON error envelope). Curl's default UA and a Firefox
UA both pass; Chrome is not required. An empty -A "" dies. Examples on this page
still send -A so a copy-paste cannot lose the header:
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
The browser SDK already sends a real UA. See Errors.
Loopback in the URL#
Local checkout with a pk_test / sk_test key is allowed. List localhost on
the publishable key, then let the browser send Origin: http://localhost:3000.
The SDK already does that.
Do not put http://localhost in a public URL query. These are HTML 403 at the edge
(not JSON, and not an API error) and they never reach the handler:
GET /v1/element-frameable?origin=http://localhost:3000
GET /element?parent=http://localhost:3000
Probe the allowlist with the header and no query:
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
200 {"object":"element_frameable","allowed":true} is the green local-dev signal. See
Errors (loopback in the URL and Embedded SDK) local
development.
The 5-minute first charge (curl only)#
You don't need the browser SDK to make your first test charge. The POST /test/tokens
endpoint mints deterministic ev: tokens for a test card, so you can do the whole loop
from a terminal.
Reuse audit+0005@example.com on the charge (see velocity
below). Charge ids look like TXN-<uuid>, not tx_….
# 1. Mint test card tokens (test keys only)
curl https://api.worthypayments.com/v1/test/tokens \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"number":"4111111111111111"}'
{
"object": "test_token",
"card": {
"number": "ev:test_token_pan_1111",
"cvv": "ev:test_token_cvc_xxx",
"last4": "1111"
}
}
The body is
{ number?: string; card?: string; cvc?: string }.cardis a brand alias:visa/mastercard/mc/amex→ 4111…1111 (they share the label — see Test cards),discover→ 6011…0009,jcb→ 3541…2595,diners→ 3020…9926,maestro→ 6799…0019. A full test PAN still goes innumber. A brand string innumber({"number":"visa"}) is the same alias. It does not mintlast4: "visa". An unknowncardis400 parameter_invalid. A live key returns400 test_mode_only. The response needs no renaming:card.cvvis named the way bothPOST /chargesand the Element confirm read it, so the wholecardobject goes straight through. Supply your own expiry. A test token stands in for the card secret, not the card, so it carries noexp_month/exp_year. Any future date works;12/ current year + 2 is what the dashboard's own test-card list uses.
# 2. Charge with those tokens — the card object goes straight through, no renaming
curl https://api.worthypayments.com/v1/charges \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"amount":2000,"currency":"usd","card":{"number":"ev:test_token_pan_1111","cvv":"ev:test_token_cvc_xxx","exp_month":"12","exp_year":"30"},"customer":{"email":"audit+0005@example.com"}}'
# → { "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "succeeded", .. }
# 3. Read it back
curl "https://api.worthypayments.com/v1/charges" \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx"
That's a full create → charge → list round-trip, no browser required. This path exercises
the ev: rail only. For the wp: rail, encrypt a test PAN yourself (see
Card tokenization).
The same tokens vault. POST /v1/customers/{id}/payment_methods with
payment_method.token set to card.number from the mint is the curl path for a
saved card (subscriptions need one). See Customers.
POST /test/tokens#
Mints ev: tokens for a known test card so you can call POST /charges from a backend
or a curl script.
- Test mode only. A live key returns
400 test_mode_only. - No scope needed. Any valid test key works.
- Rate limited to 5/minute/key (50/minute on test keys).
Request body (all optional):
| Field | Type | Default | Notes |
|---|---|---|---|
number | string | 4111111111111111 | Full test PAN, or a brand alias. Last 4 digits become the token label. |
card | string | — | Brand alias: visa, mastercard, mc, amex, discover, jcb, diners, maestro. Unknown value is 400 parameter_invalid. |
cvc | string | — | Accepted on the body; the response always returns a fixed ev:test_token_cvc_xxx. |
Response:
{
"object": "test_token",
"card": {
"number": "ev:test_token_pan_1111",
"cvv": "ev:test_token_cvc_xxx",
"last4": "1111"
}
}
card is the brand alias (visa / mastercard / mc / amex / discover / jcb /
diners / maestro). The first four all mint ev:test_token_pan_1111 and charge as
Visa — NMI's sandbox Visa, Mastercard and Amex end in the same four digits. A full PAN
still goes in number. The response field is card.cvv. The same name POST /charges and the
Element confirm read, so there is nothing to rename.
Advance a test subscription#
There is no Test Clock. To run the same renewal path the calendar worker uses,
without waiting for current_period_end:
POST /v1/test/subscriptions/{id}/bill
- TEST keys only. A live key is
400 test_mode_onlybefore billing is touched. - Scope
subscriptions:write. A publishable key is refused. Idempotency-Keyis required. Key it on this advance, not the original create. A second bill with the same key is a replay, not a second period.
CHARGE_AUTOMATICALLY (the default) charges the saved card, advances
current_period_start / current_period_end, and writes a PAID invoice
listed on GET /v1/invoices. SEND_INVOICE finalizes and emails an OPEN invoice.
A decline is subscription.past_due, not invoice.payment_failed. See
Invoices.
Reuse audit+0005@example.com so velocity does not block the renewal charge.
curl -X POST https://api.worthypayments.com/v1/test/subscriptions/sub_8c1e.../bill \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: bill_sub_8c1e_period_2" \
-H "API-Version: 2026-01-01"
A successful body is the subscription after the renewal (status: "active", period
dates jumped). The opening create already took the first period; this call is the
second. Walkthrough: Subscriptions.
Test cards (verified 2026-09-06 against NMI's sandbox)#
Type the number into the Payment Element's card field, or mint it via /v1/test/tokens
and charge from curl. Any future expiry works — NMI documents 10/29. CVV 123
(Amex 1234).
A test-mode client_secret puts the element in test mode. The charge then runs the full
engine and is posted to NMI's own sandbox: a real gateway round-trip, not a lookup
table inside the platform.
The sandbox keeps a whitelist, and Stripe's test numbers are not on it.
4242424242424242,5555555555554444,378282246310005and the4000…decline family all answerBad Request. Please use a valid test card or test ACH account from the documentation.6011111111111117answersInvalid Credit Card Number. Use the seven below, or NMI's 3-D Secure test numbers listed under 3-D Secure in the sandbox. Both sets approve.
| Number | Network | Digits |
|---|---|---|
4111111111111111 | Visa | 16 |
5431111111111111 | Mastercard | 16 |
6011000991300009 | Discover | 16 |
341111111111111 | American Express | 15 |
30205252489926 | Diner's Club | 14 |
3541963594572595 | JCB | 16 |
6799990100000000019 | Maestro | 19 |
All seven approve. There is no per-scenario decline card here: the outcome is steered by the request, not by the number you pick.
| To get | Send |
|---|---|
| A decline | An amount below 1.00 — "amount": 99, since amounts are minor units. |
| A fatal error | An invalid card number. |
avs_result: MATCH | Billing address1 888 and postal code 77777. |
cvv_result: MATCH | CVV 999. |
Everything else reads
NO_MATCH. An approved sale with an ordinary address and an ordinary CVV comes backavs_result: NO_MATCH, cvv_result: NO_MATCH. That is the sandbox answering honestly, not a verification failure. Do not write an assertion that treatsNO_MATCHon an approved charge as a bug.
Duplicate window — change the amount for each repeat on the same card. NMI refuses the same card at the same amount as
Duplicate transactionfor 20 minutes, its default window. The platform sends no shorter window to the sandbox, because the sandbox refuses a per-charge override. A different order reference does not help: the check looks at card and amount only. Through the Payment Element the refusal arrives ononErrorascard_declinedwithdeclineCode: 'duplicate_transaction', and the buyer reads "This looks like a duplicate of a payment you just made." Bump the amount by a cent, or switch brands.
incorrect_cvc, expired_card and lost/stolen are not separately simulated. Neither
is a specific issuer decline_code — the amount trigger is what produces a decline.
What POST /test/tokens can reach#
The mint labels a token by the card's last four digits, and three of the sandbox numbers share theirs.
| Alias | Label it mints | Charges as |
|---|---|---|
visa, mastercard, mc, amex | ev:test_token_pan_1111 | Visa 4111111111111111 |
discover | ev:test_token_pan_0009 | Discover |
jcb | ev:test_token_pan_2595 | JCB |
diners | ev:test_token_pan_9926 | Diner's Club |
maestro | ev:test_token_pan_0019 | Maestro |
The
1111collision is real and it is not going away. Visa, Mastercard and Amex all end in1111, so one label serves all three and the charge runs as Visa. To exercise Mastercard or Amex specifically, type the real number into the Payment Element. A full PAN innumberstill works for any of the seven.
3-D Secure in the sandbox#
3-D Secure runs in test mode. It runs on WorthyPayments' 3DS provider (Evervault), not on
NMI's Payer Authentication, so NMI's 3DS test values (a frictionless or challenge flow chosen by
the card number, and the code 12345) do not apply here.
No card number triggers 3DS by itself. The server decides whether a charge is stepped up (see When does a step-up happen?):
- the charge's risk score falls in the default 3DS band, 70 to 95;
- the customer is marked Require 3DS; or
- a Payment Workflow route with Attempt 3DS on matches the charge.
To force a challenge, mark your test customer Require 3DS with the dashboard in
test mode: open one of that customer's transactions and click Require 3DS (next to
Block customer), or select the customer in the Customers list, click Require 3D
Secure and confirm. Every later charge carrying that customer's email is stepped up (a
different or missing email is a different customer), except a merchant-initiated one (a
renewal, a recovery retry, your server charging a saved card), which is charged without
3DS. The mark
applies only to the mode the dashboard is in, and it needs an admin user
(settings:write). To stop it, open the customer and click Turn off under
Restrictions.
Once a charge is stepped up, the first confirm returns requires_action. The Payment
Element (or the SDK) opens the challenge inside the Element's frame, then confirms again
with three_ds.session_id. Every step-up a rule triggers asks the issuer for a challenge
(and so does three_ds.request: challenge or challenge_mandated), so in test mode the
test bank screen appears for any card the sandbox accepts. With three_ds.request: any the
issuer may authenticate without a screen. The screen states its rule:
111-111passes, and the charge is resubmitted.- Any other code fails. The charge is not resubmitted, and the Element reports the failure
on
onError.
NMI's own 3-D Secure test numbers are accepted too, and they approve like any other sandbox card (approved 2026-09-29). They do not trigger 3DS, and NMI's frictionless / challenge split for them does not apply here:
| Visa | Mastercard |
|---|---|
4100000000000100 | 5100000000000107 |
4100000000005000 | 5100000000005007 |
A passed challenge does not get past the duplicate window: after 111-111, the same card
at the same amount inside 20 minutes is still refused as Duplicate transaction. Change
the amount for each repeat.
Reading the dashboard. In the transactions list, the 3DS column shows ✓
(Authenticated) only on an attempt that completed 3DS with a liability shift. !
(Attempted) means 3DS data is present without a liability shift. A dash means 3DS did not
run on that attempt; it does not mean the payment was non-compliant. The mark is recorded
before the gateway answers, so a ✓ can sit on a declined row.
On a 402, the contract is code=card_declined with the issuer reason in
decline_code. Branch on both: some issuer reasons still arrive in code.
Test both branches, because they arrive on different callbacks:
- An approved charge lands on
onSuccesswithstatus: 'succeeded'. - A decline lands on
onErrorwithcode: 'card_declined'. That covers an amount under 1.00 and also a refusal from the gateway itself, such asDuplicate transactionor a card number the sandbox does not accept. The element stays mounted and the intent stays alive, so the buyer can try another card.
A decline never throws and never reaches onSuccess. If your decline handling lives in a
catch block or behind a status check on onSuccess, it will not run.
Creating the intent for the element still needs a secret key with
payment_intents:create (server-only). The publishable key only confirms.
Velocity FRAUD_BLOCKED#
Red box, test-mode velocity is live, and it is not optional. About 5 distinct emails on the same PAN inside 1440 minutes trip a pre-screen rule. The 6th distinct email on that card is refused with HTTP 400 (not 402):
{ "error": { "type": "invalid_request_error", "code": "failed_precondition", "message": "FRAUD_BLOCKED: pre-screen velocity_emails_per_card" } }Unique emails trigger the counter. Reuse
audit+0005@example.comwith Amex341111111111111(or the same email on4111111111111111) and the charge usually succeeds.This is a fraud block, not a card decline. Do not retry with a new email on the same PAN inside the window. A new
Idempotency-Keydoes not bypass it.The Payment Element does not document “banned / spam cards are always blocked”. That copy was not live-verified. Velocity is a charge-path pre-screen; treat
FRAUD_BLOCKEDas the documented block, and do not tell shoppers a card is banned unless you have a blocklist hit of your own.
See Errors for the envelope next to other 400s.
Test vs live checklist#
Before you flip to live keys:
- A successful payment reaches
onSuccessand you storepaymentIntentId. - A decline (any sandbox card charged under 1.00, e.g.
"amount": 99) is handled ononError, not in acatchblock, and the buyer can retry without you re-creating the intent. -
status: 'processing'does not fulfil the order. Fulfilment keys off thecharge.succeededwebhook, so a buyer who closes the tab still gets their goods. - A stepped-up charge completes: the first confirm returns
requires_action, the challenge opens, and the resubmit withthree_ds.session_idlands ononSuccess. Mark the test customer Require 3DS to force it, and enter111-111(see 3-D Secure in the sandbox and 3-D Secure). - Refunds work end-to-end (
POST /refunds, thenGET /refunds/:id). Refund ids look likere_TXN-<uuid>; the parent charge isTXN-<uuid>. - You send an
Idempotency-Keyon every charge and refund. - You handle 429s with
Retry-Afterbackoff. - Your webhook endpoint verifies events and is reachable (see Webhooks).
- Curl examples send a non-empty User-Agent (an empty UA is Cloudflare 1010).
-
POST /v1/test/subscriptions/{id}/billadvances a TEST subscription without waiting for the calendar. A LIVE key istest_mode_only. - Repeated test charges reuse one email (or you accept a
FRAUD_BLOCKED400).
When all of these pass in test, swap sk_test_/pk_test_ for sk_live_/pk_live_.
What actually differs between test and live#
Your code does not change. Same endpoints, same request and response shapes, same error envelope, same webhook payloads, same fraud engine. 3-D Secure is the same path as live, so a test run that completes a challenge proves your step-up handling. Everything on the checklist carries over.
Four things behave differently, and they are worth knowing before you flip the key:
1. Test-mode charges go to NMI's sandbox, not to your live acquirer. A test charge that clears fraud is posted to NMI's sandbox gateway, a real round-trip, and an approved one carries NMI's transaction id. The sandbox answers from its own test rules, though: it approves its test numbers, declines on the amount, and matches AVS and CVV only on fixed test values. So "test worked" proves your integration and the gateway request, but not the hop to your live acquirer.
2. A raw card number is accepted in test, and refused in live. Test mode tolerates a
plain sandbox PAN so you can get moving from a terminal. In live, only a tokenized
instrument (ev:, wp:v1:, a saved payment method or a gateway vault id) is accepted.
The guard is derived from the key's mode, so no configuration can switch it off. If your
integration ever posts a raw number and "works", it works only in test.
3. ACH is rejected in test. Bank-rail charges are live-only, and a test-mode ACH attempt comes back as an invalid request rather than a simulated success. Test the card rail in test mode and validate ACH on a live key with a small real amount.
4. Declines are chosen by you in test, by the issuer in live. Test declines come from
the amount you send (under 1.00, see the table above), not from the card number. In live,
any card can decline for reasons
no test card reproduces, insufficient funds on a card that worked yesterday, an issuer
fraud rule, a velocity limit. Make sure your decline handling branches on error.code
(card_declined) and decline_code rather than on the messages you happened to see in
test.
You will also meet the acquirer's own duplicate window sooner in live than in test, because real orders repeat amounts.
Before your first real charge, put one small live payment through the same code path a customer will use, then refund it. That is the only way to exercise your live acquirer; test mode stops at NMI's sandbox.
