---
title: "Test mode inside production, without one real email"
description: "Release 15 adds sandbox businesses: test businesses that sit in the production database beside real ones. They must never email a real person or charge a real card, and no live business may ever be deleted as one. Here is how two marks, a few CHECKs and triggers hold that line, how the expiry sweep is kept away from live businesses, and three design critic findings that changed the plan."
url: "https://bookatu.com/engineering/test-mode-inside-production-without-one-real-email"
language: "en"
section: "Engineering"
category: "Engineering"
author: "The Bookatu engineering team"
published: "2026-10-30"
updated: "2026-10-30"
tags: ["testing", "postgres", "multi-tenant", "security", "engineering"]
---

# Test mode inside production, without one real email

Release 15 adds sandbox businesses: test businesses that sit in the production database beside real ones. They must never email a real person or charge a real card, and no live business may ever be deleted as one. Here is how two marks, a few CHECKs and triggers hold that line, how the expiry sweep is kept away from live businesses, and three design critic findings that changed the plan.

> **The short version**
>
> - A sandbox is a business row in the production database with two marks, set when the row is made. A trigger stops either one changing.
> - CHECKs and triggers keep a sandbox off a real Stripe account and off Bookatu billing, test keys on sandboxes, and outbox rows off live businesses.
> - Code that could send or charge treats either mark as a sandbox. Code that deletes needs both.
> - The expiry sweep deletes through one statement that names both marks.
> - Three of a design critic's findings changed the plan: simulate ids, custom domains, and hidden businesses that answered differently from missing ones.
> - Making sandboxes sits behind SANDBOX_CREATION_ENABLED, which is off by default in the code.

Release 15 adds test mode for developers. A sandbox issues only bk_test_ keys, keeps every message it would send in an outbox, and simulates every payment. We built it inside production, the way Stripe's test mode works, so a sandbox runs exactly the code a live business runs. That only holds if nothing can mistake one kind of business for the other, so the first lines went in the database, where no code path can step round them.

## Two marks, set once

Migration 0186 adds two booleans to organizations: sandbox and created_as_sandbox. Both default to false, so every existing business is live. The second looks redundant. It is there so that wiping a business needs two facts, not one.

```sql
-- The flag and the marker agree on every row.
alter table bookatu.organizations
  add constraint organizations_sandbox_marker check (sandbox = created_as_sandbox);

-- A business is made live or as a sandbox, and stays that way.
create or replace function bookatu.organizations_sandbox_immutable() returns trigger
language plpgsql as $$
begin
  if new.sandbox is distinct from old.sandbox or new.created_as_sandbox is distinct from old.created_as_sandbox then
    raise exception 'a business is made live or as a sandbox and stays that way (organizations.sandbox cannot change)';
  end if;
  return new;
end $$;
```

So a live business can never become a sandbox, and so can never be wiped or expired as one. A sandbox can never be promoted into a live business full of test data. tests/integration/migration-0186.test.ts tries each flip on PGlite and expects each refused. Migration 0186 adds the other hard lines too:

- A sandbox's payments account id, when set, starts acct_sandbox_, and no live business may hold one of that shape.
- A sandbox has no Bookatu billing customer or subscription.
- A bk_test_ key is exactly a key with livemode false, and only a sandbox may hold one. Connected apps follow the same rule.
- Only a sandbox may have rows in the outbox or the simulated payments table, so a live business's message can never be swallowed there.

## Either mark to send, both to delete

```ts
export function isSandboxOrg(org: SandboxMarks | null | undefined): boolean {
  return !!org && (org.sandbox === true || org.createdAsSandbox === true);
}

export function isDeletableSandbox(org: SandboxMarks | null | undefined): boolean {
  return !!org && org.sandbox === true && org.createdAsSandbox === true;
}
```

Anything that could reach a real person or card treats either mark as a sandbox, as isSandboxOrg does, so it leans towards test. Anything that deletes asks isDeletableSandbox. The database keeps the marks equal, so the answers only differ on a row that should not exist, and on that row both fail safe: it sends nothing, and it is not deleted.

## An expiry sweep with one way to delete

A sandbox unused for 30 days is deleted. Its maker is emailed from day 23, and while the maker can still be reached, a deletion needs that warning to have stood a week, so a missed run becomes a warning first, never a surprise. Every removal goes through one statement, whether it comes from the sweep, the developer, the platform console or a reset:

```ts
export async function deleteSandboxRow(ex: SeedExecutor, orgId: string): Promise<boolean> {
  const gone = await ex
    .delete(organizations)
    .where(and(eq(organizations.id, orgId), eq(organizations.sandbox, true), eq(organizations.createdAsSandbox, true)))
    .returning({ id: organizations.id });
  return gone.length > 0;
}
```

The sweep also selects only rows with both marks and checks each before it acts. Hand deleteSandboxRow a live business's id and it matches nothing. The lifecycle tests try a live business whose owner has made a sandbox, and a live one renamed to look like a sandbox. Then they run the sweep a year ahead against a live business made 400 days ago, and check it was neither warned nor deleted.

## Three findings that changed the design

Before the build started, a design critic was given the plan and one brief: find every way a sandbox could send, charge, appear in public or hurt a live business, and check each choke point for a way round it. Here are three of its findings that changed the shape of the work.

### Simulate ids that named any booking

POST /api/v1/{slug}/sandbox/simulate/{event} makes things happen to a sandbox booking: a deposit paid, a no-show, reminders coming due. The plan checked that the URL's business was a sandbox, then took the booking id from the body. Some functions it would call, the deposit checkout among them, load a booking by id alone and find the business and its payments account from it. A test key with a live business's booking id could have reached a real booking and a real payments account. Now every event reads the booking by both ids first:

```ts
/** A booking of THIS business only (another business's id is not found). */
async function sandboxAppointment(orgId: string, appointmentId: string): Promise<Appointment | null> {
  const row = (
    await db
      .select()
      .from(appointments)
      .where(and(eq(appointments.id, appointmentId), eq(appointments.orgId, orgId)))
      .limit(1)
  )[0];
  return row ?? null;
}
```

tests/integration/r15-simulate-api.test.ts sends a live booking's id to a sandbox's simulate URL for all six events. Each answers 404 APPOINTMENT_NOT_FOUND. No Stripe client is built, nothing reaches Resend or Twilio, and the live booking is unchanged.

### A CHECK on a deprecated column

0186 forbids a custom domain on a sandbox with a CHECK on organizations.custom_domain. The critic pointed out that this column is deprecated. Domains live in the custom_domains table, and that is what the host resolver reads. The CHECK would have passed its tests and stopped nothing. So the guard lives in code: addCustomDomain refuses a sandbox before any Vercel call, customDomainEntitled is false for one, and the resolver joins only live businesses.

custom_domains has no trigger, so the test writes a sandbox's active domain straight into the table, as a bug might, and checks the resolver serves it to nobody while a live business's domain still resolves.

### Hidden businesses that answered differently

Every v1 API answer about a business now says livemode. The plan looked the URL's business up by slug for that, drafts and suspended businesses included. So a draft's slug would answer 404 with livemode: true, and a slug that does not exist 404 with none. Anyone could have probed for unpublished businesses by name. The envelope every API route goes through now notes a business only when the public may see it:

```ts
if (org && publiclyVisible(org)) noteOrg({ id: org.id, slug: org.slug, sandbox: isSandboxOrg(org) });
else if (org) hidden = org;

/** getPublicOrgBySlug's tests: may the public see this business at all? */
function publiclyVisible(org: Organization): boolean {
  return !org.internal && !!org.published && org.status !== "suspended" && org.status !== "cancelled";
}
```

A hidden business tells its livemode only to a caller whose own credential was verified. tests/integration/r15-core-envelope.test.ts calls a draft's slug and a missing one with no key, a stray bk_test_ bearer and Bookatu-Livemode: false, and expects the same status, bytes and header names each time. Sandbox slugs are sbx- and eight random characters, a prefix no live signup may take.

## Off until it is switched on

Making sandboxes sits behind SANDBOX_CREATION_ENABLED, off by default in the code. That came from the critic too: a release rolled back to one with no send gate must not find sandboxes waiting. scripts/sandbox-quarantine.mjs counts them, then, only when asked, deletes rows with both marks.

## What we take from it

- Draw the line you must never cross in the database, and test the database refusing.
- Make a deletion need two facts, and a send need only one.
- Check every id a caller hands you against the business in the URL.
- Check a guard against the table the code really reads.
- A hidden thing has to answer exactly like a missing one.
