---
title: Sandbox
path: sandbox
status: published
---

# Sandbox

A local docker-compose ScaiControl stack with deterministic test data — for integration testing without touching production billing.

The compose file, seed script, and teardown script live in this repo at `tools/sandbox/`. This page is the single canonical entry-point describing how to use them.

## What you get

| Container | What it runs |
|---|---|
| `scaicontrol-sandbox-mariadb` | MariaDB 11.4, schema `scaicontrol_sandbox`, port `3307` (host) |
| `scaicontrol-sandbox-redis`   | Redis 7-alpine, port `6380` (host) |
| `scaicontrol-sandbox-api`     | ScaiControl backend on `:8001` (host), Mollie test mode |
| `scaicontrol-sandbox-worker`  | `arq` worker — cron jobs, event dispatcher, idempotency-record purge |
| `scaicontrol-sandbox-migrate` | One-shot `alembic upgrade head` against the sandbox DB |

Ports are configurable via env vars (`SANDBOX_API_PORT`, `SANDBOX_DB_PORT`, `SANDBOX_REDIS_PORT`). Defaults are picked to not collide with the standard dev stack.

## Bring it up

```bash
# 1. From the project root, start the stack
docker compose -f tools/sandbox/docker-compose.sandbox.yml up -d

# 2. Run migrations (idempotent)
docker compose -f tools/sandbox/docker-compose.sandbox.yml run --rm migrate

# 3. Seed deterministic test data
python tools/sandbox/seed.py
```

## What the seed creates

Idempotent — re-running converges on the same state. Use `python tools/sandbox/seed.py --reset` to clear sandbox rows before reseeding.

- **2 partners**: `prt_sandbox_platform`, `prt_sandbox_reseller`
- **6 tenants** (3 per partner): `tnt_sandbox_<partner>_a/b/c`, each with a billing profile
- **12 users** (2 per tenant): one `tenant_admin`, one no-role
- **18 subscriptions** (3 per tenant): one each in `active`, `trialing`, `cancelled` — exercises the state machine
- Uses the first active+public service+plan it finds in the catalog. If the DB is empty, falls back to creating a placeholder service/plan called `sandbox-svc` / `trial`.

## Configuration: Mollie

The compose file pre-wires `MOLLIE_ENABLED=true` and reads `MOLLIE_TEST_API_KEY` from your environment. Get a Mollie test key from the Mollie dashboard (it prefixes with `test_`) and put it in `.env.sandbox`:

```
MOLLIE_TEST_API_KEY=test_xxxxxxxxxxxxxxxx
```

Then `source .env.sandbox` before `docker compose up`. **Never put a `live_` key in the sandbox file** — the sandbox can charge real cards if you do.

Stripe and crypto plugins are forced off in the sandbox to keep the surface area minimal.

## Scenarios

Each recipe assumes the stack is up, the seed has run, and you have a sandbox JWT (mint one from the ScaiKey sandbox realm or use the seeded `tenant_admin` users with the dev-token flow).

### Apply a plan change, observe the webhook

```bash
SUB=sub_sandbox_platform_a_active
curl -X POST http://localhost:8001/api/v1/admin/subscriptions/$SUB/override \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: idmp_$(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"plan_id": "<another-plan-uuid>"}'
```

Then watch the worker logs for the `subscription.changed.v1` outbox row being fanned out to whichever webhook subscriber you registered.

### Retry an Idempotency-Key

```bash
KEY=idmp_$(uuidgen)
for i in 1 2 3; do
  curl -i -X POST http://localhost:8001/api/v1/admin/subscriptions/$SUB/cancel \
    -H "Authorization: Bearer $TOKEN" \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"immediate": true, "reason": "sandbox test"}'
done
```

Calls 2 and 3 return the cached response from call 1 with `X-Idempotency-Replayed: true`. The worker only emits one `subscription.cancelled.v1` event because the outbound idempotency_key derives from the inbound `Idempotency-Key`.

### Trigger trial-ending notification

The trial monitor runs daily at 09:13 UTC. To trigger it on demand:

```bash
docker exec scaicontrol-sandbox-worker \
  python -c "from scaicontrol.workers.trial_monitor import emit_trial_endings; \
             import asyncio; asyncio.run(emit_trial_endings({}))"
```

It scans for `trialing` subs with `trial_ends_at` at 7/3/1 days remaining and emits `subscription.trial_ending.v1`.

### Suspend → resume cycle

```bash
SUB=sub_sandbox_platform_a_active

curl -X POST http://localhost:8001/api/v1/admin/subscriptions/$SUB/suspend \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json"
# emits subscription.suspended.v1, status → 'suspended'

curl -X POST http://localhost:8001/api/v1/admin/subscriptions/$SUB/resume \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json"
# emits subscription.resumed.v1, status → 'active'
```

### Force-deliver a stuck webhook

The dispatcher ticks every 30s. To force immediate delivery during a test:

```bash
docker exec scaicontrol-sandbox-worker \
  python -c "from scaicontrol.workers.event_dispatcher import dispatch_events; \
             import asyncio; asyncio.run(dispatch_events({}))"
```

## Tear down

```bash
tools/sandbox/teardown.sh
```

Stops every container and wipes the volumes — next bring-up starts deterministic.

## Pointing CRM at the sandbox

Configure your CRM's outbound webhook subscriber and OIDC issuer to:

| Setting | Value |
|---|---|
| API base | `http://localhost:8001/api/v1` |
| OpenAPI | `http://localhost:8001/openapi.json` |
| ScaiKey realm | `https://auth.scailabs.ai/realms/sandbox` |
| Webhook subscriber target | your CRM's test webhook endpoint |

Register the webhook via the seeded `tenant_admin` user against `POST /admin/webhook-subscriptions` and the dispatcher will start sending events on the next tick.

## See also

- [API reference](./reference/api/) — every endpoint with request/response schemas
- [Events catalog](./reference/events/catalog) — webhook payload shapes
- [Webhooks operational reference](./reference/webhooks) — dispatcher behaviour
- [State machines](./reference/state-machines) — full subscription / invoice / provisioning transitions
