---
title: "Testing with a sandbox"
description: "Build and test against a sandbox business with bk_test_ keys: realistic sample data, a test outbox instead of real messages, a simulated checkout instead of real payments, and a simulator for the events you would otherwise wait for."
url: "https://bookatu.com/en-nz/developers/testing"
language: "en"
section: "Developers"
---

# Testing with a sandbox

A sandbox is a test business inside Bookatu, made for building and testing an integration. It answers the same API with the same booking rules and sends the same webhooks as a live business, but nothing it does reaches a real person or moves real money: every message it would send is kept in its test outbox, and every payment goes through a simulated checkout.

## What a sandbox is

Each sandbox is a business of its own with a slug of its own, so the calls you make to a live business work on it unchanged: `https://bookatu.com/api/v1/{slug}/...`. It is marked as a test business everywhere: a Test mode banner on every page of its admin, a Test business ribbon on its booking pages that asks visitors not to enter real personal details (as does a short notice right above the client details on its booking page and in its widget), and `livemode: false` on every API answer. A sandbox can never be turned into a live business, and a live business can never become a sandbox.

A sandbox is never listed anywhere public: not in the Bookatu directory or its search, not in sitemaps, not on Google or Bing, and its pages are never indexed. It is not billed and has no trial.

## Make a sandbox

Sign in with any verified Bookatu account (a business owner's, or a developer's with no business at all) and open your sandboxes at [/developers/sandbox](https://bookatu.com/developers/sandbox). Pick a name, a business type (it decides the sample data: see What is in it, below), a time zone and a currency (your browser's are filled in) and accept the test-mode terms. You can have up to 3 sandboxes at a time. A sandbox can't be made in a currency with no minor unit, such as JPY or KRW: Bookatu takes no card payments in those yet, so making one answers `400` with `INVALID_CURRENCY`.

Or make one from your own code, signed in with your Bookatu login: the web session, or the account token `POST /api/mobile/auth/login` returns, sent as `Authorization: Bearer ...`.

```http
POST /api/developer/sandboxes
Content-Type: application/json
Authorization: Bearer YOUR_ACCOUNT_TOKEN

{ "name": "Acme test salon", "timezone": "Pacific/Auckland", "currency": "NZD", "acceptTestModeTerms": true }
```

```http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "sandbox": {
    "id": "org_...",
    "slug": "sbx-ab12cd34",
    "name": "Acme test salon",
    "timezone": "Pacific/Auckland",
    "currency": "NZD",
    "createdAt": "2026-10-30T02:00:00.000Z",
    "lastActiveAt": "2026-10-30T02:00:00.000Z",
    "expiresAt": "2026-11-29T02:00:00.000Z",
    "adminUrl": "https://bookatu.com/sbx-ab12cd34/admin",
    "storefrontUrl": "https://bookatu.com/sbx-ab12cd34",
    "apiBaseUrl": "https://bookatu.com/api/v1/sbx-ab12cd34"
  },
  "testKey": {
    "key": "bk_test_...",
    "prefix": "bk_test_AbCd",
    "scopes": ["bookings:read", "bookings:write", "customers:read", "..."],
    "expiresAt": "2027-01-28T02:00:00.000Z"
  }
}
```

`acceptTestModeTerms` must be `true`: a sandbox is for building and testing, with made-up people and data only. `industry` is optional (`salon` when left out): it is the business type, and decides the sample data the sandbox is made with and reset to.

A sandbox's first key comes with it: every scope its owner may give (the opt-in ones included), valid for 90 days, shown once in the page or in the 201 answer that made the sandbox. It is never put in a URL. Store it (`testKey.key`) like any other secret.

| Call | What it does |
| --- | --- |
| `GET /api/developer/sandboxes` | Your sandboxes, each with when it expires. |
| `POST /api/developer/sandboxes` | Make one: `201` with the sandbox and its first test key. |
| `POST /api/developer/sandboxes/{id}/reset` | Wipe it and fill it with fresh sample data. Its id and slug, its keys, its webhook endpoints and its connected apps are kept. |
| `DELETE /api/developer/sandboxes/{id}` | Delete it and everything in it. |

One exception, for CI: POST /api/developer/sandboxes/{id}/reset also takes that sandbox's own test key (bk_test_, as Authorization: Bearer or X-Api-Key) when it has the bookings:write scope, so a test run can reset its sandbox with a key you can revoke, and never anyone's login. It resets that sandbox only; its keys are kept, so the same key goes on working. Another sandbox's key gets 404 NOT_FOUND, and a key without the scope 403 MISSING_SCOPE. Keep the account token out of CI: it opens every business its login belongs to (through the phone app's API too), lasts 30 days and needs a two-step code at each sign-in when two-step is on. For automation that makes sandboxes, use a login of its own with no business. Over MCP, the same test key resets its sandbox with the sandbox_reset tool.

```http
POST /api/developer/sandboxes/{id}/reset
Authorization: Bearer YOUR_SANDBOX_TEST_KEY
```

A refusal says why in `code`:

| Status | Code | When |
| --- | --- | --- |
| 401 | `UNAUTHORIZED` | Not signed in, an expired account token, or an API key instead of your login (on a reset, a test key that is revoked, expired or made up). |
| 403 | `MISSING_SCOPE` | A reset with a test key that does not have bookings:write; missingScope names it. |
| 403 | `FORBIDDEN_ORIGIN` | A write by session cookie that did not come from Bookatu's own pages. |
| 403 | `EMAIL_NOT_VERIFIED` | Your email address is not confirmed yet. |
| 403 | `SANDBOX_CREATION_DISABLED` | Making sandboxes is switched off on the platform at the moment. |
| 400 | `TEST_MODE_TERMS_NOT_ACCEPTED` | acceptTestModeTerms was not true. |
| 400 | `INVALID_TIME_ZONE` | timezone is not an IANA zone name. |
| 400 | `INVALID_CURRENCY` | currency is not one a sandbox can use. |
| 400 | `INVALID_INDUSTRY` | industry (optional, default salon) is not one of Bookatu's business types. |
| 400 | `INVALID_BODY` | The body is not a JSON object. |
| 409 | `SANDBOX_LIMIT_REACHED` | You already have three sandboxes. |
| 409 | `TEST_COPY` | On a reset: this is a business's test copy, which is reset from that business's Developer API page. |
| 429 | `RATE_LIMITED` | More than five made by you, or ten from your address, in a day. Retry-After says when. |
| 404 | `NOT_FOUND` | The id is not one of your sandboxes (or, on a reset with a test key, not that key's own sandbox). |

Three sandboxes at a time each. Five may be made a day by one person and ten from one address (a delete doesn't give one back).

## What is in it

- Its sample data is its business type's (industry, salon when left out), and a reset seeds the same type again. By type:
- Salon (salon, the default): three people (two stylists and a therapist); Women's cut and blow-dry, Men's cut, Full colour (takes a deposit), Balayage (asks "Have you coloured your hair in the last three months?"), Express facial, Relaxing massage and Deep conditioning treatment (an extra, offered with every service, that adds 15 minutes); booking terms; a waitlist entry.
- Dental clinic (clinic): two dentists and a dental hygienist; Surgery 1 and Surgery 2, rooms every service needs; Check-up and clean, New patient exam (longer), Emergency appointment (short notice: the clinic takes bookings from 30 minutes ahead) and Whitening consultation (takes a deposit); every booking asks "Are you a new patient?" (required) and "Anything we should know before your visit?"; booking terms; past and upcoming appointments, each in a surgery, and a waitlist entry.
- Restaurant (restaurant): ten tables of two to eight seats in three sections (Main room, Window, Courtyard), laid out on the floor plan; lunch and dinner sittings (Tuesday to Saturday 12:00 to 15:00 and 17:30 to 22:00, Sunday lunch); Table reservation, Window table, Courtyard table and Tasting menu (priced per person, with a deposit per person), each needing a table: send partySize, and only tables that seat the party are offered and used; every booking asks "Any special requests?" and "Is it a special occasion?"; booking terms (tables are held for 15 minutes); reservations at lunch and dinner with their covers.
- Tennis coach (coach): two coaches; Court 1 and Court 2, one for every lesson and group; Private lesson, Semi-private lesson for two (partySize up to 2, priced per player), and two weekly groups with places and four upcoming sessions each, Cardio tennis and Junior squad; every booking asks "What is your level?" (a choice, required); a rain policy as the booking terms; lessons past and upcoming, and places booked on the groups.
- Personal trainer (trainer): two trainers; Free intro consultation (no charge), 1:1 session (45 min), 1:1 session (60 min), Partner session for two (partySize up to 2, priced per person) and Small-group bootcamp (a weekly group with places and four upcoming sessions); every booking asks "Where would you like to train?" (Studio or Outdoors at the park, required); booking terms; sessions past and upcoming.
- Any other business type (spa, studio, photographer, other): the sample every sandbox had before, with no extra and no booking terms: three people with weekly hours inside the business's own (weekdays 9 to 6, Saturday 9 to 4); seven services, one taking a deposit (and only that one), one asking a booking question and one a weekly class with four upcoming sessions.
- Five made-up clients, every email at an example domain and every phone in a range reserved for fiction (+1 NXX 555 01XX, +44 7700 900XXX), opted in to texts so the Outbox shows what they would have been sent.
- Bookings in the past (completed, and a no-show) and the next few days (confirmed), and places booked on its classes. Every time is in the zone you picked, inside the hours of the person or the business; every price in the currency you picked, in minor units.
- Sample payments, so Finance and Test payments start with real-looking numbers: deposits paid online through the test checkout on some upcoming bookings and on the no-show (kept, half of it refunded: the one refund), the completed visits paid in person (by card or in cash), and one sale at the point of sale of a sample product, sold at the desk only. They are recorded as the real thing is (the payments ledger, the booking's deposit, the sale at the point of sale) and announce nothing: no message and no webhook. GET /sandbox/payments lists them, and a reset makes them again.
- Where a sample asks booking questions or has booking terms, POST /book needs the person's answers (answers) and acceptTerms: true, exactly as at a live business that set them: without them it answers ANSWERS_REQUIRED or TERMS_NOT_ACCEPTED and books nothing. A restaurant's tables and the lessons and sessions for two take partySize: GET /availability with partySize offers only times with a table or court that seats the party, POST /book with partySize seats them there, and a price per person is charged for each of them.
- Each seeded booking has a manage key, as every booking made today does, and its key, like a real client's, is only ever given to the person who booked: with a seeded booking's id alone, GET /booking/{id} answers the read-only view and its availability, reschedule and cancel calls answer 403 MANAGE_KEY_REQUIRED, exactly as at a live business. To build and test managing a booking, make one with POST /book (its answer carries manageKey) or take the k from the manage link in its confirmation in the Outbox.
- The card-required rule (CARD_REQUIRED_USE_BOOKING_PAGE) is the business's cancellation policy, not a service's, so it starts off: switch on Require a card under Settings to try the card step, and every priced service then needs one.

The made-up clients' emails are at reserved example domains (such as example.com) and their phone numbers in reserved fiction ranges, so nothing can ever reach a real person. Change anything you like, in its admin or through the API. Reset puts the sample data back.

## Test keys and livemode

A sandbox issues only test keys, which start `bk_test_`, and a live business only live keys, which start `bk_live_`. Make and revoke them in the sandbox's admin at `/{slug}/admin/api`, with the same scopes as live keys, and use one exactly as you would a live key: `Authorization: Bearer bk_test_...` or `X-Api-Key`.

Every JSON answer says `livemode`: `false` from a sandbox, `true` from a live business. Every answer also carries the `Bookatu-Livemode` header, the one to read when an answer is not a JSON object. On the MCP server a result says it in `_meta["com.bookatu/livemode"]`; the header is there on every answer, streamed ones included.

A test key sent to a live business, or a live key sent to a sandbox, is refused before anything runs, on every endpoint, the keyless ones included:

```http
HTTP/1.1 403 Forbidden
Content-Type: application/json

{ "ok": false, "error": "...", "code": "LIVEMODE_MISMATCH", "keyLivemode": false, "businessLivemode": true, "livemode": true }
```

A front end that calls the keyless endpoints, with no key at all, can send `Bookatu-Livemode: false` on every request: a call that reaches a live business is then refused the same way, so a test build can never book at a real business.

An app connected through OAuth to one of your own sandboxes is a test connection: the consent page lists your sandboxes marked (test), never anyone else's; the token answer says livemode: false; MCP calls with it say livemode false. Connections to live businesses get livemode: true in the token answer, and are otherwise unchanged. A connection is pinned to the API version current when it was approved.

Testing an AI agent? Point it at the sandbox through the MCP server, with its test key or a connected app, and it gets four tools of its own: the outbox, the test payments, the simulator and reset. See [Test your AI agent](https://bookatu.com/developers/testing-ai-agents).

## The test outbox

Nothing a sandbox sends leaves Bookatu. Every email, text message, WhatsApp message, call and push notification it would send to a client or to its team is kept in its test outbox instead: read it in the sandbox's admin at `/{slug}/admin/outbox`, or with `GET /api/v1/{slug}/sandbox/outbox` (a test key with `customers:read`; filter with `channel`, `since` and `limit`, and page with `cursor`, the `nextCursor` of the page before). It keeps the newest 500 messages.

```sh
curl "https://bookatu.com/api/v1/sbx-ab12cd34/sandbox/outbox?channel=email&limit=20" \
  -H "Authorization: Bearer bk_test_YOUR_KEY"

{
  "messages": [
    { "id": "sbo_...", "channel": "email", "kind": "booking_confirmation",
      "to": "mia@example.com", "subject": "You're booked in", "text": "...", "html": "...",
      "appointmentId": "apt_...", "customerId": "cus_...", "createdAt": "2026-10-30T02:05:00.000Z", "meta": {} }
  ],
  "nextCursor": null,
  "livemode": false
}
```

The one message a sandbox really sends is the warning to you, its maker, before it expires.

## Payments: the simulated checkout

A sandbox never reaches a real card or a real payment account. Where a live business sends a client to pay (a deposit, a gift card, the shop), a sandbox sends them to a simulated checkout page with three buttons: Pay (test), Decline (test) and Abandon. Pay (test) completes the payment; Decline (test) shows a declined card and leaves the checkout open, as a real one does; Abandon lets it expire. Each takes the same path as a real payment, so the booking, its webhooks and its emails (in the outbox) go through the real states: paid, failed and expired.

The keyless API hands a deposit or a card to save to the booking page, as at a live business: POST /book answers 409 DEPOSIT_NEEDS_BOOKING_PAGE or CARD_REQUIRED_USE_BOOKING_PAGE with bookingPageUrl. A sandbox's booking page is open to anyone (its other storefront pages are yours only), so your front end can follow that link from any browser, signed in or not, to the simulated checkout.

Where a service needs a card saved to hold the booking, the card step offers Save test card and Save a card that will decline, and a no-show fee charged later succeeds or fails by the card saved. These pages have buttons only: never type a real card number into a sandbox.

Every kind of checkout works this way: deposits, class places, gift cards, the shop, packages, prints, gallery extras and invoices. Refunds of test payments are simulated too. Memberships (subscriptions) can't be bought in test mode yet.

Every test payment is listed in the sandbox's admin under Test payments (`/{slug}/admin/test-payments`, beside the Outbox), and by `GET /api/v1/{slug}/sandbox/payments` (a test key with `reports:read`; filter with `kind` and `since`, and page with `limit` and `cursor`, the `nextCursor` of the page before). Each is a checkout, a payment, a card hold, a no-show charge or a refund, with its outcome (paid, declined, abandoned, card saved, refunded), its amount and currency, when it was made and the booking or sale it belongs to. Nothing in it names a client.

```sh
curl "https://bookatu.com/api/v1/sbx-ab12cd34/sandbox/payments?kind=checkout&limit=20" \
  -H "Authorization: Bearer bk_test_YOUR_KEY"

{
  "payments": [
    { "id": "cs_test_sbx_...", "kind": "checkout", "outcome": "paid", "amountCents": 5000, "refundedCents": 0,
      "currency": "NZD", "description": "Deposit · Full colour", "appointmentId": "apt_...",
      "belongsTo": { "type": "booking", "id": "apt_..." }, "checkoutSessionId": "cs_test_sbx_...",
      "paymentIntentId": "pi_test_sbx_...", "createdAt": "2026-10-30T02:05:00.000Z", "updatedAt": "2026-10-30T02:06:00.000Z" }
  ],
  "nextCursor": null,
  "livemode": false
}
```

A new sandbox already has some, made with its sample data: deposits paid online on some of its bookings (the no-show's kept, half of it refunded), its completed visits paid in person and one sale at the point of sale, so Finance reads like a real business's. They announce nothing (no message, no webhook), and a reset makes them again.

## Make things happen: the simulator

Some events would otherwise mean waiting, or a client doing something. `POST /api/v1/{slug}/sandbox/simulate/{event}` makes them happen now, at a sandbox only (a test key with `bookings:write`; at a live business the answer is `404` with `code: "NOT_A_SANDBOX"`).

| Event | What happens |
| --- | --- |
| `deposit.paid` | Pays the booking's open test checkout, as the test checkout page's "Pay (test)" does: checkout.session.completed runs through the Stripe webhook's own handler, the booking is confirmed with its deposit paid, the confirmation goes to the outbox and booking webhooks are sent. |
| `payment.failed` | Declines the test card on the booking's open checkout, as "Decline (test)": the checkout stays open (as on Stripe's page) and nothing else changes; the answer says so (result.declined true, result.changed false). |
| `checkout.expired` | Abandons the booking's open checkout, as "Abandon": checkout.session.expired runs, and an online booking held for its deposit is let go (cancelled as deposit_abandoned) and its time offered to the waitlist. |
| `booking.client_cancelled` | The client's own cancellation (the manage page's), with the business's cancellation window, deposit rule, late-cancellation fee, emails and the booking.cancelled webhook. |
| `booking.no_show` | Marks the booking a no-show. A no-show fee the cancellation policy sets is queued for the owner, as at a live business (result.fee): nothing charges it but the owner's Charge on the test business's No-show fees screen, which charges the client's saved TEST card: paid for a card saved with "Save test card", declined (card_declined) for one saved with "Save a card that will decline". |
| `reminders.due` | Time passing: the booking's reminder email and reminder texts are sent as if now were its start minus each lead the business set (reminderHoursBefore, and its own text times). A booking reminded already is not reminded again. |

```sh
curl -X POST https://bookatu.com/api/v1/sbx-ab12cd34/sandbox/simulate/deposit.paid \
  -H "Authorization: Bearer bk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "appointmentId": "apt_..." }'
```

The answer gives the booking as it is now, the ids of the outbox messages the event kept (`outbox`), the webhook events it sent (`webhooks`) and what the event itself reported (`result`). The booking must be the sandbox's own: an id from any other business is a `404` with `code: "APPOINTMENT_NOT_FOUND"`. An unknown event is `400` `UNKNOWN_SIMULATION` (the answer lists the events), a booking with no open test checkout to pay, decline or abandon is `409` `NO_OPEN_CHECKOUT`, and a cancellation the business's rules don't allow is `409` `SIMULATION_REFUSED`.

## Webhooks from a sandbox

Webhooks are the one thing a sandbox does send out. Register an endpoint on the sandbox (in its admin, or with `webhooks_create` over MCP) and its events are delivered to you, signed as a live business's are, so one receiver can tell the two apart by `livemode`.

Every webhook payload has a top-level livemode field: true for a live business's event, false for a sandbox's (test mode). The same is sent as the X-Bookatu-Livemode header ("true" or "false"). A retried delivery resends the same bytes, so it says the same. A sandbox's webhooks are sent to your endpoints like a live business's, through the same address checks (public https only), with smaller limits: up to 5 endpoints, 300 deliveries an hour, 3 retries, and Send test 10 times a minute.

## Expiry, reset and delete

A sandbox nobody uses is deleted after 30 days without an API call with its key or a visit to its admin, and you get an email 7 days before. Using it again starts the 30 days again.

Reset wipes everything in it and fills it with fresh sample data, keeping its id and slug, its keys, its webhook endpoints and its connected apps. Delete removes it and everything in it at once. Both are on your sandboxes page and in the developer API above.

## Limits

- No Bookatu billing: it has every feature of the Scale plan, is never on a trial, never paused, never charged, and never accrues overage.
- Small fixed ceilings, whatever the plan says: 50 AI operations a month (and one daily budget shared by all sandboxes), half a gigabyte of gallery storage, 200 marketing emails a month (to its Outbox), 200 rows an import.
- It can't connect Twilio, WhatsApp, Messenger, Instagram, Google Calendar, QuickBooks or a Stripe account, have a custom domain, add team members or staff logins, use the managed AI assistant, or have a referral link.
- It is never in discover, search, the sitemap, the platform's directories or social proof. Its storefront pages are for you (and Bookatu support) only, marked Test business and never indexed; its booking page (/{slug}/book, where POST /book's DEPOSIT_NEEDS_BOOKING_PAGE and CARD_REQUIRED_USE_BOOKING_PAGE answers send a client), the group session pages it links its classes to (/{slug}/sessions/{serviceId}), its test checkout and the pages its Outbox links to (a client's booking, confirm, review, claim, swap and the like) are open to anyone with the link, so your own front end can be tested end to end by anyone on your team. Its booking widget is shown only on the domains you list under Settings.

A sandbox has lower rate limits than any live business, whatever its plan: 25 calls a minute and 1,000 a day per test key, and 30 a minute per address on its keyless endpoints, which also keep a live business's booking caps. Its outbox and the simulator have small budgets of their own. See [Rate limits](https://bookatu.com/developers/rate-limits).

When it works, see [Going live](https://bookatu.com/developers/going-live) to switch to a live business's key.
