Deploy API
Apps are deployed through the platform: the console host (hosts.platform) serves the console and its /v1 API, authenticated by a browser session or a scoped API token. There is no separate admin key, zip upload or JSON-RPC deploy surface.
For the runtime API a Worker is written against (env.DB, env.SECRETS, routing), see the Worker SDK reference. To use queues and databases from outside a worker (pull messages, run SQL) with an API token, see the Data API.
The totis CLI
workers/cli is a standard-library Go CLI (cd workers/cli && go build -o totis .). The full reference is cli.md; the short version:
bash
totis login https://console.example.com # device flow; approve the code in the console
totis deploy # every app of ./totis.yaml: plan, provision, deploy
totis --platform https://console.example.com apps list --project shop
totis deploy --project shop --app storefront --site ./dist worker.ts # one-off file, no totis.yamldeploy takes one .ts or .js Worker source; it is bundled on the server (.ts types stripped, the default export wired to the runtime) and the app's migrations run. A source that imports npm packages or other local modules (import { x } from "./http/router") is first bundled into one module by the CLI with esbuild (from the nearest node_modules/.bin, then PATH; --bundle forces it). If a wrangler.toml, wrangler.jsonc or wrangler.json sits beside the source or in a parent directory up to the repository root (so src/index.ts finds ../wrangler.toml), or --manifest <file> names one, it is sent with the deploy for named bindings; its warnings are printed (see Named bindings). deploy --dry-run checks the manifest against the server without changing anything.
A one-off deploy worker.ts needs the app to exist; totis deploy in a folder with a totis.yaml creates the apps and everything they name (databases, KV namespaces, queues, secrets, connection bindings, settings, job webhooks). Secrets, bindings, schedules, the origin and custom domains have their own commands (totis secrets, bindings, schedules, origin, domains).
Custom domains
An app may attach up to 20 domains, each exact (app.example.com) or a one-label wildcard (*.example.com — matches a single extra label, so an exact entry beats it and a.b.example.com is not covered). A domain cannot cover the platform hosts or a bare TLD. The operator owns DNS and TLS: point the domain at the server and terminate TLS yourself — the platform does not verify domain ownership, a deliberate trust-model decision for a self-hosted, single-operator deployment.
Static sites
--site <dir> publishes a static frontend alongside the Worker (the console offers the same under Upload site folder). Each file is uploaded, then the full list is committed, so any file not in the latest upload is removed from the site. Limits: dotfiles (any path segment starting with .) are refused, a file may be at most 8 MB, and a push is sized for up to ~1000 files.
Console API
The CLI calls the same API the console uses. With an API token (Authorization: Bearer …):
| Call | Purpose |
|---|---|
GET /v1/me | the token's user, org and scope (maxRole: null for a device-login token, which acts as its user's current membership) |
POST /v1/device/{code}/approve | console session only; body { "org" }. Issues a 90-day cli token for that org with no role cap or project list; any maxRole/projectIds sent is ignored |
GET /v1/orgs/{org}/projects | projects in an org |
GET /v1/orgs/{org}/projects/{project}/envs/{env}/apps | apps in an environment (id, slug) |
POST /v1/apps/{appId}/deploy | body { "source": "…", "lang": "ts" | "js", "public"?: bool, "manifest"?: { "name", "text" }, "dryRun"?: bool }; returns { ok, result, warnings }. dryRun only validates the manifest (aliases, consumers, secrets) and changes nothing |
POST /v1/apps/{appId}/secrets/{NAME} | generate a random secret on the server; body { "bytes"?: 16–256, "reveal"?: bool }; the value comes back only with reveal (also under /orgs/{org}/secrets/ and …/envs/{env}/secrets/) |
GET /v1/version | { api, server }, no sign-in |
GET /v1/apps/{appId}/manifest | the manifest stored with the last deploy that carried one |
PUT /v1/apps/{appId}/site/{path} | upload one static-site file (raw body; ≤ 8 MB, no dotfiles); staged until the next commit, or published at once with ?publish=1 |
GET /v1/apps/{appId}/site | list the published files: { files: [{ path, size, contentType }], totalBytes, limits } |
GET /v1/apps/{appId}/site/{path} | download one published file (always application/octet-stream, Content-Disposition: attachment) |
DELETE /v1/apps/{appId}/site/{path} | remove one published file |
GET /v1/apps/{appId}/source | the source of the last deploy: { source, deployedAt } |
GET /v1/apps/{appId}/starter | the starter app: { source, lang, site } |
GET /v1/databases/{dbId}/tables | tables and views: { tables: [{ name, kind, columns, rowCount, rowCountExact, sizeBytes }] } (MergeTree counts are exact; row-table counts are estimates) |
GET /v1/databases/{dbId}/tables/{name} | structure: columns (type, precision, scale, nullable, default), keys, checks, indexes, MergeTree sorting/partition key, and ddl regenerated from the catalog |
GET /v1/databases/{dbId}/tables/{name}/rows | ?cols=a,b&sort=a:desc,b&f=col:op:value&limit=1..500&offset= — ops = != < <= > >= contains starts and is null/is not null; no SQL is accepted, names are checked against the catalog and values bound; text over 4 KiB comes back as {"$trunc", "len"} |
POST /v1/databases/{dbId}/tables/{name}/count | exact row count (rate-limited separately: a row table is scanned) |
GET /v1/orgs/{org}/queues | queues (with stats and per-minute series) in projects you can view |
POST /v1/orgs/{org}/projects/{p}/envs/{e}/queues | Owner/Admin — { slug, config? } |
GET /v1/queues/{qId} | detail with the consumer and recent senders (apps that sent in the last 24 h) |
GET /v1/queues/{qId}/stats | stats and the per-minute series |
PATCH /v1/queues/{qId} | Owner/Admin — { config?, paused? } |
DELETE /v1/queues/{qId} | Owner/Admin — refused (409) while a consumer is set |
PUT /v1/queues/{qId}/consumer | Owner/Admin — { appId, maxBatchSize?, maxBatchTimeoutMs?, maxConcurrency?, visibilityMs? } or { appId: null } to clear it (making it a pull queue) |
GET/POST /v1/queues/{qId}/messages | peek (?limit&failed=1) and send a test message — developer+; viewers never see bodies |
POST /v1/queues/{qId}/retry-failed | Owner/Admin — { max? }; move messages from the failed area back into the queue |
POST /v1/queues/{qId}/purge | Owner/Admin — { confirm: "<slug>", failed? } (failed: true purges the failed area) |
POST /v1/apps/{appId}/databases | body { "slug", "binding"? } — create a database in the app's environment and bind it (default DB); needs db.manage and app.manage; nothing is left behind if the bind fails |
POST /v1/apps/{appId}/site | body { "paths": [...] } commits the site; unlisted files are removed |
PUT /v1/apps/{appId}/domains | body { "domains": [...] } (≤ 20; exact or *.example.com) |
GET /v1/apps/{appId} | app details; status.bindingTargets maps each binding name to what it points at ({ kind, dbId | scId, bucket, prefix | ecId }, ids only) |
PUT /v1/apps/{appId}/bindings/{name} | bind env.{name}; body { "email": { "ecId", "senders"?, "recipients"? } } (also { "db" } / { "storage" }) — email binding is owner/admin only |
A deploy that omits public keeps the app's current visibility. Only public apps are served on <label>.<hosts.apps>, /apps/<label>/ and their custom domains.
Email
Outbound-email connections are managed per org; quotas and suspension are instance-owner settings; instance email (used by the system app for invitations and password reset) is managed under /v1/instance. All connection, sender, test and send-log routes are owner/admin (developers may only list connections to see what a binding points at).
| Call | Purpose |
|---|---|
GET /v1/orgs/{org}/email | list the org's email connections |
POST /v1/orgs/{org}/email | create a connection (SMTP or provider) |
DELETE /v1/orgs/{org}/email/{ecId} | delete a connection |
PUT /v1/orgs/{org}/email/{ecId}/senders | set the connection's verified senders (and optional bounce address) |
POST /v1/orgs/{org}/email/{ecId}/test | send a test message; body { "to" }, returns a sanitized result |
GET /v1/orgs/{org}/email/log | the org send log (30-day retention; ?before= paginates) |
GET /v1/orgs/{org}/email/settings | the org's quotas and suspension state |
PATCH /v1/orgs/{org}/email/settings | set quotas/suspension; body { "hourly"?, "daily"?, "suspended"? } — instance owner only |
GET | PUT | DELETE /v1/instance/email | the instance email connection (instance owner only) |
POST /v1/instance/email/test | test the instance connection; body { "to" } |
Auth and password reset
| Call | Purpose |
|---|---|
GET /v1/auth/options | returns { "passwordReset": bool } — reset is offered only when instance email is configured |
POST /v1/auth/reset | request a reset; body { "email" } — same response whether or not the account exists |
POST /v1/auth/reset/confirm | body { "token", "password" }; the single-use link is valid 30 minutes and revokes all sessions on use |
When instance email is configured, POST /v1/orgs/{org}/invitations emails the invitation and the response carries emailed: true; otherwise the copy-and-paste link is still returned.
Over a CDN/tunnel: a WAF may 403 large JS/TS request bodies. Add a WAF exception for
POST /v1/apps/*/deployor deploy against the origin.