# Testing

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](#test-cards-verified-2026-09-06-against-nmis-sandbox)).

---

## 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](https://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](./10-errors.md#cloudflare-1010).

---

## 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](./10-errors.md#loopback-in-the-url) and [Embedded SDK) local
development](./08-embedded-sdk.md#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](#velocity-fraud_blocked)
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](#test-cards-verified-2026-09-06-against-nmis-sandbox)), `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](./28-card-tokenization.md)).

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](./18-customers.md).

---

## `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:**

```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`](./12-idempotency.md) 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](./18c-invoices.md).

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](./18b-subscriptions.md#advance-a-test-subscription).

---

## 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](#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 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.

| 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 `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?](./07-3ds.md#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:

| 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 `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](./10-errors.md) 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](#3-d-secure-in-the-sandbox) and
      [3-D Secure](./07-3ds.md)).
- [ ] 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`](./12-idempotency.md) on every charge and refund.
- [ ] You handle [429s](./14-rate-limits.md) with `Retry-After` backoff.
- [ ] Your webhook endpoint verifies events and is reachable (see [Webhooks](./16-webhooks.md)).
- [ ] 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](./12-idempotency.md#a-second-guard-you-do-not-control-the-acquirers-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.
