Testing

View as Markdown

Test 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:

json
{
  "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:

  1. Create an NMI sandbox account at guide.nmi.com (Create Sandbox Account). NMI gives it sandbox API keys and a sandbox merchant portal.
  2. Copy that account's API security key.
  3. 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.
  4. 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:

bash
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_….

bash
# 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"}'
json
{
  "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 }. card is 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 in number. A brand string in number ({"number":"visa"}) is the same alias. It does not mint last4: "visa". An unknown card is 400 parameter_invalid. A live key returns 400 test_mode_only. The response needs no renaming: card.cvv is named the way both POST /charges and the Element confirm read it, so the whole card object goes straight through. Supply your own expiry. A test token stands in for the card secret, not the card, so it carries no exp_month / exp_year. Any future date works; 12 / current year + 2 is what the dashboard's own test-card list uses.

bash
# 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):

FieldTypeDefaultNotes
numberstring4111111111111111Full test PAN, or a brand alias. Last 4 digits become the token label.
cardstring—Brand alias: visa, mastercard, mc, amex, discover, jcb, diners, maestro. Unknown value is 400 parameter_invalid.
cvcstring—Accepted on the body; the response always returns a fixed ev:test_token_cvc_xxx.

Response:

json
{
  "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_only before billing is touched.
  • Scope subscriptions:write. A publishable key is refused.
  • Idempotency-Key is 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.

bash
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, 378282246310005 and the 4000… decline family all answer Bad Request. Please use a valid test card or test ACH account from the documentation. 6011111111111117 answers Invalid 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.

NumberNetworkDigits
4111111111111111Visa16
5431111111111111Mastercard16
6011000991300009Discover16
341111111111111American Express15
30205252489926Diner's Club14
3541963594572595JCB16
6799990100000000019Maestro19

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 getSend
A declineAn amount below 1.00 — "amount": 99, since amounts are minor units.
A fatal errorAn invalid card number.
avs_result: MATCHBilling address1 888 and postal code 77777.
cvv_result: MATCHCVV 999.

Everything else reads NO_MATCH. An approved sale with an ordinary address and an ordinary CVV comes back avs_result: NO_MATCH, cvv_result: NO_MATCH. That is the sandbox answering honestly, not a verification failure. Do not write an assertion that treats NO_MATCH on 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 transaction for 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 on onError as card_declined with declineCode: '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.

AliasLabel it mintsCharges as
visa, mastercard, mc, amexev:test_token_pan_1111Visa 4111111111111111
discoverev:test_token_pan_0009Discover
jcbev:test_token_pan_2595JCB
dinersev:test_token_pan_9926Diner's Club
maestroev:test_token_pan_0019Maestro

The 1111 collision is real and it is not going away. Visa, Mastercard and Amex all end in 1111, 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 in number still 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-111 passes, 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:

VisaMastercard
41000000000001005100000000000107
41000000000050005100000000005007

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 onSuccess with status: 'succeeded'.
  • A decline lands on onError with code: 'card_declined'. That covers an amount under 1.00 and also a refusal from the gateway itself, such as Duplicate transaction or 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):

json
{
  "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.com with Amex 341111111111111 (or the same email on 4111111111111111) 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-Key does 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_BLOCKED as 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 onSuccess and you store paymentIntentId.
  • A decline (any sandbox card charged under 1.00, e.g. "amount": 99) is handled on onError, not in a catch block, and the buyer can retry without you re-creating the intent.
  • status: 'processing' does not fulfil the order. Fulfilment keys off the charge.succeeded webhook, 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 with three_ds.session_id lands on onSuccess. Mark the test customer Require 3DS to force it, and enter 111-111 (see 3-D Secure in the sandbox and 3-D Secure).
  • Refunds work end-to-end (POST /refunds, then GET /refunds/:id). Refund ids look like re_TXN-<uuid>; the parent charge is TXN-<uuid>.
  • You send an Idempotency-Key on every charge and refund.
  • You handle 429s with Retry-After backoff.
  • 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}/bill advances a TEST subscription without waiting for the calendar. A LIVE key is test_mode_only.
  • Repeated test charges reuse one email (or you accept a FRAUD_BLOCKED 400).

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.