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

> [!TIP]
> **No account yet?** `POST /sandbox/accounts` with `Content-Type: application/json` and a
> `{}` body mints an anonymous demo seller and returns its `sk_test_` key — time-limited
> (`expiresAt` in the response says when), no e-mail, no real money. Everything below works
> against it. The door is open on the sandbox host (`staging-api.fynex.ai`), whose `/sandbox`
> page has the details; a host with it switched off answers `503`.

---

## 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://api.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://api.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. Demo accounts exist on **both** `https://api.fynex.ai` and
`https://staging-api.fynex.ai` — most integration accounts are issued on staging, some on
production, and neither is 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 the
plain-text body `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 the GraphQL `genericPayment`
> query or subscribing to the SSE stream — see the [Polling & SSE](https://api.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 (`provider_completed`). 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://api.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 settle to `provider_completed`:

| 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.

---

## 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 transitions to `provider_completed`.
Poll `genericPayment` 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",
  "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://api.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",
  "failureCode": 2001,
  "failureDescription": "Card declined by issuer"
}
```

| Field | Purpose |
|-------|---------|
| `status` | `"failed"` for hard declines; `"cancelled"` if the customer cancelled |
| `failureCode` | Numeric code — see the [Errors guide](https://api.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 reach a refundable status (`provider_completed`, `settled`, or `deposit_confirmed` — see [Captures & Refunds](https://api.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 captured/settled payments can be refunded — pre-capture refund attempts return `409 "refund is allowed only for provider_completed/settled/deposit_confirmed/refund_failed/refund_cancelled payments"`. Omit `amount` to refund the remaining refundable amount, or pass a smaller value for a partial refund.

#### Partial capture

Create a payment with `autoSettlement: false` (or `captureMode: "manual"`), 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 |

---

## What does NOT work in sandbox

| Feature | Status | Notes |
|---------|--------|-------|
| Real customer email receipts | **Sent on a Demo account** | Suppressed on `staging-api.fynex.ai` only. 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 | Faked | `completeMerchantValidation({})` is accepted in sandbox but will fail against real Apple Pay; a real server-side merchant validation endpoint is required for production |
| 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://api.fynex.ai/payments-api/v2/docs#tag/quickstart)** — Take your first test payment in 5 minutes.
- **[Hosted checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Full hosted checkout reference — optional fields, return URL handling, and more.
- **[Server-to-server](https://api.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — POST /initialize-payment and POST /finalize-payment, including 3DS handling.
- **[3DS Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/3ds)** — The redirect lifecycle, localStorage bridge, and finalize call.
- **[Alternative payment methods (APM)](https://api.fynex.ai/payments-api/v2/docs#tag/alternative-payment-methods-apm)** — Sandbox steps for Bancontact, Multibanco, MB WAY, Wero, and Swish.
- **[Errors](https://api.fynex.ai/payments-api/v2/docs#tag/errors)** — HTTP status codes, failure codes, and error response shapes.
