# Test cards & sandbox

> [!NOTE]
> Whether a card payment reaches real card networks is decided by your account's
> **operational mode**, not by the host you call. A **Demo** account routes to the upstream
> card processor's **sandbox** — no real card networks, no money movement. A **Live** account
> does not: test PANs on a Live account can reach real cards, so confirm your
> `operationalMode` before sending one.
>
> Whether a payment goes through a 3DS challenge is controlled by the **`skip3DS` request flag**, not by the card number (see below).

This page is your self-service reference for sandbox testing.


---

## How the sandbox works

**Sandbox or live is a property of your ACCOUNT, not of the URL you call.**

Your seller account carries an `operationalMode`, and that mode — not the hostname — decides
where a payment goes:

- **Demo** routes to the upstream processor's **sandbox**. No real card networks are involved
  and no money moves, so test card numbers are exactly what you should be using.
- **Live** routes to **real card networks**. Never send a test card number on a Live account —
  it will be declined or, worse, charged to a real card if the number happens to exist.
- The Fynex-hosted checkout page, the server-to-server flow, and the checkout widget all
  route through the same upstream sandbox when the account is in Demo mode.
- Alternative payment methods (APMs) use a separate local-payment sandbox flow. Bancontact
  has its own public test PANs, Multibanco / MB WAY use the provider-hosted mock page, Wero
  uses a UAT consent site, and Swish credentials are provisioned separately. See
  [Alternative payment methods (APM)](https://docs.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm) for the
  step-by-step APM testing table.

> [!CAUTION]
> **Never send a test PAN on a Live account.** Confirm your mode first: query your seller
> account and check `operationalMode` — the same check described in
> [Going live](https://docs.fynex.ai/payments-api/v2/docs#tag/going-live). If it reads `Live`, test cards are not safe. Use real cards,
> or ask us to provision a Demo account for integration work.

### Which base URL do I use?

Mode is independent of environment. A Demo account on `https://api.fynex.ai` is a sandbox
account, and that is not a misconfiguration.

So the host does not tell you your mode, and your mode does not tell you the host. **Use the
base URL you were issued alongside your token.**

The two environments keep entirely separate credential stores, so a token only authenticates
against the host it was issued for. Pointing it at the other one returns HTTP **401** with
`code` `invalid_api_key` (`invalid authorization token`). That means *wrong host for this token* — not
that your credentials are broken, and not that you should switch hosts to fix it.

> [!NOTE]
> Fynex delivers outbound `PaymentCompleted` webhooks to the URLs configured on your seller
> account (and to any per-request `webhookUrl`). Your receiver must return **HTTP 200** for a
> delivery to count as successful — any other status is retried (up to 3 attempts) and then
> marked failed. You can also verify payment outcomes by polling
> `GET /payments-api/v1/payments/{payment_id}` (the path value is your `externalOrderRef`) or
> subscribing to the SSE stream — see the [Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse) guide.

---

## Controlling 3DS

On the server-to-server flow (`POST /payments-api/v1/initialize-payment`), **whether** a 3DS
flow runs is selected by the **`skip3DS`** flag in the request body. When 3DS does run, the
**card number** then determines the authentication *outcome* (`threeDResult` — see
[Card numbers](#card-numbers)):

| `skip3DS` | Behaviour |
|-----------|-----------|
| `true` | No 3DS. The response has `requiresAction: false` and the payment proceeds straight to authorization (`lifecycleStatus` `captured`, or `authorized` for a manual-capture payment). Use this for the **no-redirect** test flow. |
| `false` / omitted | 3DS is requested. The response has `requiresAction: true` and an `actionUrl`. Redirect the customer to `actionUrl`, let them complete the challenge on the processor's sandbox page, then call `POST /finalize-payment`. Use this for the **3DS redirect** test flow. See the [3DS guide](https://docs.fynex.ai/payments-api/v2/docs#tag/3ds). |

On the **hosted checkout** (`POST /checkout`) the hosted page manages 3DS internally — you only
observe the final outcome when polling.

---

## Card numbers

> [!IMPORTANT]
> On this sandbox the **card number selects the 3DS authentication outcome** (`threeDResult`).
> `skip3DS` only controls *whether* a 3DS flow runs at all — when 3DS runs (e.g. on the hosted
> checkout, which always runs it), the **PAN** determines whether authentication succeeds (`Y`),
> challenges (`C`), or is rejected/unable (`R`/`U`/`N`). A handle whose authentication is not
> `Y`/`A` is failed by the processor's risk rule and **cannot be settled** — the settle call comes
> back with a field-level error saying the payment handle is in a non-payable state. Use the right PAN.

### Cards that authenticate successfully (`threeDResult=Y`, frictionless — no challenge)

Use these for a **happy-path success** on the hosted checkout — they authenticate without an OTP
and reach `lifecycleStatus` `captured`:

| Card number | Brand |
|-------------|-------|
| `4000 0000 0000 2701` | Visa — frictionless `Y` |
| `5200 0000 0000 2235` | Mastercard — frictionless `Y` |

### Cards that trigger a 3DS challenge (`threeDResult=C`)

These redirect to the processor's 3DS **emulator**, where you select the authentication
status/reason to test (choose `Y` to complete successfully):

| Card number | Brand |
|-------------|-------|
| `4000 0000 0000 2503` / `4000 0000 0000 2370` / `4000 0000 0000 2420` | Visa — challenge |
| `5200 0000 0000 2490` / `5200 0000 0000 2151` / `5200 0000 0000 2664` | Mastercard — challenge |

### Cards that fail authentication (for decline/UI testing)

| Card number | Outcome |
|-------------|---------|
| `4530 9100 0001 2345`, `4500 0300 0000 0004`, `4037 1122 3300 0001` | `threeDResult=U` (unable to authenticate) → risk-rule rejected |
| `4000 0000 0000 2925` | `threeDResult=N` (not enrolled) |

Unless noted otherwise, use:

- **Expiry month:** any future month, 2 digits (e.g. `12`)
- **Expiry year:** any future year. **4-digit (`2028`) is recommended.** A 2-digit year
  (`28`) is also accepted — Fynex widens it to `2028` before calling the processor.
- **CVV / CVC:** any 3-digit number (e.g. `123`); American Express CID: any 4-digit number
- **Cardholder name:** Latin letters (`A-Z`), spaces, apostrophes, dots, or hyphens only

> [!NOTE]
> The card numbers listed in this section are the set Fynex supports; the upstream sandbox can
> add or retire PANs without notice, so email support@fynex.ai if one stops behaving as documented.
> On a **Live** account a real issuer returns `Y` for a genuine card, so the `U`/`N` failure modes
> above are sandbox-only artifacts of the test PANs.

---

## Forcing a specific `failureCode`

The card numbers above select a 3DS *authentication* outcome — they don't let you deterministically
produce a specific numeric `failureCode` (e.g. testing your handling of `2003` `ProviderTimeout`
versus `3002` `AuthenticationRequired` versus `2001` `ProviderDeclined`). For that, send the
**`Simulator`** request header on a **Demo-mode** account:

```
Simulator: force_failure=2003
```

The payment is stamped `failed` with exactly that `failureCode` — same `status`, `failureDescription`,
`retry` and `failureCategory` as a real failure, and the same `PaymentCompleted` webhook fires — but
**no call reaches the card processor at all**. This makes failure-handling tests fully deterministic:
no dependency on the upstream sandbox's own behavior, and no risk of the failure mode drifting if the
processor's sandbox changes.

- Any code in the [failure code catalogue](https://docs.fynex.ai/payments-api/v2/docs#tag/errors) can be forced, **except** `0` (no failure)
  and `9999` (unclassified) — those describe the absence of a classified failure, not one to simulate.
  An unrecognised or malformed value returns `400`.
- **Demo-mode only.** On a **Live** account the `Simulator` header is never parsed or acted on,
  whatever it contains — it cannot affect a real payment.
- Applies to `POST /payments-api/v1/initialize-payment`.

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Simulator: force_failure=2003" \
  -d '{
    "externalOrderRef": "TEST-SIMULATE-TIMEOUT-1",
    "amount": 19.99,
    "paymentType": "card",
    "paymentMethod": "card",
    "currencyCode": "GBP",
    "countryCode": "GB",
    "autoSettlement": true,
    "skip3DS": true,
    "cardData": {
      "cardNumber": "4111111111111111",
      "expMonth": 12,
      "expYear": 2028,
      "holderName": "Test User",
      "cvv": "123"
    },
    "returnLinks": [
      { "rel": "default", "href": "https://example.com/return", "method": "GET" }
    ]
  }'
```

> [!IMPORTANT]
> **The `initialize-payment` response itself does NOT carry the simulated `failureCode`.** A
> simulated failure follows the exact same path a real authorization-stage failure does, which
> returns `502` with a generic body — same as any other authorization-time failure on this call
> (validation errors return `400`, an idempotency conflict `409`, and so on; those aren't what a
> `Simulator` request produces, since it always reaches the authorization stage):

```json
{ "error": "the request could not be processed - please try again or contact support" }
```

The requested `failureCode`, `status: "failed"`, and matching `failureDescription` are visible on
the payment row: poll `GET /payments-api/v1/payments/{paymentId}` (using your `externalOrderRef`),
or read them off the `PaymentCompleted` webhook, which fires exactly as it would for a real
failure. This is the same behavior every other **authorization-stage** failure already has —
see the [Errors guide](https://docs.fynex.ai/payments-api/v2/docs#tag/errors) — the simulator does not special-case it.

The exception is the two card refusals made before authorization:
`force_failure=1007` and `force_failure=1008` return `422` with a machine-readable
`code` (`card_expired`, `card_brand_not_supported`), exactly as a real expired
card or unsupported brand does — see *Common 422 responses* in the
[Errors guide](https://docs.fynex.ai/payments-api/v2/docs#tag/errors).

---

## Common test scenarios

#### Happy path — no 3DS

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "externalOrderRef": "TEST-HAPPY-1",
    "amount": 19.99,
    "paymentType": "card",
    "paymentMethod": "card",
    "currencyCode": "GBP",
    "countryCode": "GB",
    "autoSettlement": true,
    "skip3DS": true,
    "cardData": {
      "cardNumber": "4111111111111111",
      "expMonth": 12,
      "expYear": 2028,
      "holderName": "Test User",
      "cvv": "123"
    },
    "returnLinks": [
      { "rel": "default", "href": "https://example.com/return", "method": "GET" }
    ]
  }'
```

The response has `requiresAction: false` and the payment's `lifecycleStatus` becomes `captured`.
Poll `GET /payments-api/v1/payments/{payment_id}` to confirm before fulfilling the order.

> [!NOTE]
> `countryCode` is required and, if your terminal pins a country, must match it. `returnLinks`
> (note: not `returnUrls`) is required on the server-to-server flow — each link needs
> `rel` ∈ `default | on_completed | on_failed | on_cancelled`, an `http`/`https` `href`, and
> `method: "GET"`. The hosted checkout flow uses `returnUrls` at session creation instead.

#### 3DS challenge

Send the same request **without** `skip3DS` (or with `"skip3DS": false`). The response is:

```json
{
  "paymentId": "TEST-3DS-1",
  "status": "provider_pending",
  "lifecycleStatus": "pending",
  "legacyStatus": "provider_pending",
  "requiresAction": true,
  "actionUrl": "https://3ds.example.com/.../authentication/..."
}
```

Redirect the customer's browser to `actionUrl`, let them complete the sandbox challenge, then
call `POST /finalize-payment` with the same `paymentId`. See the [3DS guide](https://docs.fynex.ai/payments-api/v2/docs#tag/3ds) for the
full localStorage bridge pattern.

#### Decline / failure UI testing

A payment that the processor declines (or a 3DS challenge the customer fails) transitions to
`failed`; a customer who abandons the challenge returns `cancelled`. When you poll for status,
the response includes:

```json
{
  "paymentId": "TEST-DECLINE-1",
  "status": "failed",
  "lifecycleStatus": "failed",
  "legacyStatus": "failed",
  "failureCode": 2001,
  "failureDescription": "Card declined by issuer"
}
```

| Field | Purpose |
|-------|---------|
| `lifecycleStatus` | `"failed"` for hard declines; `"cancelled"` if the customer cancelled |
| `failureCode` | Numeric code — see the [Errors guide](https://docs.fynex.ai/payments-api/v2/docs#tag/errors) for the full list |
| `failureDescription` | Human-readable reason — do not display verbatim to customers |

> [!CAUTION]
> Distinguish `"cancelled"` from `"failed"`. A customer who clicks "Back" or abandons the
> form returns a `cancelled` status — show a neutral "payment not completed" message rather
> than an error.

#### Refund testing

1. Pay and wait for the payment to become refundable — `lifecycleStatus` `captured` and `settlementStatus` not `in_flight` (see [Captures & Refunds](https://docs.fynex.ai/payments-api/v2/docs#tag/captures-refunds)).
2. Issue the refund using the public endpoint with your `externalOrderRef` as the path param:

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/TEST-REFUND-1/refund" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "amount": 19.99 }'
```

> [!CAUTION]
> Only payments whose `lifecycleStatus` is `captured` can be refunded — a pre-capture refund attempt returns `409 "payment action is not allowed for the current payment status"`. Omit `amount` to refund the remaining refundable amount, or pass a smaller value for a partial refund.

#### Partial capture

Create a manual-capture payment (`autoSettlement: false`), then capture less
than the authorized amount:

```bash
# Authorize £50
curl -sS -X POST "$FYNEX_API/payments-api/v1/initialize-payment" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "externalOrderRef": "TEST-PARTIAL-1",
    "amount": 50.00,
    "paymentType": "card",
    "paymentMethod": "card",
    "currencyCode": "GBP",
    "countryCode": "GB",
    "autoSettlement": false,
    "captureMode": "manual",
    "skip3DS": true,
    "cardData": {
      "cardNumber": "4111111111111111",
      "expMonth": 12,
      "expYear": 2028,
      "holderName": "Test User",
      "cvv": "123"
    },
    "returnLinks": [
      { "rel": "default", "href": "https://example.com/return", "method": "GET" }
    ]
  }'

# Capture only £35
curl -sS -X POST "$FYNEX_API/payments-api/v1/finalize-payment" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "paymentId": "TEST-PARTIAL-1", "amount": 35.00 }'
```

The response `capturedAmount` will be `35.00`. The remaining £15 is released back to the
customer's card.

---

## Test customer data

The sandbox does not validate billing data against real services. Use any
well-formed values:

| Field | Suggested test value | Notes |
|-------|---------------------|-------|
| Email | `test@example.com` | Any `*@example.com` address works; no emails are sent from sandbox |
| Billing name | `Test User` | Any non-empty string |
| Billing address | `1 Test Street, London, EC1A 1BB, GB` | Staging does not run AVS checks against real addresses |
| Phone | `+44 7700 900000` | Any well-formed `+44` number; not validated |
| Postcode / ZIP | `EC1A 1BB` | Not validated by AVS in sandbox |

---

## Paying a Demo checkout without a browser

A hosted checkout session is paid by its buyer, on its `checkoutUrl`. When there is no buyer — a
test suite, a CI job, an AI agent — a Demo account can pay its own session with one call. Fynex
submits the session's checkout page with the frictionless Visa above (`4000 0000 0000 2701`) and
the [test customer data](#test-customer-data), runs 3DS and completes the payment:

```bash
curl -sS -X POST "$FYNEX_API/test/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b/complete" \
  -H "Authorization: Bearer $FYNEX_TOKEN"
```

```json
{ "status": "completed", "paymentId": "ORDER-100106", "lifecycleStatus": "captured" }
```

The path takes the `sessionId` `POST /checkout` returned. No card details are sent to or returned
by the call, and the payment is the session's own: `GET /payments/{paymentId}`, webhooks and
events see an ordinary card payment. A session created with manual capture ends `authorized`;
capture it before settling. Repeating the call is safe — a session that was already paid answers
`already_completed`. It is refused on a Live account (`403`, `code` `demo_only`), for another
account's session (`404`, `checkout_session_not_found`), once the session has expired (`410`,
`checkout_expired`), and with the checkout page's own refusal otherwise.

A Demo account takes payments in **one currency, its own** — `GET /account` shows it. A sandbox
from `POST /sandbox/accounts` gets it from the `country` you send: GBP for `GB` (the default),
USD for `US`, EUR for a euro-area country such as `DE`. A session in any other currency is refused
with `422` `currency_not_supported`.

---

## Settling a Demo payment

The sandbox acquirer never sends a settlement report, so Fynex settles Demo payments itself: a completed Demo payment in GBP, USD or EUR of **at most 500** (so never more than £500) settles on its own within about a minute — its split runs and the wallets are credited. A bigger Demo payment is never settled automatically: settle it yourself once it has completed (for manual capture, once it is captured). The call also settles a smaller one at once instead of waiting:

```bash
curl -sS -X POST "$FYNEX_API/test/payments/ORDER-100106/settle" \
  -H "Authorization: Bearer $FYNEX_TOKEN"
```

```json
{ "status": "settled", "paymentId": "ORDER-100106" }
```

The call takes a few seconds; the split runs right after it returns, so `GET /payments/{paymentId}/split-decisions`, wallet balances and payouts then behave as they will in production. Repeating the call is safe — a payment that was already settled, by an earlier call or by the automatic settlement, answers `already_settled`. It is refused on a Live account (`403`, `code` `demo_only`) and before the payment has completed (`409`, `payment_not_completed`).

---

## What does NOT work in sandbox

| Feature | Status | Notes |
|---------|--------|-------|
| Real customer email receipts | **Sent on a Demo account** | There is no operational-mode gate on the email path, so a Demo account on production sends real mail to whatever address you supply — use addresses you control when testing |
| KYB / identity checks | Separate sandbox | Contact your Fynex representative for a KYB sandbox link; it is independent of the payments sandbox |
| Apple Pay merchant validation | Real, when enabled | The hosted checkout page validates through `/api/v1/checkout/apple-pay-validate-merchant` (session-bound; active only where Apple Pay is enabled for the deployment and a merchant certificate is configured). Your own Apple Pay button must run merchant validation from your own server in every environment |
| Google Pay in production mode | Not available | The dashboard uses `environment: 'TEST'`; switch to `environment: 'PRODUCTION'` only when going live |

---

## Sandbox limits

| Limit | Value | Notes |
|-------|-------|-------|
| Rate limiting | Enforced | The same mechanism as production, with its own budget — read `X-RateLimit-Remaining`; tell Fynex before high-volume load testing |
| Maximum test amount | £10,000 (recommended) | No hard cap is enforced by Fynex, but use reasonable amounts; very large amounts may be rejected by the upstream sandbox's own rules |
| Minimum test amount | £0.01 | |
| Session expiry | Set server-side | Check `expiresAt` in the checkout session response |

---

## See also

- **[Quickstart](https://docs.fynex.ai/payments-api/v2/docs#tag/quickstart)** — Take your first test payment in 5 minutes.
- **[Hosted checkout](https://docs.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Full hosted checkout reference — optional fields, return URL handling, and more.
- **[Server-to-server](https://docs.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — POST /initialize-payment and POST /finalize-payment, including 3DS handling.
- **[3DS Authentication](https://docs.fynex.ai/payments-api/v2/docs#tag/3ds)** — The redirect lifecycle, localStorage bridge, and finalize call.
- **[Alternative payment methods (APM)](https://docs.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm)** — Sandbox steps for Bancontact, Multibanco, MB WAY, Wero, and Swish.
- **[Errors](https://docs.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes, failure codes, and error response shapes.
