Tenants

In saas mode, your platform runs your customers' apps: an AI app builder deploying the apps it generates, a site builder hosting its users' sites. Each customer is a tenant; each tenant has apps. Your product's backend drives them through the tenants API, and your customers, if you let them, through your tenants' CLI with a token of their own tenant.

A tenant's app is handlers in TypeScript or JavaScript, shaped like Cloudflare Workers', that your platform runs on scaledbox's runtime: each environment of an app is one sandboxed process (a box), with no path to other tenants, within limits you set. An app with nothing to do sleeps, holding nothing at all, and wakes in milliseconds on its next request, queue message or scheduled time: the request is held meanwhile. Its resources are standard concepts (invocations, CPU time, rows, messages…), not servers or containers.

Your API's reference (https://api.shop.example/docs) has every operation and field; this page is the tour. Paths below start with https://api.shop.example/v1.

A token for your backend

shop-ops tokens create backend --role tenants

A tenants token reaches only your product's tenants: it can't deploy your product or read its secrets. Store it as one of your product's secrets.

Your API is behind Cloudflare's proxy, in your zone. Have your backend send a User-Agent that names it (e.g. shop-backend/1.4), as your CLIs do (shop/<version>). If Cloudflare ever answers in the API's place (a challenge carries the header cf-mitigated: challenge; a block, a text such as error code: 1020), log its status, that header and the cf-ray header: the Ray ID finds the request in your zone's security events, and the rule that stopped it.

A part of your system that serves one customer needs only that customer: give it one tenant's token (or let them sign the CLI in themselves: below).

shop-ops tokens create acme --role tenants --tenant acme

or, from your backend with its tenants token, POST /v1/product/tokens with {"name": "acme", "role": "tenants", "tenant": "acme"} (it expires no later than the token that made it, and is revoked with it). It reaches only that tenant's apps, deploys, environments, domains, data, logs and usage, not its limits, its suspension or its removal, nor any other tenant. Your backend's token lists and revokes such tokens (GET, DELETE /v1/product/tokens/<id>); removing the tenant revokes them.

Your tenants' CLI signs in through your page

Your customers sign shop, your tenants' CLI, in to their own tenant through your page: your product owns their accounts, your platform only their tenants (OAuth 2.0 device authorization, RFC 8628, as gh auth login and fly auth login do). Name the page in your platform's cluster file:

device_page = "https://shop.example/cli"
  1. Your customer runs shop login. The CLI shows a code such as BCDF-GHJK and opens https://shop.example/cli?code=BCDF-GHJK.

  2. Your page, where they are signed in to your product, shows the code and asks them to confirm that it is the one in their terminal. Never approve a code nobody confirmed: whoever started the sign-in gets the token.

  3. Your backend approves it for their tenant, with its tenants token:

    POST /v1/tenants/<tenant>/device/approve
    {"user_code": "BCDF-GHJK"}
    

    204 once approved; 404 if no CLI waits with that code (expired after ten minutes, already decided, or mistyped: any case, dashes and spaces are ignored). If they decline, POST /v1/device/deny with the same body tells the CLI so. One tenant's token can do neither.

  4. The CLI, which polls meanwhile, receives a token of that tenant only and keeps it in the user's configuration directory, under your brand's name (~/.config/shop/credentials.json on Linux, ~/Library/Application Support/shop/ on macOS). It expires in 30 days at most, never after the token that approved it, and is revoked with it; shop logout revokes it at once.

Your tenants' tokens show in GET /v1/product/tokens (named shop cli <tenant> …), where your backend can revoke one.

What the CLI calls, if you write your own client: POST /v1/device (no token) answers device_code, user_code, verification_uri (your page), verification_uri_complete, expires_in and interval; then POST /v1/device/token with {"device_code": "..."} every interval seconds answers 428 ("error": "authorization_pending") until your backend decides, then the token (secret), or 400 with "error": "access_denied" or "expired_token".

The tenants' CLI

shop, built from tenants.Main with your brand (Getting started), is the tenants' CLI: what a tenant (or your backend, for one) runs with one tenant's token. Your CI builds it for your customers' machines, and you publish it where they download it. The tenant is the token's (GET /v1/whoami says whose it is), so no command names it; with your product's tenants token, for all its tenants, --tenant acme names one.

export SHOP_TOKEN=sbx_...                        # acme's token (or: shop login)
shop deploy blog ./dist --assets ./dist/public
shop apps                                        # each app's environments, their status and URLs
shop environments list blog
shop environments delete blog staging            # its own database with it: type its name
shop apps delete blog                            # type its name; --confirm blog without a terminal
shop releases blog
shop rollback blog --env prod                    # the release before; or --to <digest, or its start>
shop logs blog --env prod --since 30m
shop secrets set STRIPE_KEY --env prod           # asks for the value
shop domains add shop.acme.com --app blog        # prints the records to create
shop dev ./dist                                  # run it on this machine

SHOP_API_URL names another API than your brand's (https://api.shop.example). Your product's own commands (its deploys, its tokens, any of its tenants: tenants deploy acme blog ./dist) are the operator's CLI's, shop-ops, which is never handed to tenants.

Tenants and their limits

PUT /tenants/acme
{"limits": {"apps": 3, "database": "1Gi", "bucket": "5Gi",
            "box": {"memory_mb": 128, "cpu_ms": 1000},
            "month": {"invocations": 1000000, "cpu_ms": 3600000}}}

The limits are its plan, in standard units (Limits has every one, with its default and maximum): how many apps, and environments each (a deploy to one more is refused); its database's and bucket's sizes; box, the caps of each of its apps' boxes and of each invocation (an app's box.toml may ask for less, never more); awake and connections, its apps awake and WebSockets open at once, all together; cron_interval, how often a schedule may fire; requests_per_second and concurrent_requests, each of its apps' names' rate and requests open at once; and month, what it may use a month. New limits redeploy nothing: they hold within moments where its apps run (an awake app takes new caps at its next request). A cap lowered below what a running app's box.toml asks holds the app to it: GET .../apps/{app} says so in its environment's held, and your product is told (tenant.quota_exceeded); a new release asking more is refused. Past a month's allowance, new work is refused or slowed and nothing running is stopped:

Past its month's What happens until the next month (or a higher limit)
invocations its apps answer at most 5 requests a second each (others get 429)
cpu_ms new invocations are refused (429)
memory_gb_s its sleeping apps don't wake (503); awake ones go on
egress its apps' calls out to the internet go at 1 Mbit/s each, its bucket's downloads at 1 Mbit/s (never cut)

Past its logs_per_day (UTC), its apps' output isn't kept until the next day.

Past its class_a and class_b together, its bucket answers 5 requests a second (503 SlowDown, which S3 SDKs retry). Your product is told (webhook tenant.quota_exceeded, then tenant.quota_cleared). A database at its size turns read-only; a bucket at its size takes nothing more; its objects are at most its bucket_object (default 25M, at most 100M). Suspending a tenant ends its bucket's transfers at once. Its database's and bucket's sizes are charged at your prices (Spending), and so are its month's memory time and CPU, as used, once you set their prices; its other usage is metered and shown, not charged.

POST /tenants/acme/suspend takes all its apps down in one call (they answer 503 and never wake); POST /tenants/acme/resume brings them back. GET /tenants/acme/usage shows what its apps used, per environment and unit: invocations, CPU-ms, memory time, rows, KV units, messages, egress, logs. GET /tenants/acme/metrics?app=blog&env=prod&since=24h&step=1h gives its apps' (one app's, one environment's) requests, CPU and memory over time, for you to show your customer. GET /tenants/acme/analytics?app=blog&env=prod&since=24h&step=1h gives their traffic, as Workers' and Vercel's analytics do: requests by status, latency percentiles (p50, p90, p99 of the time to the answer's head) and the most requested paths. Every request is counted; paths are grouped, a segment that looks like an id (digits, a UUID, a long token) as :id, and past an environment's first 50 paths as (other). For one environment (app and env), versions gives each version of its deployment apart (its requests, its 5xx and its percentiles, by its short id), so a gradual deployment's new version is judged by its error rate and latency beside the old one's before you promote it.

Apps

PUT /tenants/acme/apps/blog
{"kind": "function", "subdomain": "acme-blog"}

declares the app: a function (handlers, with the resources its manifest binds) or static (files only); its kind is fixed once made. It answers at https://<subdomain>.<tenant domain> (your [tenants] domain, see Domains; the subdomain defaults to <app>--<tenant>).

Before deploying, run it on your machine as a deploy would: Develop locally.

Deploying

Your platform builds nothing: your product builds its customers' apps (it already does: an app builder bundles what it generates, with esbuild or its bundler of choice) and deploys what it built, the way Cloudflare's Workers for Platforms take scripts. A deploy is the app's files by their SHA-256: its JavaScript modules (ES modules, as many as the app has, importing each other by relative paths and cloudflare:workers only), their source maps (for its logs' stack traces) and its static assets by URL path (a static app's files; a function app's beside its handlers, at the paths its fetch doesn't answer: [assets]), with its box.toml. Only the files your platform doesn't have yet are sent:

POST /tenants/acme/apps/blog/deploys
{"box_toml": "main = \"index.js\"\n...",
 "modules":     {"index.js": {"hash": "sha256:9f2c…", "size": 4754}, "lib/db.js": {"hash": "sha256:…", "size": 812}},
 "source_maps": {"index.js": {"hash": "sha256:…", "size": 8716}},
 "assets":      {"/index.html": {"hash": "sha256:…", "size": 1290}, "/logo.svg": {"hash": "sha256:…", "size": 3311}}}

The answer is the deploy (its id) and the files to upload in buckets (none if your platform has them all, as when only box.toml changed). Upload each bucket as one multipart/form-data request, a part per file named by its hash, a few buckets at a time:

POST /tenants/acme/apps/blog/deploys/<id>/files

then finish, within the hour:

POST /tenants/acme/apps/blog/deploys/<id>/finish

Finishing compiles each new module to bytecode on the tenant's cluster (a module compiled before, by any deploy, isn't compiled again), records the release (identified by its digest) and deploys it to the environment ("environment": "staging" in the manifest; prod by default): its answer is 202 with the deploy and the environment, deploying. A module that doesn't compile fails the deploy with a 422 naming the module, its line and the error; nothing changes. The manifest may be gzipped (Content-Encoding: gzip); a code-only change sends no assets and "keep_assets": true to keep the ones the environment runs. GET .../deploys/<id> shows a deploy and what compiling it took.

The CLI does all of it from a directory (its box.toml, its modules and their .map files): shop deploy blog ./dist --assets ./dist/public on the tenants' CLI with acme's token (shop-ops tenants deploy acme blog ... from yours).

A box of the new release must wake and answer its health check before the environment moves to it (within 3 minutes); until then, and if it fails, the environment keeps running what it ran. Poll the app (GET .../apps/blog) for each environment's status; you get app.live or app.failed.

Compiling counts against the tenant's compile budget, month.compile_cpu_ms (ten minutes of CPU by default): past it, its deploys are refused until the next month; nothing running stops.

Versions and gradual deployments

A release is a version, as in Cloudflare Workers. An environment's deployment is one version taking all its traffic, or two splitting it by percentage while you roll the new one out. Every version answers alone at its preview URL, https://<version>--<subdomain>.<tenant domain> (for prod; <version>--staging--<subdomain> for staging), its version the release's first 8 hex digits: test it there before it takes any traffic.

Deploy a release without traffic, beside the one the environment runs ("percent": 0 in the deploy's manifest, or shop deploy blog ./dist --percent 0), then move it forward, promote it or roll it back:

PUT /tenants/acme/apps/blog/environments/prod/versions
{"versions": [{"release": "sha256:<v1>", "percent": 90}, {"release": "sha256:<v2>", "percent": 10}]}

Logs

Every line of an app's boxes is kept 14 days: its console.* lines, a line per request (level request: method, path, status, duration) and the runtime's own (runtime), each with its environment, release and request id (the request's X-Request-Id, else the runtime's own); a thrown error's line has its stack in stack, mapped to your source by the source maps you deployed (../src/util.ts:6:40; the stack in the bundle's lines is in bundle_stack).

GET /tenants/acme/logs?app=blog&environment=prod&level=error&since=1h
GET /tenants/acme/logs?request_id=5f0c2a9e-1
GET /tenants/acme/logs/tail?app=blog

A search answers the newest lines (limit, at most 1000), oldest first; q keeps those with a phrase. tail streams server-sent events, a log event per new line about a second after it was written (since=5m sends the last five minutes' first); a line the store took late (around a box's move to another node) comes within about 6 seconds of its writing, out of order, and one it took over 5 seconds late (while it restarted) is in searches only. It ends after an hour, and a tenant has 5 at once. A box's lines from before it was claimed for the app are the platform's warm-up, not the app's, and aren't in its logs.

GET /tenants/acme/traces/5f0c2a9e-1

is a request's trace, as Workers' automatic tracing gives it, in OpenTelemetry's span model: the request's own span, and a span for each fetch (each redirect) and each call of a binding (db.query with its SQL, kv.get, queue.send, bucket.put…), with its start, duration, attributes and error; a thrown error is an exception event, its stack mapped. A trace is kept with its request's line (14 days; a request keeps its first 64 spans), so a box writing more than its 100 lines a second loses some.

box.toml

main = "index.js"                # the module (of the deploy's) whose default export handles requests
hosts = ["api.stripe.com"]       # what fetch may reach (https only); nothing else
cron = ["0 * * * *"]             # scheduled() runs (UTC)

[bindings.DB]                    # env.DB
kind = "database"

[bindings.KV]
kind = "kv"

[bindings.JOBS]
kind = "queue"
consumer = true                  # its messages go to this app's queue()

[bindings.FILES]
kind = "bucket"

[[bindings.FILES.events]]        # its objects' changes, one message each on JOBS
queue = "JOBS"
actions = ["object-create", "object-delete"]
prefix = "uploads/"              # optional, and suffix
[bindings.STRIPE_KEY]            # env.STRIPE_KEY, a string: the tenant's secret STRIPE_KEY
kind = "secret"

[bindings.AUTH]                  # the app's own users (env.AUTH; /api/auth/* served by the platform)
kind = "auth"
user_events = "USERS"            # a queue binding (consumer = true) each new user is a message on
providers = ["github"]           # sign-in with GitHub (and/or "google"): the tenant's OAuth app, set first

[bindings.USERS]
kind = "queue"
consumer = true

[limits]                         # within the tenant's box limits
memory_mb = 128
cpu_ms = 100                     # per invocation, at most the tenant's box.cpu_ms (200 unless you raise it)

[auth]                           # your product's users' JWTs, verified before the handler runs
jwks_url = "https://shop.example/.well-known/jwks.json"

[[routes]]
method = "POST"
path = "/api/orders"
auth = true                      # needs a valid JWT; its claims are ctx.user
schema = { type = "object", required = ["item"] }   # the body's JSON Schema: 400 if it doesn't fit

[assets]                         # with the deploy's assets
run_worker_first = ["/api/*", "!/api/docs/*"]        # the paths fetch answers; the rest are assets
not_found_handling = "single-page-application"      # a path no asset has: index.html for a browser
Key What it is
main One of the deploy's modules (.js, .mjs: TypeScript and JSX are bundled by you); its default export has the handlers. A static app has none
hosts Host names fetch may reach, over https on port 443 (at most 50); every redirect is checked too
cron Schedules for scheduled(): five fields (minute hour day month weekday, UTC) or @hourly, @daily, @weekly, @monthly; at most 5
[bindings.<NAME>] A resource the code reaches as env.<NAME>: kind is database, kv, queue, workflow, bucket, webhooks, objects (with class, the exported class each object is, at most 55 characters), secret, ai or auth (one at most); at most 20
secret A secret binding's: the tenant's secret it binds, when not the binding's own name ([bindings.STRIPE] kind = "secret" with secret = "STRIPE_KEY")
user_events, providers auth's: the queue binding (with consumer = true) each new user's user.created message goes to; the OAuth providers its users may sign in with (github, google)
consumer A queue's: its messages are delivered to this app's queue(); max_batch_size (10, at most 100), max_batch_timeout (seconds, 5), max_retries (3) as Workers Queues'
queue A product's service's queue's: its environment's queue it is (default: the binding's name), which its services share, one consuming it; a tenant app's queues are its own (refused)
class A workflow's: the exported class that runs it
[[bindings.<NAME>.events]] A bucket's: its objects' changes sent to one of the app's queue bindings (queue), one message each; actions are object-create (PutObject, CopyObject, CompleteMultipartUpload) and object-delete (DeleteObject, LifecycleDeletion); optional prefix and suffix; at most 10
[[routes]] method, path (/api/orders/:id), optional schema (a JSON Schema for the body) and auth
[auth] jwks_url (https), optional issuer and audience
[[ingest]] path and queue: POST and PUT requests to that path are stored on that queue binding and answered 202, without waking the app (webhooks you must never lose)
[limits] memory_mb, cpu_ms, duration_ms, subrequests, body_bytes, rows_read_per_tx, rows_written_per_tx, idle_seconds: 0 or unset is the tenant's
[assets] run_worker_first and not_found_handling, as Workers' (below)

Unknown keys and invalid values are refused before anything is uploaded, each message saying what to write instead.

Assets

An app's static assets are the deploy's assets, served at their paths by the tenants' cluster's file server: /docs/ serves /docs/index.html, and their answers aren't invocations (they are egress). A function app's assets share its URLs with its fetch, as a Worker's do, named as Workers' settings:

Handlers

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const { rows } = await env.DB.query("select id, total from orders where customer = $1", [42]);
    return Response.json(rows);
  },
  async scheduled(event, env, ctx) {
    // event.cron, event.scheduledTime
  },
  async queue(batch, env, ctx) {
    for (const message of batch.messages) {
      // message.body, message.attempts; message.ack() or message.retry()
    }
  },
};

Bindings

Kind env.<NAME>
database The tenant's Postgres database, as a pg Pool: query(text, values) → {rows, rowCount, fields}; transaction(async (client) => …) commits when it resolves; connect(). Drizzle's node-postgres driver and Kysely's PostgresDialect attach to it. int8 and numeric come as strings, timestamps as ISO strings, bytea as Uint8Array, json as values. Session state (SET, LISTEN, temporary tables) doesn't last past a transaction; SET LOCAL does. A transaction ends with its request: one left open is rolled back once the request is answered (one begun after the answer, in waitUntil, once the app has no request in flight)
kv Workers KV's API: get(key, {type}), getWithMetadata, put(key, value, {expirationTtl, metadata}), delete, list({prefix, cursor}); strongly consistent, and atomic() with Deno KV's checks: .check({key, versionstamp}).set(…).sum(…).commit()
queue Workers Queues': send(body, {delaySeconds}), sendBatch([…]); JSON or text bodies, delays up to 12 hours
workflow Workers Workflows': create({id, params}), get(id) → status(), sendEvent({type, payload}); the class (extends WorkflowEntrypoint) runs step.do, step.sleep, step.sleepUntil, step.waitForEvent, each step's result kept, so a run resumes where it was
bucket R2's binding API over the tenant's object storage: get, head, put, delete, list; and presign(key, {method, expiresIn}) for a URL your users upload or download with (a transfer ends when its URL does: give it minutes)
objects Durable Objects, with class = "Room" (an exported class extending DurableObject from cloudflare:workers): env.ROOMS.idFromName(name), idFromString(id), newUniqueId(), get(id) and getByName(name) give a stub, whose fetch(request) and methods (RPC, arguments and results copied as structuredClone copies them) run the object: one instance per id, made with (ctx, env). ctx.storage is Durable Objects' key-value storage API: get, put, delete, deleteAll, list({prefix, start, startAfter, end, reverse, limit}), transaction(fn), sync(), getAlarm, setAlarm(time), deleteAlarm (and alarm() runs then, retried if it throws); keys are strings of at most 2 KiB, values at most 128 KiB; an answer waits until its writes are stored, and while a read waits for the database no other event reaches the object. ctx.blockConcurrencyWhile(fn), ctx.acceptWebSocket(ws, tags), ctx.getWebSockets(tag), ctx.getTags(ws) as on Workers. There is no SQL storage per object: use the database binding for SQL. Stored in the tenant's database, its size counting them
secret The tenant's secret as a string (below), as its environment sees it: new Stripe(env.STRIPE_KEY) works at the module's top level
webhooks send({eventType, payload}, {idempotencyKey}): signed (Standard Webhooks), delivered to the tenant's endpoints and retried by Svix. Its endpoints, in Svix's shapes: endpoints({limit, iterator}) lists them, createEndpoint({url, filterTypes, channels, uid, …}, {idempotencyKey}), endpoint(id), updateEndpoint(id, {…}) and deleteEndpoint(id) manage them (by id or uid), endpointSecret(id) gives one's signing secret for its receiver, rotateEndpointSecret(id, {key}) replaces it (the old one still signs for 24 hours). Only the tenant's own
ai Your product's AI gateway (its [ai]), with its provider keys: OpenAI's Chat Completions (/v1/chat/completions) and Responses (/v1/responses) and Anthropic's Messages (/v1/messages), a model named <provider>/<model>. fetch(input, init) answers a call whole, and the providers' SDKs take it as theirs (new OpenAI({apiKey: "unused", baseURL: "https://ai.invalid/v1", fetch: env.AI.fetch})); stream(path, request, {ttl}) gives {url, expires}, a single-use URL your user's browser fetches the answer streamed from (new EventSource(url), or a POST), while your app sleeps; handoff(path, request) gives a Response to return: your user gets the answer streamed instead, your app done at once. Your tenant's tokens are metered per model; past its month's (limits.month.ai_tokens) or at its requests at once (limits.ai_streams), new calls get 429 and those under way finish
auth The app's own users (Better Auth's, run by the platform): getSession(request) → {session, user} or null, user(request) → the user or null, read from the request's session cookie in a fraction of a millisecond, no library in your bundle. Its users, sessions and organizations are tables of schema auth in the tenant's database (auth."user", auth.session, auth.organization, auth.member…): yours to read and join in SQL

auth serves the app's users at its own host, under /api/auth/ (Better Auth's client works as is: createAuthClient() from better-auth/client, with its organizationClient, emailOTPClient and passkeyClient plugins): sign-up and sign-in with an email and password (argon2id), email codes, passkeys, GitHub or Google (the tenant's OAuth app: PUT .../tenants/{tenant}/auth/providers/github with its client_id and client_secret, its callback https://<the app's host>/api/auth/callback/github), organizations with members and invitations. GET /api/auth/get-session is answered by the app itself, without waking anything else. Sessions last 7 days, extended as they are used.

A bucket's events (as R2's event notifications): a tenant's bucket is its apps' bucket bindings of one name (every FILES of its apps is one bucket), and each app environment declaring [[bindings.FILES.events]] gets the changes its rules match on its queue, at least once, as R2's message:

{"account": "acme", "action": "PutObject", "bucket": "FILES",
 "object": {"key": "uploads/a.png", "size": 5120, "eTag": "9a0364b9e99bb480dd25e1f0284c8555"},
 "eventTime": "2026-10-26T10:00:00Z"}

A change is answered only once its events are stored (a client cut off sends it again: an event may come twice). A copy's event has no size; a deletion's neither size nor ETag. Each message counts in the tenant's messages; past its allowance, events are dropped.

Lifecycle rules (S3's lifecycle configuration): objects under a prefix deleted some days after they were written, multipart uploads left incomplete aborted some days after they began, applied once a day (a day is 24 hours; an object several rules match goes by the fewest days). Deletions send the bucket's LifecycleDeletion events. Rules are a bucket's whole set, replaced by each PUT; a tenant's token may set its own:

curl -X PUT -H "Authorization: Bearer $TOKEN" https://api.shop.example/v1/tenants/acme/buckets/FILES/lifecycle \
  -d '{"rules": [{"id": "tmp", "prefix": "tmp/", "expire_days": 7}, {"id": "uploads", "abort_incomplete_multipart_days": 1}]}'

WebSockets are held by the platform while the app sleeps; each belongs to an object. As on Workers, an object accepts one while answering its upgrade request:

export class Room extends DurableObject {
  async fetch(request) {
    const [client, server] = Object.values(new WebSocketPair());
    this.ctx.acceptWebSocket(server, ["lobby"]);
    return new Response(null, { status: 101, webSocket: client });
  }
  async webSocketMessage(ws, message) {
    for (const s of this.ctx.getWebSockets()) s.send(message);
  }
}
export default { fetch: (request, env) => env.ROOMS.getByName("lobby").fetch(request) };

Its webSocketMessage(ws, message) and webSocketClose(ws, code, reason, wasClean) run for its sockets, its box woken if it slept; getWebSockets() answers every socket of the object, on whichever of your servers holds it. Without objects, the default export's handlers hold the app's sockets (its default object): webSocketOpen(ws, request, env, ctx) (a Response other than 2xx refuses it), webSocketMessage(ws, data, env, ctx) (what it returns is sent back) and webSocketClose(ws, code, reason, wasClean, env, ctx), ctx.getWebSockets() their sockets. await ws.send(…), ws.close(…) and ws.serializeAttachment(value) (at most 16 KiB, kept with the socket) work as on Durable Objects; the sends of one moment go out together. Pings never wake the app.

Secrets

A tenant's secrets (its Stripe key, its own API keys) are set once for the tenant and bound by its apps:

PATCH /tenants/acme/secrets
{"set": {"STRIPE_KEY": "sk_test_…"}}

PATCH /tenants/acme/secrets
{"environment": "prod", "set": {"STRIPE_KEY": "sk_live_…"}}

The first is for every environment of the tenant's apps; the second an override for prod ("environment": "preview": every preview, pr-<n>; so no environment is named preview). An environment sees its own value, then (a preview) the previews', then the tenant's. "unset": ["STRIPE_KEY"] removes a scope's value; DELETE /tenants/acme/secrets/STRIPE_KEY all of them. Your backend, or the tenant's own token, does it; the CLI asks for the value (shop secrets set STRIPE_KEY --env prod), never on the command line.

Domains

A tenant's own domain for one of its apps:

PUT /tenants/acme/domains/blog.acme.example

returns the two records the domain's owner creates: a TXT record proving it (its value in quotation marks, as Cloudflare wants a TXT record's content) and a CNAME to the app's own target (<id>.shop.<targets>, the same for each of its domains), proxied (orange cloud; at an apex Cloudflare flattens it). For now the domain's DNS must be on Cloudflare, in its owner's own account (its free plan does): a domain elsewhere is refused, since the app is served through that zone's proxy. The app's production answers on it once both hold (no certificate of its own to wait for); its other environments answer only at their names under your tenant domain. Your platform checks the CNAME by fetching /.well-known/scaledbox-check/<token> through the name over plain HTTP: no redirect rule of the zone may catch that path.

Each own domain is a paid resource: your price book's custom_domain_month, charged hour by hour to the tenant's ledger while it has the domain, whatever its status (see Spending). The names your tenants' apps get under your tenant domain are free.

Events

PUT /webhook
{"url": "https://shop.example/hooks/platform"}

sends your product's events: app.live, app.failed, domain.verified, domain.failed, domain.released, tenant.quota_exceeded, tenant.quota_cleared, tenant.suspended, tenant.resumed, tenant.deleted, tenant.secrets_changed (the names and their scope, never a value), tenant_app.unhealthy and tenant_app.recovered (below), service.unhealthy and service.recovered (your own services': see Deploying). They are Standard Webhooks: verify the webhook-signature header with the secret the call returns once; webhook-id is the idempotency key. Undelivered events are retried for a day.

Your tenants' apps' health

A tenant's app is never probed: a check would wake its box. Its health is its real traffic's, over the last 5 minutes, once it had 20 requests or more: failing when 20% of them failed, degraded at 5% or when its answers' 95th percentile (to their first byte) took 5 seconds or more. Failed means the app's own failures: a 5xx it answered, a request it took and didn't answer, a wake its code threw in at its top level; never the platform's own refusals (a tenant past its limits, suspended, a bot kept from waking it). Checked every minute: tenant_app.unhealthy says so, {tenant, app, environment, status, reason, requests, error_rate, p95_ms, since} (again when degraded turns failing), and tenant_app.recovered once it is healthy again (or quiet: no failure in the window). GET .../apps/<app> shows a degraded or failing environment's health (its status, why, since when), and GET /v1/health all of your tenants' (tenant_apps).

Retries

A POST with an Idempotency-Key header is answered once: sent again with the same key within a day, it gets the first answer.

Data: dumps, export, deletion

Show your customers an acceptable-use policy, with your own abuse contact: Acceptable use is a starting point.