Test cards & sandbox
Test card numbers, APM sandbox links, how to control 3DS, and how the Fynex sandbox behaves.
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
operationalModebefore sending one.Whether a payment goes through a 3DS challenge is controlled by the
skip3DSrequest 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) 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. If it readsLive, 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
PaymentCompletedwebhooks to the URLs configured on your seller account (and to any per-requestwebhookUrl). 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 pollingGET /payments-api/v1/payments/{payment_id}(the path value is yourexternalOrderRef) or subscribing to the SSE stream — see the 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):
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. |
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).skip3DSonly 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 notY/Ais 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 to2028before 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
Yfor a genuine card, so theU/Nfailure 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 can be forced, except
0(no failure) and9999(unclassified) — those describe the absence of a classified failure, not one to simulate. An unrecognised or malformed value returns400. - Demo-mode only. On a Live account the
Simulatorheader is never parsed or acted on, whatever it contains — it cannot affect a real payment. - Applies to
POST /payments-api/v1/initialize-payment.
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-paymentresponse itself does NOT carry the simulatedfailureCode. A simulated failure follows the exact same path a real authorization-stage failure does, which returns502with a generic body — same as any other authorization-time failure on this call (validation errors return400, an idempotency conflict409, and so on; those aren't what aSimulatorrequest produces, since it always reaches the authorization stage):
{ "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 — 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.
Common test scenarios
Happy path — no 3DS
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
countryCodeis required and, if your terminal pins a country, must match it.returnLinks(note: notreturnUrls) is required on the server-to-server flow — each link needsrel∈default | on_completed | on_failed | on_cancelled, anhttp/httpshref, andmethod: "GET". The hosted checkout flow usesreturnUrlsat session creation instead.
3DS challenge
Send the same request without skip3DS (or with "skip3DS": false). The response is:
{
"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 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:
{
"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 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 acancelledstatus — show a neutral "payment not completed" message rather than an error.
Refund testing
- Pay and wait for the payment to become refundable —
lifecycleStatuscapturedandsettlementStatusnotin_flight(see Captures & Refunds). - Issue the refund using the public endpoint with your
externalOrderRefas the path param:
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
lifecycleStatusiscapturedcan be refunded — a pre-capture refund attempt returns409 "payment action is not allowed for the current payment status". Omitamountto 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:
# 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 |
|---|---|---|
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, runs 3DS and completes the payment:
curl -sS -X POST "$FYNEX_API/test/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b/complete" \
-H "Authorization: Bearer $FYNEX_TOKEN"
{ "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:
curl -sS -X POST "$FYNEX_API/test/payments/ORDER-100106/settle" \
-H "Authorization: Bearer $FYNEX_TOKEN"
{ "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 — Take your first test payment in 5 minutes.
- Hosted checkout — Full hosted checkout reference — optional fields, return URL handling, and more.
- Server-to-server — POST /initialize-payment and POST /finalize-payment, including 3DS handling.
- 3DS Authentication — The redirect lifecycle, localStorage bridge, and finalize call.
- Alternative payment methods (APM) — Sandbox steps for Bancontact, Multibanco, MB WAY, Wero, and Swish.
- Errors — HTTP status codes, failure codes, and error response shapes.