# Sandbox — a throwaway account for testing

One unauthenticated call gives you a complete, bookable business with its own keys. Point the
widget, the public API, the management API or the MCP server at it, make real bookings, read the
mails that *would* have been sent, pay with a fake checkout, and throw it away. Nothing leaves the
sandbox: no mail is delivered and no money moves.

```bash
curl -s -X POST https://app.nisebookings.com/api/sandbox
```

```json
{
  "tenantId": "6f1c…",
  "name": "Sandbox 3fa2",
  "publicKey": "9c2e41…",
  "apiKey": "nk_…",
  "scopes": ["catalog:read", "catalog:write", "settings:read", "settings:write", "customers:read",
             "customers:write", "bookings:read", "bookings:write", "payments:read", "payments:write"],
  "expiresAt": "2026-10-13T09:12:44.000Z",
  "endpoints": {
    "publicApi": "https://app.nisebookings.com/api/public/9c2e41…",
    "managementApi": "https://app.nisebookings.com/api/v1",
    "mcp": "https://app.nisebookings.com/api/mcp",
    "widget": "https://app.nisebookings.com/widget.js"
  }
}
```

The `apiKey` is a normal management-API key with **every scope**. It is shown **once** — keep the
response. The `publicKey` is the sandbox account's public key, exactly as on a real account.

## What is in it

| | |
|---|---|
| Business | `Sandbox <4 hex>`, time zone Europe/Amsterdam, EUR, default mail templates, gateways `local` and `mollie` both enabled |
| Staff | **Anna** and **Ben**, Monday–Saturday 09:00–17:00 (opening hours the same) |
| Services (category "Demo") | **Knippen** 30 min €25 (Anna and Ben) · **Kleuren** 90 min €65 with a €20 deposit rule (Anna; whether it applies follows the deposit mode in the settings) · **Groepsles** group class, 6 seats, 60 min €15 (Ben) |
| Events | one event a week ahead with one ticket type |

The data is fictional. Change anything you like through the management API or MCP — it is your account
until it expires. There is no admin login for a sandbox: use the API, MCP and the widget.

## Limits

| Limit | Value |
|---|---|
| Lifetime | **24 hours** from creation (`expiresAt`), then the account and everything in it is deleted |
| Creation rate | **5 sandboxes per hour** per client address (the rightmost `x-forwarded-for` hop our edge appends, else `x-real-ip`) → `429 {"error":{"code":"rate_limited"}}` with `Retry-After` |
| Capacity | a global cap on live sandboxes → `429 {"error":{"code":"capacity"}}` with `Retry-After` |
| E-mail | **never delivered**; every mail is recorded with status `suppressed` (see below) |
| Payments | **never real**; the Mollie gateway goes to a fake checkout page (see below) |

## Connect

```bash
eval "$(curl -s -X POST https://app.nisebookings.com/api/sandbox |
  jq -r '"export PK=\(.publicKey) NISE_API_KEY=\(.apiKey)"')"
```

**Widget** — embed it on any page (a sandbox has no allowed-sites list, so every origin works):

```html
<script src="https://app.nisebookings.com/widget.js" defer></script>
<nise-booking data-key="9c2e41…"></nise-booking>
```

**Public API** — [`examples/headless.mjs`](examples/headless.mjs) runs against it unchanged:

```bash
KEY=$PK node headless.mjs
```

**Management API** — `Authorization: Bearer $NISE_API_KEY` on [`/api/v1`](../api/README.md):

```bash
curl -s -H "Authorization: Bearer $NISE_API_KEY" https://app.nisebookings.com/api/v1/me
```

**MCP** — connect your coding agent to the [hosted MCP server](../api/mcp.md) with the same key:

```bash
claude mcp add --transport http nise-sandbox https://app.nisebookings.com/api/mcp \
  --header "Authorization: Bearer $NISE_API_KEY"
```

## Verify mails: the outbox

Every mail the sandbox would have sent — confirmations, staff notices, cancellations — is kept with
status `suppressed`. Read them with `GET /api/v1/outbox` (or the `list_outbox` MCP tool), newest first:

```bash
curl -s -H "Authorization: Bearer $NISE_API_KEY" \
  "https://app.nisebookings.com/api/v1/outbox?to=ada@example.com"
```

```json
{
  "data": [
    { "id": "…", "status": "suppressed", "toEmail": "ada@example.com", "toName": "Ada Jansen",
      "subject": "Je afspraak is bevestigd", "text": "…", "html": "…",
      "createdAt": "2026-10-12T09:14:02.000Z", "sentAt": null, "lastError": null }
  ],
  "total": 1
}
```

Filters: `status` (`pending`, `sending`, `sent`, `failed`, `suppressed`), `to` (exact address,
case-insensitive); pagination `limit` (≤ 100) and `offset`. Mails are written by the background
worker, so a row can appear a minute after the booking that caused it; poll rather than assert once.
The endpoint exists on real accounts too (scope `settings:read`), where it shows what was actually sent.

## Fake payments

Book with `gateway: "mollie"` and a `returnUrl` exactly as on a real account. The response's
`payment.checkoutUrl` points to a nise test page, `https://app.nisebookings.com/sandbox/pay/<token>`,
instead of Mollie. It shows the amount and description with two buttons:

- **Betalen** — the payment becomes `completed`, the bookings are confirmed and the confirmation mail is recorded.
- **Mislukt** — the payment becomes `rejected` and so do its bookings, as with a failed Mollie payment.

Both redirect to your `returnUrl` with `?nb_pay=<token>`, so your return page and its
`GET /api/public/<key>/payments/<token>` polling work exactly as in production. Refunds
(`POST /api/v1/payments/{id}/refund`, scope `payments:write`) succeed locally without contacting Mollie.

## Delete it

A sandbox deletes itself after 24 hours. To delete it now, with its own key:

```bash
curl -s -X DELETE -H "Authorization: Bearer $NISE_API_KEY" https://app.nisebookings.com/api/v1/sandbox
```

`204` on success; the keys stop working immediately (`401`). On a real account this endpoint answers
`404` — it can never delete a business.
