# Sandbox & test mode

Every BaxPay account has two parallel worlds: test and live. Same API, same features, completely
separate data — and in test mode, no real money ever moves.

## How test mode works

Every object in BaxPay — API key, connection, session, webhook endpoint, event — carries a
`livemode` flag. `livemode: false` objects live in the sandbox; `livemode: true` objects are the
real thing. The two sets never mix: a test key cannot see live data, a test session can never
reach a live webhook endpoint, and vice versa.

- `API keys` (prefix):
  Secret keys are minted per mode: `bx_test_…` for the sandbox, `bx_live_…` for production. The
  key's mode decides the mode of everything you create with it — there is no way to accidentally
  charge a real card with a test key.
- `Dashboard` (per tab):
  The dashboard is live until you switch a tab to **Sandbox**; that tab then shows test data on
  every page, while your other tabs, your team and your tills stay live
  ([details](#sandbox-in-the-dashboard)). Under the hood each request carries
  `X-Baxpay-Mode: test | live` (default `live`).
- `Webhooks` (mode-scoped):
  Webhook endpoints are registered in one mode and only ever receive that mode's events. Every
  event payload carries `livemode`, so your receiver can assert it defensively.

> **Full parity**
> The sandbox is not a mock. Test sessions run the same state machine, the same idempotency rules,
> the same webhook dispatch and the same error surface as live ones. If it works in test mode, the
> only thing that changes at go-live is the key prefix.

## Simulating outcomes

Live sessions are completed by a shopper at a real terminal (fingerprint + PIN). Test sessions
have no shopper, so the sandbox gives you a lever instead:

- `POST /integration/sessions/{id}/simulate` — test sessions only

```json
{ "outcome": "APPROVED" }
```

`outcome` is one of `APPROVED`, `DECLINED` or `TIMEOUT` — enough to exercise your happy path,
your failure handling and your stuck-basket recovery. Calling it on a live session is rejected:
real money only moves through the real terminal flow.

## Test cards

Test links and invoices take these cards, and only these: any other number is refused, so a real
card typed into a test page is never processed. Use any future expiry, any 3-digit CVC (4 digits
for American Express) and any name. The numbers follow the usual industry test-card conventions, but
every outcome is decided by BaxPay: a test payment never reaches an acquirer or a card network. On a test pay page, **Test cards** fills the form with one of them.

| Number | Brand | Result | `code` |
| --- | --- | --- | --- |
| `4242 4242 4242 4242` | Visa | Succeeds | |
| `5555 5555 5555 4444` | Mastercard | Succeeds | |
| `3782 822463 10005` | American Express | Succeeds | |
| `4000 0025 0000 1001` | Cartes Bancaires | Succeeds | |
| `4000 0025 0000 3155` | Visa | 3D Secure, then succeeds | |
| `4000 0000 0000 3220` | Visa | 3D Secure 2, then succeeds | |
| `4000 0084 0000 1629` | Visa | 3D Secure, then declined | `card_declined` |
| `4000 0000 0000 0002` | Visa | Declined | `card_declined` |
| `4000 0000 0000 9995` | Visa | Insufficient funds | `insufficient_funds` |
| `4000 0000 0000 9987` | Visa | Lost card | `lost_card` |
| `4000 0000 0000 9979` | Visa | Stolen card | `stolen_card` |
| `4000 0000 0000 0069` | Visa | Expired card | `expired_card` |
| `4000 0000 0000 0127` | Visa | Incorrect CVC | `incorrect_cvc` |
| `4000 0000 0000 0119` | Visa | Processing error | `processing_error` |
| `4100 0000 0000 0019` | Visa | Blocked as fraudulent | `fraudulent` |

A decline answers `400` with the reason in `code`. Any other number answers `test_card_required`.

### 3D Secure

A 3D Secure card first answers with a challenge instead of a payment:

```json
{ "status": "REQUIRES_ACTION", "challengeId": "3ds_test_8c1f…", "nextAction": { "type": "sandbox_3ds" } }
```

The test pay page then shows the bank's step, where you choose **Complete authentication** or
**Fail authentication**. To do the same from code, answer the challenge within 15 minutes; each
challenge works once:

- `POST /pay/link/{token}/card/3ds` — test links · /pay/einvoice/{token}/card/3ds for invoices

```json
{ "challengeId": "3ds_test_8c1f…", "outcome": "complete" }
```

`complete` captures the payment (or declines it, for `4000 0084 0000 1629`); `fail` declines with
`authentication_failed`.

## Test payment methods

A test link also offers the other methods the business has turned on in **Settings → Online
payment methods** (iDEAL, Bancontact, Apple Pay, Google Pay, Klarna, …), as simulated payments.
On the pay page each one opens a confirmation where you choose **Authorize test payment** or
**Fail test payment**. From code:

- `POST /pay/link/{token}/simulate` — test links only

```json
{ "method": "ideal", "outcome": "authorize" }
```

An authorized payment is recorded with its method, exactly like a live one (`paidMethod: "IDEAL"`),
and fires the same test-mode webhooks. `fail` answers `payment_method_failed`. Only methods active
for the business are accepted; the list is BaxPay's own copy, refreshed whenever the business opens
its payment-method settings, so a test page never calls a payment processor.

## Two hosts, one API

Build against `https://sandbox-api.baxpay.com/v1` with a `bx_test_…` key, then go live on
`https://api.baxpay.com/v1` with a `bx_live_…` key. Paths, payloads and responses are identical, so
going live is two values in your configuration: the base URL and the key.

The **key decides the mode**: `bx_test_…` works only on sandbox data, `bx_live_…` only on live
data. The sandbox host refuses live keys with `403`, so a misconfigured server can never move real
money from there. `api.baxpay.com` still accepts test keys for integrations built before the
sandbox host existed.

## Walkthrough: first simulated payment

From a fresh test key to a delivered webhook, in three calls. Grab a `bx_test_` key from the
**Developers** page in the [Business dashboard](https://business.baxpay.com) first, and
[register a webhook endpoint](https://developers.baxpay.com/webhooks#register-an-endpoint) for `payment_session.captured` with it.

**1. Create a test connection**

The API key's mode decides the connection's mode: a `bx_test_` key can only create test
connections. The `bxc_` credential is returned once; store it.

**2. Open a session with the credential**

This is exactly what your till will do in production: authenticate as the connection and
declare the amount.

**3. Simulate the shopper**

Approve your own session: the sandbox stand-in for the shopper paying at the terminal.

**4. Receive the webhook**

The captured event arrives at your test-mode endpoint, with `livemode: false` in the payload.

Create the connection with your API key:

```bash
curl -X POST https://sandbox-api.baxpay.com/v1/connections \
  -H "Authorization: Bearer $BAXPAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "systemType": "ECR", "label": "Sandbox till" }'
```

```json
{
  "success": true,
  "data": {
    "connectionId": "358f1a7a-…",
    "credential": "bxc_hwsOcP3Y65z9G1X4…"
  }
}
```

Open a session as that connection. Note the switch to the `bxc_` credential, the amount in
**minor units** (`1250` = €12.50), and the `Idempotency-Key`, which is required here:

```bash
curl -X POST https://sandbox-api.baxpay.com/v1/integration/sessions \
  -H "Authorization: Bearer bxc_hwsOcP3Y65z9G1X4…" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "saleReferenceId": "till-01/receipt/42",
    "amountMinor": 1250,
    "currency": "EUR"
  }'
```

```json
{
  "success": true,
  "data": {
    "sessionId": "6d3b3ec5-…",
    "saleReferenceId": "till-01/receipt/42",
    "livemode": false,
    "status": "CREATED",
    "type": "SALE",
    "amountMinor": 1250,
    "capturedAmountMinor": null,
    "currency": "EUR",
    "resultCode": null,
    "final": false
  }
}
```

Now play the shopper and approve it:

```bash
curl -X POST https://sandbox-api.baxpay.com/v1/integration/sessions/6d3b3ec5-…/simulate \
  -H "Authorization: Bearer bxc_hwsOcP3Y65z9G1X4…" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "APPROVED" }'
```

```json
{
  "success": true,
  "data": {
    "sessionId": "6d3b3ec5-…",
    "status": "CAPTURED",
    "capturedAmountMinor": 1250,
    "resultCode": "APPROVED",
    "authCode": "TEST…",
    "maskedInstrument": "•••• 4242 (test)",
    "final": true
  }
}
```

`final: true` means the session won't change again. Moments later your test-mode webhook endpoint
receives the event, HMAC-signed like every delivery and explicit about which mode it came from. Its
`data` is the same session view:

```json
{
  "id": "81dab1ba-…",
  "type": "payment_session.captured",
  "livemode": false,
  "data": {
    "sessionId": "6d3b3ec5-…",
    "saleReferenceId": "till-01/receipt/42",
    "status": "CAPTURED",
    "amountMinor": 1250,
    "capturedAmountMinor": 1250,
    "currency": "EUR",
    "resultCode": "APPROVED",
    "final": true
  },
  "createdAt": "2026-07-16T14:03:21.530Z"
}
```

> **Assert livemode in your receiver**
> Before you fulfil an order, check `livemode` matches what that deployment of your system
> expects. A test event processed by your production fulfilment code is a free product; the check
> is one line.

## Sandbox in the dashboard

Everything above also works without writing code in the
[Business dashboard](https://business.baxpay.com). Owners and admins switch a browser tab to
sandbox with **Enter sandbox** in the profile menu, and confirm. That tab then shows test data on
every page (transactions, payment links, invoices, clients, products, inventory, analytics, the
till) under a yellow **SANDBOX** bar, and a yellow **Developers** entry is pinned at the bottom of the sidebar. It lasts until you
click **Exit sandbox** (and confirm), log out or close the tab. Your other tabs, your team and your
devices stay live, so your business keeps running normally. Outside sandbox, the developer
workspace is under **Settings → Developers**.

- **Pay as a customer**: an open test link, or a test invoice's pay link, has **Pay with test
  card** (`4242 4242 4242 4242`) and **Decline** (`4000 0000 0000 0002`).
- **Test till**: the till sells with test payment links under a TEST banner. Card terminals and
  cash work only in live: a sandbox tab can never charge a real card.
- **Not in sandbox**: pages that move real money (banking, transfers, cards, settlements) and,
  for now, the online shop and accounting say so instead of showing live data. Team and
  settings are the same in both modes, and say that too.
- In **Developers**:
  - **Sessions**: test in-store sessions, with **Approve**, **Decline** and **Time out**, the
    same outcomes as [`/simulate`](#simulating-outcomes).
  - **Events**: every event with each webhook delivery, what your endpoint answered and
    **Resend** ([event log](https://developers.baxpay.com/webhooks#event-log)).
  - **Requests**: every call made with an API key ([request log](https://developers.baxpay.com/conventions#request-log)).
  - **Delete all test data**: removes test links, invoices, clients, products, sessions, test
    payments and events. Test API keys, webhook endpoints and connections stay. Live data is
    never touched.

## Going live

Swap `bx_test_` for `bx_live_`, provision a live connection, and bind it to a real terminal —
nothing else in your code changes. The events you handled in test mode arrive with
`livemode: true`, and the simulate endpoint stops working, because now there's a real shopper.

- [Terminal & Session API](https://developers.baxpay.com/terminal-api):
  The session lifecycle your till drives — open, SCA, capture, void.
- [Webhooks](https://developers.baxpay.com/webhooks):
  Signatures, retries, and why you verify before you trust.
