> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chargeexpressltd.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox

> Build and test against a private copy of your fleet with simulated chargers and payments, using a cxk_test_ key.

The sandbox is a private copy of your fleet for building and testing. It has the same API, the same code paths and the same webhooks as your live fleet, but its drivers are fake, its chargers are simulated and its payments never move money.

You reach it with a **sandbox key** (`cxk_test_…`). Everything a sandbox key does happens in the sandbox; everything a live key (`cxk_live_…`) does happens on your real fleet. The base URL is the same for both.

## Test and live keys

You choose the environment for each key when you create it: in the fleet portal open **Developers → API keys → Create key** and pick **Live** or **Sandbox**.

| | Sandbox key `cxk_test_…` | Live key `cxk_live_…` |
| - | - | - |
| Acts on | Your fleet's sandbox | Your real fleet |
| Drivers | Fake, with placeholder emails and no logins | Real people, who get a login invite |
| Chargers | Four simulated charge points | The ChargeExpress network |
| Payments | Simulated; nothing is charged | Real ExpressPay checkouts |
| Billing and invoices | Never | Yes |
| Events | `livemode: false` | `livemode: true` |
| Where you get one | Any fleet portal | The production fleet portal |

Sandbox and live are fully separate. Keys, webhook endpoints, events, drivers, vehicles, sessions and the wallet belong to one environment and are never visible from the other. A webhook endpoint registered with a sandbox key only receives sandbox events, and the portal's **Developers** pages show one environment at a time.

The sandbox is created the first time you use a sandbox key, so your first request may take a moment longer.

## What's in the sandbox

A new sandbox (and one you've just [reset](#reset-the-sandbox)) starts with:

* **Three drivers**, Ama Owusu, Kwame Asante and Efua Mensah (employee ids `SBX-001` to `SBX-003`). They have placeholder emails ending in `@sandbox.chargeexpress.invalid`, no logins, and are never emailed.
* **A vehicle for each driver**, registered `SBX 1001-26`, `SBX 1002-26` and `SBX 1003-26`.
* **A wallet** with 500 kWh of prepaid credit and a GHS 1,000 credit limit, at your live fleet's unit price.

Drivers you create in the sandbox with `POST /drivers` also get a placeholder email, whatever address you send, and the response has `login_invite.status: "skipped"`:

```json theme={null}
{
  "login_invite": {
    "status": "skipped",
    "message": "Sandbox drivers don’t get logins or emails"
  }
}
```

`GET /fleet` in the sandbox returns the sandbox copy of your fleet. Its `id` is different from your live fleet's id, and it is the `fleet_id` on sandbox events.

## Simulated chargers

With a sandbox key, `GET /charge_points` returns only these four charge points, each with `simulated: true`. Real chargers can't be reached from the sandbox: sandbox sessions only run on these four.

| Charge point | Power | Connectors | Behaviour |
| - | - | - | - |
| `SIM-AC-22` | 22 kW AC | 1 and 2 | Charges normally. |
| `SIM-DC-60` | 60 kW DC | 1 | Charges normally. |
| `SIM-OFFLINE` | 22 kW | 1 | Always offline, so sessions can't start on it. |
| `SIM-REJECT` | 22 kW | 1 | Rejects every start and fires `session.start_failed`. |

## How the simulation runs

Sandbox sessions run on a simulated meter at **60 times real time**:

* Energy delivered is `max_power_kw × elapsed time × 60`, capped by the session's budget. One real second is one simulated minute, so `SIM-DC-60` delivers 1 kWh per second and `SIM-AC-22` about 0.37 kWh per second.
* A server job advances every running sandbox session every **5 seconds**, so meter readings and events move in 5-second steps.
* A session ends on its own after about **2 minutes** (two simulated hours), or as soon as it reaches its budget, whichever comes first.
* When a session reaches its budget you get `session.stopped_by_limit` with `stop_reason: "budget_reached"`, then `session.completed`.
* Stopping a session in the portal ends it right away and settles the energy delivered so far.

The budget works as in live: the smallest of the session's max kWh (set when you start it in the portal), any fleet or driver [limits](/api-reference/introduction#limits), and what the wallet can cover. Limits, wallet reservations and debits, credit and `limit.reached` events all behave as they do live. Sandbox session ids look like `sim_17918822001234567`; treat them as opaque, like every id.

## Walkthrough

This walkthrough lists the simulated chargers, runs a session on `SIM-DC-60` until it hits a 10 kWh cap, and tops up the wallet. It needs a sandbox key with `fleet:read` and `wallet:write`, and a webhook endpoint subscribed to `session.*` and `wallet.*` (register one with the sandbox key as in the [Quickstart](/quickstart#3-register-a-webhook-endpoint)).

```bash theme={null}
export CHARGEEXPRESS_API_KEY=cxk_test_…
export CX=https://stage.partners.chargeexpressltd.com/api/public/v1
```

**1. List the charge points**

```bash theme={null}
curl "$CX/charge_points" -H "Authorization: Bearer $CHARGEEXPRESS_API_KEY"
```

```json Response theme={null}
{
  "data": [
    {
      "object": "charge_point",
      "id": "SIM-DC-60",
      "description": "Simulated 60 kW DC fast charger",
      "online": true,
      "status": "Operative",
      "max_power_kw": "60.000",
      "latitude": null,
      "longitude": null,
      "address": null,
      "simulated": true,
      "connectors": [
        { "connector_id": 1, "status": "Available", "rated_power_kw": "60.000" }
      ]
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

The real response lists all four simulated charge points; this sample shows one.

**2. Start a session with a 10 kWh cap**

In the fleet portal, open **Developers → Sandbox** and choose **Start session**. Pick a driver and their vehicle, charge point `SIM-DC-60`, connector `1`, and a max kWh of `10`.

The simulated charger starts at once, so `session.started` (`data.object.status: "charging"`) is sent to your endpoint straight away. Note the session `id` from `data.object.id`, or find it with `GET /sessions?status=charging`.

**3. Watch it hit the cap**

At 1 kWh per second, the session reaches 10 kWh after about 10 seconds and is stopped at the next 5-second step. Your endpoint receives `session.stopped_by_limit`:

```json session.stopped_by_limit data.object theme={null}
{
  "object": "session",
  "id": "sim_17918822001234567",
  "status": "charging",
  "charge_point_id": "SIM-DC-60",
  "driver_id": "d3x7n2q9k4m8p1r6t0v5w2yz",
  "vehicle_id": "v6b1c8n3m5k2j9h4g7f0d2sa",
  "energy_kwh": "10.0000",
  "budget_kwh": "10.0000",
  "unit_price": "3.85",
  "cost": "38.50",
  "on_credit": "0.00",
  "currency": "GHS",
  "paid_after_session": false,
  "started_at": "2026-10-13T09:00:00.000Z",
  "stop_requested_at": null,
  "ended_at": null,
  "stop_reason": "budget_reached"
}
```

followed by `session.completed` with `status: "completed"` and the final `energy_kwh` and `cost`. The 10 kWh are debited from the sandbox wallet. You can also poll `GET /sessions/{sessionId}`.

To end a session early instead, select it in **Developers → Sandbox** and choose **Stop**. In the sandbox it settles immediately, and `session.completed` follows.

**4. Create a top-up**

```bash theme={null}
curl "$CX/wallet/fundings" \
  -H "Authorization: Bearer $CHARGEEXPRESS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3a1f9c2e-7b4d-4e8a-9f6c-1d2e3f4a5b6c" \
  -d '{ "amount": 500 }'
```

```json Response (201) theme={null}
{
  "object": "funding",
  "id": "wallet-6f1d2c3a-9b8e-5c4d-a7f6-0e1d2c3b4a59",
  "status": "pending",
  "amount": "500.00",
  "currency": "GHS",
  "unit_price": "3.85",
  "kwh": "129.8701",
  "credited_kwh": null,
  "debt_repaid": null,
  "invoice_number": null,
  "checkout_url": null,
  "credited_at": null,
  "created_at": "2026-10-13T10:00:00.000Z",
  "updated_at": "2026-10-13T10:00:00.000Z",
  "sandbox": true
}
```

There is no checkout page: `checkout_url` is `null` and `sandbox` is `true`. The funding stays `pending` until you settle it.

**5. Mark it paid**

```bash theme={null}
curl -X POST "$CX/sandbox/fundings/wallet-6f1d2c3a-9b8e-5c4d-a7f6-0e1d2c3b4a59/succeed" \
  -H "Authorization: Bearer $CHARGEEXPRESS_API_KEY"
```

The response is the funding with `status: "success"`. The wallet is credited (any credit used is repaid first, as live) and `wallet.funded` is sent. To simulate a failed payment, call `/fail` instead: the funding becomes `failed` and `wallet.funding_failed` is sent. Settling a funding that is no longer pending returns `409 funding_not_pending`.

Paying usage invoices works the same way: `POST /invoices/usage/pay` with a sandbox key returns a pending funding with `checkout_url: null` and `sandbox: true`, which you settle with `/sandbox/fundings/{fundingId}/succeed` or `/fail`.

Both settle endpoints need the `wallet:write` scope.

## Test failure paths

Start sessions on the two misbehaving chargers from **Developers → Sandbox** to exercise your error handling:

* **`SIM-OFFLINE`** is never connected, so no session starts and nothing is reserved.
* **`SIM-REJECT`** rejects the start: the wallet reservation is released and `session.start_failed` carries the session with `status: "cancelled"`, as when a real charger rejects a start.

Other failures work as live, against the sandbox's own data: set a tight limit with `PUT /limits` to get `limit.reached`, or fail a top-up to get `wallet.funding_failed`.

## Send test events of any type

`POST /webhook_endpoints/{endpointId}/test` sends one signed event to an endpoint straight away. Pass `type` to send any [event type](/webhooks/events) with a realistic sample payload; without it you get a `ping`:

```bash theme={null}
curl -X POST "$CX/webhook_endpoints/we_m4n8p2q6r0s4t8u2v6w0x4y8/test" \
  -H "Authorization: Bearer $CHARGEEXPRESS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "invoice.created" }'
```

```json Response theme={null}
{
  "object": "webhook_test",
  "event_id": "evt_p0q1r2s3t4u5v6w7x8y9z0a1",
  "type": "invoice.created",
  "succeeded": true,
  "response_status": 200,
  "response_body": "OK",
  "error": null,
  "duration_ms": 184
}
```

This is the easiest way to see events the sandbox doesn't produce on its own, such as `invoice.created` or `invitation.accepted`. Test events aren't stored, retried or listed in `/events`, and they work with live keys too.

## Reset the sandbox

`POST /sandbox/reset` clears the sandbox's sessions, top-ups, drivers, vehicles, limits and events, then seeds fresh drivers, vehicles and wallet credit. Your keys and webhook endpoints are kept, so your integration keeps working.

```bash theme={null}
curl -X POST "$CX/sandbox/reset" -H "Authorization: Bearer $CHARGEEXPRESS_API_KEY"
```

```json Response theme={null}
{ "object": "sandbox_reset", "reset": true }
```

Ids change after a reset, so list drivers and vehicles again.

## In the portal

**Developers → Sandbox** in the fleet portal does all of this without code: see the sandbox's drivers, vehicles, wallet and recent activity, start and stop sessions on the simulated chargers, simulate paid or failed top-ups, and reset the sandbox.

## What the sandbox never touches

* **Billing:** nothing in the sandbox is billed. Sandbox credit and debt are not real money.
* **Invoicing:** sandbox sessions and top-ups are never invoiced, so `invoice_number` stays `null` and `invoice.created` isn't sent for sandbox activity.
* **Payments:** sandbox top-ups and invoice payments never reach ExpressPay.
* **Real chargers:** sandbox sessions never send commands to a charger.
* **Real people:** sandbox drivers have no logins and are never emailed.

The `/sandbox/*` endpoints only work with a sandbox key. With a live key they return `403 sandbox_only`.

## Going live

When your integration works in the sandbox, create a live key in the **production** fleet portal and swap it in. Then register your webhook endpoints again with the live key (sandbox endpoints don't receive live events), and check `livemode` in your webhook handler if the same server receives both.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.