Skip to content

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.yaml

deploy 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 …):

CallPurpose
GET /v1/methe 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}/approveconsole 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}/projectsprojects in an org
GET /v1/orgs/{org}/projects/{project}/envs/{env}/appsapps in an environment (id, slug)
POST /v1/apps/{appId}/deploybody { "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}/manifestthe 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}/sitelist 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}/sourcethe source of the last deploy: { source, deployedAt }
GET /v1/apps/{appId}/starterthe starter app: { source, lang, site }
GET /v1/databases/{dbId}/tablestables 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}/countexact row count (rate-limited separately: a row table is scanned)
GET /v1/orgs/{org}/queuesqueues (with stats and per-minute series) in projects you can view
POST /v1/orgs/{org}/projects/{p}/envs/{e}/queuesOwner/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}/statsstats 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}/consumerOwner/Admin — { appId, maxBatchSize?, maxBatchTimeoutMs?, maxConcurrency?, visibilityMs? } or { appId: null } to clear it (making it a pull queue)
GET/POST /v1/queues/{qId}/messagespeek (?limit&failed=1) and send a test message — developer+; viewers never see bodies
POST /v1/queues/{qId}/retry-failedOwner/Admin — { max? }; move messages from the failed area back into the queue
POST /v1/queues/{qId}/purgeOwner/Admin — { confirm: "<slug>", failed? } (failed: true purges the failed area)
POST /v1/apps/{appId}/databasesbody { "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}/sitebody { "paths": [...] } commits the site; unlisted files are removed
PUT /v1/apps/{appId}/domainsbody { "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).

CallPurpose
GET /v1/orgs/{org}/emaillist the org's email connections
POST /v1/orgs/{org}/emailcreate a connection (SMTP or provider)
DELETE /v1/orgs/{org}/email/{ecId}delete a connection
PUT /v1/orgs/{org}/email/{ecId}/sendersset the connection's verified senders (and optional bounce address)
POST /v1/orgs/{org}/email/{ecId}/testsend a test message; body { "to" }, returns a sanitized result
GET /v1/orgs/{org}/email/logthe org send log (30-day retention; ?before= paginates)
GET /v1/orgs/{org}/email/settingsthe org's quotas and suspension state
PATCH /v1/orgs/{org}/email/settingsset quotas/suspension; body { "hourly"?, "daily"?, "suspended"? } — instance owner only
GET | PUT | DELETE /v1/instance/emailthe instance email connection (instance owner only)
POST /v1/instance/email/testtest the instance connection; body { "to" }

Auth and password reset ​

CallPurpose
GET /v1/auth/optionsreturns { "passwordReset": bool } — reset is offered only when instance email is configured
POST /v1/auth/resetrequest a reset; body { "email" } — same response whether or not the account exists
POST /v1/auth/reset/confirmbody { "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/*/deploy or deploy against the origin.

TinyS3 is AGPLv3 licensed. Cloudflare and Cloudflare Workers are trademarks of Cloudflare, Inc. Amazon S3 and AWS are trademarks of Amazon.com, Inc. or its affiliates. MinIO is a trademark of MinIO, Inc. They are named only to describe compatibility; totis.ai is not affiliated with, endorsed by, or sponsored by any of them.