Cirrus Host documentation

Ship Workers to the edge in seconds. The helix CLI bundles your code locally with esbuild and uploads it to Cirrus Host; this guide walks through the CLI, the REST API (80+ endpoints with granular API key scopes), and worker configuration.

Quickstart

Ship your first worker in four commands.

1Install the CLI
npm i -g @cirrustech/helix
helix login

helix login opens a device-code flow against Cirrus Host and stores credentials in ~/.config/helix/config.json.

2Scaffold a project
helix init my-project/hello
cd my-project/hello

This creates a helix.json, a src/worker.ts entry, and a tsconfig.json.

3Deploy
helix deploy

Your worker is bundled with esbuild and uploaded. The first deploy provisions a hostname like hello.my-project.helx.dev with SSL automatically.

4Tail live logs
helix logs --tail

Each request to your worker streams in real time, including console.log output and uncaught exceptions.

Concepts

Cirrus Host runs your code on a globally-distributed V8 isolate runtime — your Worker boots in under a millisecond in 300+ cities. Key resources:

Workers don't get any storage by default. Provision databases, buckets, or KV stores at the project level with helix db create / helix bucket create / helix kv create, then declare each one as a binding in helix.json for the deployments that need access.

Helix CLI commands

CommandWhat it does
helix loginDevice-code OAuth flow
helix logoutForget local credentials
helix whoamiPrint the current user
helix init <project>/<name>Scaffold a new project and deployment
helix deployBuild and ship the current project
helix listList deployments in the current project
helix logs --tailStream live logs from a deployment
helix rollback <version>Roll back to a previous version
helix openOpen the deployment URL in the browser
helix delete <name>Permanently remove a deployment
helix env set KEY=valSet a plain-text env var
helix env listList env vars
helix secrets set KEYSecurely store a secret (prompted)
helix secrets listList secret names (values not shown)
helix domain add example.comAttach a custom domain
helix domain listList custom domains
helix domain verify example.comRe-check SSL/verification
helix project create <name>Create a new project
helix db create <name>Provision a dedicated SQL database
helix db lsList databases in the current project
helix bucket create <name>Provision a dedicated object-storage bucket
helix bucket lsList buckets in the current project
helix kv create <name>Provision a dedicated key-value store
helix kv lsList KV stores in the current project
helix bindings add --db <name> --as ENVAttach a resource to this deployment
helix usageShow period requests + CPU usage

helix deploy

Bundles main from helix.json with esbuild (modules + tree-shaking, target ES2022, with nodejs_compat), then uploads as a multipart form with the metadata declared in your config. Re-deploying the same (project, name) bumps the version and replaces the previous bundle in place.

helix logs

By default helix logs shows the last 50 lines retained on the platform. --tail opens a WebSocket and streams in real time. Logs are produced by every invocation event sent to the platform tail consumer; no observability config is required on your side.

env & secrets

Plain env vars are visible in the dashboard and applied as plain_text bindings on the next deploy. Secrets are AES-GCM encrypted at rest and applied as secret_text bindings — their values are never logged or returned by the API.

Custom domains

Run helix domain add example.com, then point a CNAME for example.com at cname.cirrus-host.com. SSL is provisioned automatically in under 60 seconds. Once active, the deployment is reachable at the custom hostname in addition to its default URL.

Resources

Workers don't get any storage by default. To use a database, bucket, or key-value store, you provision it once with the CLI, then declare it as a binding in helix.json. Resources live at the project level and can be attached to any deployment in that project.

ResourceCreate withBacked by
SQL databasehelix db create <name>SQLite database (10 GB)
Object storagehelix bucket create <name>S3-compatible bucket
Key-value storehelix kv create <name>Globally-replicated KV with TTL

helix.json

The single config file at the root of every project. Only name, project, and main are required.

{
  "name": "hello",
  "project": "my-project",
  "main": "src/worker.ts",
  "compatibility_date": "2025-04-01",
  "compatibility_flags": ["nodejs_compat"],
  "vars": { "STAGE": "prod" },
  "bindings": [
    { "type": "kv",     "name": "MY_CACHE",   "resource": "cache" },
    { "type": "bucket", "name": "IMAGES",     "resource": "images" },
    { "type": "db",     "name": "PRIMARY_DB", "resource": "primary" }
  ],
  "assets": { "directory": "./public" },
  "routes": ["api.example.com/*"]
}

Bindings

Bindings in helix.json attach a project resource onto this deployment under a chosen variable name. With helix deploy the resource must already exist (see Resources); the one-click deploy button can create it on the fly. Either way, the binding declares it visible at env.<NAME> for this deployment.

TypeAttachesAvailable as
dbA SQL databaseenv.<NAME>.prepare(sql).bind(...).all() — full D1 API
bucketAn object bucketenv.<NAME>.put(key, val) / .get(key) / .list() — R2 API
kvA KV storeenv.<NAME>.put(key, val) / .get(key) — KV API

Example flow:

helix db create primary
# adds an entry to your project's resources

# in helix.json:
"bindings": [
  { "type": "db", "name": "DB", "resource": "primary" }
]

# in your worker:
await env.DB.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, email TEXT)');
await env.DB.prepare('INSERT INTO users (email) VALUES (?)').bind('a@b.com').run();
const { results } = await env.DB.prepare('SELECT * FROM users').all();

Static assets

Add "assets": { "directory": "./public" } to helix.json. The CLI bundles every file under ./public and serves them from env.ASSETS at runtime.

One-click deploy button

Add a "Deploy to Cirrus Host" button to your project's README so visitors can fork-and-deploy in a single click.

1Add a template block to your helix.json
{
  "name": "url-shortener",
  "kind": "worker",
  "main": "src/worker.ts",
  "bindings": [
    { "type": "db", "name": "DB", "resource": "links" }
  ],
  "template": {
    "name": "URL Shortener",
    "description": "Tiny URL shortener with admin UI.",
    "bundle": "dist/worker.js",
    "env": [
      { "name": "ADMIN_PASSWORD", "description": "Password for /admin", "required": true, "generator": "secret" },
      { "name": "DEFAULT_REDIRECT", "description": "Where unknown short links go", "default": "https://example.com" }
    ]
  }
}
Any bindings you declare are picked up by the deploy form (workers only). For each one the visitor can create a new resource — pre-filled with the resource name from your helix.json, or a generated name if that's taken — or attach an existing resource from their project. Resource names are unique per project and type, so the platform reuses a matching resource rather than creating a duplicate. The visitor doesn't need to run helix db create first.
2Link to the deploy URL
https://cirrus-host.com/deploy?url=https://github.com/<owner>/<repo>

Add a button to your README:

[![Deploy to Cirrus Host](https://cirrus-host.com/badge.svg)](https://cirrus-host.com/deploy?url=https://github.com/owner/repo)
For a specific branch or sub-folder, use a tree URL: https://github.com/owner/repo/tree/feature/path-to-app.

Authentication

Every endpoint used by the panel is also available over the REST API. Authenticate with an API key created from the dashboard or via the API keys endpoint:

Authorization: Bearer helx_...

Base URL: https://cirrus-host.com/api/v1

All requests and responses use JSON (Content-Type: application/json) unless noted otherwise. Deployment uploads use multipart/form-data.

Session cookie authentication (from the browser) also works and grants full access.

API key scopes

API keys support granular scopes that control which resources and actions the key may access. Each scope follows the pattern resource:action. The wildcard scope * grants unrestricted access (backwards-compatible with older keys).

When creating an API key, specify the scopes array. If omitted, the key receives ["*"]. Invalid scope names are rejected with a 400 error.

If a request requires a scope the API key does not carry, the API returns:

{ "error": "insufficient scope: requires 'deployments:write'", "required_scope": "deployments:write" }

Available scopes

Account

Read or modify your own profile and notifications.

  • account:read — View your profile, email, and avatar
  • account:write — Update your profile settings
  • notifications:read — View notifications
  • notifications:write — Mark notifications as read

Projects

List, create, update, and delete projects.

  • projects:read — List and view projects
  • projects:write — Create, rename, and delete projects

Deployments

View and manage deployments, including containers.

  • deployments:read — View deployment details, versions, hostnames
  • deployments:write — Deploy, rollback, and delete deployments
  • containers:read — View container machines and status
  • containers:write — Deploy, restart, and scale containers

Configuration

Manage env vars, secrets, bindings, and custom domains.

  • env:read / env:write — Environment variables
  • secrets:read / secrets:write — Secrets (encrypted)
  • domains:read / domains:write — Custom domains
  • bindings:read / bindings:write — Resource bindings

Resources

Provision and manage databases, buckets, and KV stores.

  • resources:read — List and view resources
  • resources:write — Create, delete, and query resources

Observability

Read logs, metrics, and usage data.

  • logs:read — Stream and read deployment logs
  • metrics:read — View request/CPU metrics
  • usage:read — View billing period usage

Collaboration

Manage project members, org members, and invites.

  • members:read / members:write — Project & org members
  • orgs:read / orgs:write — Organizations

Billing

View and manage billing accounts, plans, and payment methods.

  • billing:read — View billing accounts & plans
  • billing:write — Manage billing, change plans

Authentication

Manage API keys and sessions.

  • api_keys:read / api_keys:write — API keys
  • sessions:read / sessions:write — Sessions

Admin

Platform administration (requires admin role).

  • admin:read — View admin dashboard
  • admin:write — Manage users, deployments, branding

Project restrictions

API keys can optionally be restricted to specific projects by passing allowed_projects (an array of project slugs) when creating the key. This is orthogonal to scopes — a key can have full scopes but only be able to act on certain projects.

If a request targets a project the key is not authorized for, the API returns:

{ "error": "api key not authorized for project 'my-project'" }

Keys with allowed_projects: null (the default) may access all projects the user has membership in.

Account

GET/api/v1/me

Get the authenticated user's profile, unread notification count, and pending invite count.

Scope: account:read

Response:

{
  "user": { "id": "...", "email": "...", "name": "...", "username": "...", "avatar": "...", "balance_cents": 0 },
  "unread_notifications": 3,
  "pending_invites": 1
}
GET/api/v1/overview

Dashboard overview with project/deployment counts, 24h request stats, recent activity, and timeseries data.

Scope: projects:read

Response:

{
  "projects": 5, "deployments": 12,
  "requests24h": 48201, "errors24h": 7,
  "activity": [ ... ],
  "timeseries": [ { "t": "2026-05-07T00:00:00Z", "requests": 1200, "errors": 2 } ]
}

Projects

GET/api/v1/projects

List projects the user has membership in. Filter by org with ?org=slug.

Scope: projects:read
ParameterInDescription
orgqueryOptional org slug filter
POST/api/v1/projects

Create a new project.

Scope: projects:write
ParameterInDescription
slugbodyURL-safe slug, lowercase alphanumeric + hyphens (max 32 chars)
namebodyDisplay name
descriptionbodyOptional description
org_slugbodyOptional org (defaults to personal org)
billing_account_idbodyOptional billing account (defaults to org's default BA)
GET/api/v1/projects/:slug

Get project details with deployments, membership info, and app base domain.

Scope: projects:read
PATCH/api/v1/projects/:slug

Rename a project. The slug is immutable.

Scope: projects:write
{ "name": "New Name" }
DELETE/api/v1/projects/:slug

Delete a project. All deployments must be deleted first. Owner only.

Scope: projects:write
POST/api/v1/projects/:slug/billing-account

Reassign the project to a different billing account.

Scope: billing:write
{ "billing_account_id": "ba_..." }

Deployments

GET/api/v1/projects/:slug/deployments/:name

Get deployment details with version history, env vars, secrets metadata, hostnames, and bindings.

Scope: deployments:read

Response:

{
  "project": { ... },
  "deployment": {
    "id": "...", "name": "hello", "status": "active",
    "version": 3, "url": "hello.my-project.helx.dev",
    "versions": [ { "version": 3, "message": "fix routing" }, ... ],
    "env_vars": [ { "name": "STAGE", "value": "prod" } ],
    "secrets": [ { "id": "...", "name": "API_KEY", "hint": "sk_l..." } ],
    "hostnames": [ { "hostname": "api.example.com", "ssl_status": "active" } ],
    "bindings": [ { "type": "db", "name": "DB", "resource_name": "primary" } ]
  },
  "membership": { "role": "owner", "permissions": [] }
}
POST/api/v1/projects/:slug/deployments

Deploy a worker. Body is multipart/form-data with a bundle file and metadata JSON. The CLI wraps this for you.

Scope: deployments:write

Response:

{ "deployment_id": "...", "version": 4, "url": "hello.my-project.example.com" }
The metadata JSON can include: name, message, deployment_id, compatibility_date, compatibility_flags, and bindings (array of {type, name, resource}).
DELETE/api/v1/deployments/:id

Permanently remove a deployment. The script is removed from the edge runtime.

Scope: deployments:write
POST/api/v1/deployments/:id/versions/:version/rollback

Restore a previous version as the active one.

Scope: deployments:write

Response:

{ "ok": true, "version": 2 }

Containers

Container deployments run OCI images instead of Worker scripts. The same project and billing model applies.

POST/api/v1/projects/:slug/deployments/container

Deploy a container from an OCI image.

Scope: containers:write
ParameterInDescription
namebodyDeployment name
imagebodyOCI image reference (e.g. ghcr.io/you/app:latest)
regionbodyOptional region hint
sizebodyOptional size preset (e.g. shared-cpu-1x@1024mb)
replicasbodyOptional replica count
portbodyOptional container port
envbodyOptional environment variables object

Response:

{
  "deployment_id": "...", "kind": "container",
  "url": "myapp.project.helx.dev",
  "machines": [ { "id": "...", "state": "running", "region": "ord" } ]
}
GET/api/v1/deployments/:id/container/machines

List machines for a container deployment.

Scope: containers:read
POST/api/v1/deployments/:id/container/restart

Restart all machines in a container deployment.

Scope: containers:write
POST/api/v1/deployments/:id/container/scale

Scale a container deployment.

Scope: containers:write
{ "replicas": 3 }

Cron jobs

Attach recurring scheduled triggers to any worker deployment. The platform evaluates cron expressions every minute (UTC) and dispatches a POST request to your worker's fetch handler with the header X-Cron-Trigger: <job-name>.

Handling cron triggers: In your worker, check for the X-Cron-Trigger header to distinguish scheduled invocations from regular traffic:
export default {
  async fetch(request) {
    const cron = request.headers.get('X-Cron-Trigger');
    if (cron) {
      // Handle scheduled job by name
      if (cron === 'cleanup') await doCleanup();
      return new Response('ok');
    }
    // Normal request handling
    return new Response('Hello!');
  }
}
GET/api/v1/deployments/:id/cron-jobs

List all cron jobs for a deployment.

Scope: cron:read

Response:

[{
  "id": "...", "name": "cleanup", "schedule": "0 3 * * *",
  "description": "Nightly data cleanup", "enabled": 1,
  "last_run_at": "2025-05-07T03:00:00.000Z", "last_status": "success"
}]
POST/api/v1/deployments/:id/cron-jobs

Create a new cron job.

Scope: cron:write
ParameterInDescription
namebodyJob name (lowercase, alphanumeric, hyphens, underscores)
schedulebodyCron expression (5-field: min hour dom mon dow)
descriptionbodyOptional description
{ "name": "cleanup", "schedule": "0 3 * * *", "description": "Nightly data cleanup" }
PATCH/api/v1/deployments/:id/cron-jobs/:jobId

Update a cron job schedule, description, or enabled state.

Scope: cron:write
{ "schedule": "0 6 * * *", "enabled": false }
DELETE/api/v1/deployments/:id/cron-jobs/:jobId

Delete a cron job.

Scope: cron:write
GET/api/v1/deployments/:id/cron-jobs/:jobId/runs

List recent execution history for a cron job (last 50 runs).

Scope: cron:read

Response:

[{
  "id": "...", "status": "success", "duration_ms": 42,
  "detail": null, "started_at": "2025-05-07T03:00:00.000Z"
}]

Environment variables

POST/api/v1/deployments/:id/env

Set or update a plain-text env var. Applied on the next deploy.

Scope: env:write
{ "name": "STAGE", "value": "prod" }
Names must match ^[A-Z][A-Z0-9_]{0,63}$.
DELETE/api/v1/deployments/:id/env/:name

Remove an env var.

Scope: env:write

Secrets

POST/api/v1/deployments/:id/secrets

Set or update a secret. Encrypted with AES-GCM at rest; decrypted only when applied to the worker as a secret_text binding.

Scope: secrets:write
{ "name": "STRIPE_KEY", "value": "sk_live_..." }
DELETE/api/v1/deployments/:id/secrets/:name

Remove a secret.

Scope: secrets:write

Custom domains

GET/api/v1/domains

List all custom domains across projects the user is a member of.

Scope: domains:read
POST/api/v1/deployments/:id/domains

Attach a custom hostname. Returns the CNAME target to point at.

Scope: domains:write
{ "hostname": "api.example.com" }
Point a CNAME to ${cnameTarget}. SSL provisions automatically.
POST/api/v1/hostnames/:id/verify

Re-check SSL provisioning and DNS for a custom hostname.

Scope: domains:write
DELETE/api/v1/hostnames/:id

Remove a custom hostname.

Scope: domains:write

Resources

GET/api/v1/projects/:slug/resources

List all resources in a project with binding counts.

Scope: resources:read

Response:

[
  { "id": "...", "type": "database", "name": "primary", "cf_resource_id": "...",
    "created_at": "2026-05-01T...", "binding_count": 2 },
  { "id": "...", "type": "bucket", "name": "uploads", "binding_count": 1 }
]
POST/api/v1/projects/:slug/resources

Provision a new resource.

Scope: resources:write
ParameterInDescription
typebodydatabase, bucket, or kv (aliases: d1, r2, kv_namespace)
namebodyResource name (lowercase, project-scoped)
location_hintbodyOptional location hint
GET/api/v1/resources/:id

Get resource details with bindings and type-specific stats (size, object count, key count).

Scope: resources:read
DELETE/api/v1/resources/:id

Delete a resource. Pass ?force=true to delete even if bindings exist.

Scope: resources:write

Resource explorer

Query databases, browse buckets, and manage KV stores directly from the API.

Database

POST/api/v1/resources/:id/database/query

Execute a SQL query against a database resource.

Scope: resources:write
{ "sql": "SELECT * FROM users WHERE id = ?", "params": [42] }
Pass "raw": true to execute multi-statement SQL (e.g. migrations).
GET/api/v1/resources/:id/database/tables

List user tables in a database.

Scope: resources:read

Bucket (Object Storage)

GET/api/v1/resources/:id/bucket/objects

List objects in a bucket.

Scope: resources:read
ParameterInDescription
prefixqueryFilter by key prefix
delimiterqueryGroup by delimiter (e.g. / for folder-like listing)
limitqueryMax results (default 100, max 1000)
cursorqueryPagination cursor
POST/api/v1/resources/:id/bucket/upload

Upload an object. Pass the key in the X-Key header and the file as the request body.

Scope: resources:write
GET/api/v1/resources/:id/bucket/object

Download an object. Pass the key as ?key=....

Scope: resources:read
DELETE/api/v1/resources/:id/bucket/object

Delete an object.

Scope: resources:write
{ "key": "path/to/file.png" }

KV Store

GET/api/v1/resources/:id/kv/keys

List keys in a KV store.

Scope: resources:read
ParameterInDescription
prefixqueryFilter by key prefix
limitqueryMax results (default 100, max 1000)
cursorqueryPagination cursor
GET/api/v1/resources/:id/kv/value

Read a KV value. Returns text or base64-encoded binary.

Scope: resources:read
ParameterInDescription
keyqueryThe key to read
PUT/api/v1/resources/:id/kv/value

Write a KV value.

Scope: resources:write
{ "key": "session:abc", "value": "{...}", "expiration_ttl": 3600 }
DELETE/api/v1/resources/:id/kv/value

Delete a KV key.

Scope: resources:write
{ "key": "session:abc" }

Bindings

GET/api/v1/deployments/:id/bindings

List resource bindings on a deployment.

Scope: bindings:read
POST/api/v1/deployments/:id/bindings

Attach a project resource to a deployment.

Scope: bindings:write
{ "resource_id": "res_...", "name": "MY_DB" }
The binding name must be UPPER_SNAKE_CASE. The resource must belong to the same project. The binding is applied immediately via a redeployment.
DELETE/api/v1/deployments/:id/bindings/:name

Detach a resource binding by name. Triggers a redeployment.

Scope: bindings:write

Metrics

GET/api/v1/deployments/:id/metrics

Per-deployment metrics: hourly request/error counts, p50/p99 CPU, status-code breakdown.

Scope: metrics:read
ParameterInDescription
sincequery24h (default), 7d, or 30d

Live logs

GET/api/v1/deployments/:id/tail

WebSocket upgrade for real-time logs. Each frame is one JSON-encoded log line.

Scope: logs:read

Response:

{ "t": "2026-05-07T01:03:32.896Z", "level": "info", "msg": "GET /api/users → ok (2.4ms cpu)" }
Logs stream via a tail consumer registered on every worker at deploy time. No setup needed — console.log, uncaught exceptions, and request summaries all stream automatically.

Usage

GET/api/v1/usage

Account-wide usage for the current 30-day billing period.

Scope: usage:read

Response:

{
  "plan": { "name": "Pro", "included_requests": 10000000, ... },
  "period_requests": 1284502, "period_cpu_ms": 483920,
  "included_requests": 10000000, "included_cpu_ms": 5000000,
  "daily": [ { "date": "2026-05-01", "requests": 41203, "errors": 12, "cpu_ms": 14820 } ],
  "invoices": [ ... ]
}

Members & invites

Project members

GET/api/v1/projects/:slug/members

List project members, pending invites, and the permissions catalog.

Scope: members:read
POST/api/v1/projects/:slug/invites

Invite a user to a project by email or username.

Scope: members:write
ParameterInDescription
identifierbodyEmail or username
rolebodyadmin, developer, viewer, or custom
permissionsbodyArray of permissions (for custom role)
DELETE/api/v1/projects/:slug/invites/:id

Cancel a pending invite.

Scope: members:write
PATCH/api/v1/projects/:slug/members/:userId

Update a member's role or permissions.

Scope: members:write
{ "role": "admin", "permissions": ["deployments:write", "env:read"] }
DELETE/api/v1/projects/:slug/members/:userId

Remove a member. Anyone can remove themselves (leave).

Scope: members:write
POST/api/v1/projects/:slug/transfer

Transfer project ownership to another member. Current owner becomes admin.

Scope: members:write
{ "user_id": "..." }

Personal invites

GET/api/v1/me/invites

List project invites addressed to you.

Scope: members:read
POST/api/v1/me/invites/:id/accept

Accept a project invite.

Scope: members:write
POST/api/v1/me/invites/:id/decline

Decline a project invite.

Scope: members:write

Organizations

Endpoints are at /api/v1/orgs.

GET/api/v1/orgs

List organizations the user belongs to.

Scope: orgs:read
POST/api/v1/orgs

Create a team organization.

Scope: orgs:write
{ "name": "My Team", "slug": "my-team", "description": "..." }
GET/api/v1/orgs/:slug

Get org details with your role.

Scope: orgs:read
PATCH/api/v1/orgs/:slug

Update org name or description. Admin required.

Scope: orgs:write
{ "name": "New Name", "description": "..." }
DELETE/api/v1/orgs/:slug

Delete a team org. All projects must be removed first. Owner only.

Scope: orgs:write

Org members

GET/api/v1/orgs/:slug/members

List org members.

Scope: members:read
POST/api/v1/orgs/:slug/invites

Invite a user to an org by email or username.

Scope: members:write
ParameterInDescription
email / usernamebodyThe person to invite
rolebodyadmin, billing, developer, or viewer
PATCH/api/v1/orgs/:slug/members/:userId

Update a member's role.

Scope: members:write
{ "role": "admin" }
DELETE/api/v1/orgs/:slug/members/:userId

Remove a member from the org.

Scope: members:write

Org invites

GET/api/v1/orgs/accept-invite

Preview an org invite by token.

Scope: orgs:read
ParameterInDescription
tokenqueryInvite token
POST/api/v1/orgs/accept-invite

Accept an org invite.

Scope: orgs:write
{ "token": "..." }
POST/api/v1/orgs/decline-invite

Decline an org invite.

Scope: orgs:write
{ "token": "..." }

Org logo

POST/api/v1/orgs/:slug/logo

Upload an org logo. Send as multipart/form-data or raw image bytes. Max 2 MB. Accepted types: SVG, PNG, JPEG, WebP, GIF.

Scope: orgs:write
DELETE/api/v1/orgs/:slug/logo

Remove the org logo.

Scope: orgs:write

Billing accounts

Endpoints are at /api/v1/billing-accounts. Billing accounts belong to orgs and aggregate usage + charges for all projects assigned to them.

GET/api/v1/billing-accounts

List billing accounts available to an org.

Scope: billing:read
ParameterInDescription
for_orgqueryRequired org slug
POST/api/v1/billing-accounts

Create a billing account in an org. Requires admin role.

Scope: billing:write
{ "org_slug": "my-team", "name": "Production", "description": "..." }
GET/api/v1/billing-accounts/:id

Get billing account details with usage and access info.

Scope: billing:read
PATCH/api/v1/billing-accounts/:id

Update billing account name, description, or spending limits.

Scope: billing:write
{ "name": "...", "hard_limit_cents": 10000, "soft_limit_cents": 5000 }
DELETE/api/v1/billing-accounts/:id

Delete a billing account.

Scope: billing:write
POST/api/v1/billing-accounts/:id/default

Set as the org's default billing account.

Scope: billing:write
GET/api/v1/billing-accounts/:id/available-plans

List visible plans for this billing account's reseller.

Scope: billing:read
POST/api/v1/billing-accounts/:id/plan

Change the billing account's plan.

Scope: billing:write
{ "plan_id": "..." }
GET/api/v1/billing-accounts/:id/breakdown

Per-project usage breakdown for the current billing period.

Scope: billing:read

Billing account sharing

GET/api/v1/billing-accounts/:id/shares

List orgs and users this billing account is shared with.

Scope: billing:read
POST/api/v1/billing-accounts/:id/shares

Share a billing account with another org or user.

Scope: billing:write
{ "org_slug": "partner-team", "permission": "use" }
Permissions: use (assign projects to it) or manage (full control). Identify the target with org_slug, username, or email.
DELETE/api/v1/billing-accounts/:id/shares/:shareId

Revoke a billing account share.

Scope: billing:write

Payment methods

User-level (legacy)

GET/api/v1/stripe-key

Get the Stripe publishable key for client-side card collection.

Scope: billing:read
GET/api/v1/payment-methods

List saved payment methods.

Scope: billing:read
POST/api/v1/payment-methods/setup

Create a Stripe SetupIntent for adding a new card.

Scope: billing:write
POST/api/v1/payment-methods/confirm

Confirm and save a payment method after Stripe Elements completes.

Scope: billing:write
{ "payment_method_id": "pm_..." }
POST/api/v1/payment-methods/:id/default

Set a payment method as default.

Scope: billing:write
DELETE/api/v1/payment-methods/:id

Remove a payment method.

Scope: billing:write

Billing-account-level

GET/api/v1/billing-accounts/:id/payment-methods

List payment methods on a billing account.

Scope: billing:read
POST/api/v1/billing-accounts/:id/payment-methods/setup

Create a SetupIntent for adding a card to a billing account.

Scope: billing:write
POST/api/v1/billing-accounts/:id/payment-methods/confirm

Save a payment method to a billing account.

Scope: billing:write
{ "payment_method_id": "pm_...", "make_default": true }
POST/api/v1/billing-accounts/:id/payment-methods/:pmId/default

Set a card as default for a billing account.

Scope: billing:write
DELETE/api/v1/billing-accounts/:id/payment-methods/:pmId

Remove a card from a billing account.

Scope: billing:write

API keys

GET/api/v1/api-keys

List your API keys with scopes, project restrictions, and expiration.

Scope: api_keys:read

Response:

[{
  "id": "...", "name": "CI Deploy Key", "key_prefix": "helx_abc",
  "scopes": ["deployments:write", "projects:read"],
  "allowed_projects": ["my-api"],
  "expires_at": "2026-08-01T00:00:00Z", "created_at": "..."
}]
POST/api/v1/api-keys

Create a new API key with granular scopes and optional project restrictions.

Scope: api_keys:write
ParameterInDescription
namebodyDisplay name (max 100 chars)
scopesbodyArray of scopes (default: ["*"]). See scopes reference.
allowed_projectsbodyOptional array of project slugs to restrict access to
expires_in_daysbodyOptional expiration in days from now
{
  "name": "CI Deploy Key",
  "scopes": ["deployments:write", "projects:read"],
  "allowed_projects": ["my-api", "my-frontend"],
  "expires_in_days": 90
}

Response:

{
  "id": "...", "secret": "helx_abc123...", "prefix": "helx_abc",
  "name": "CI Deploy Key",
  "scopes": ["deployments:write", "projects:read"],
  "allowed_projects": ["my-api", "my-frontend"],
  "expires_at": "2026-08-05T..."
}
The secret is only returned once at creation time. Store it securely.
GET/api/v1/api-keys/scopes

List all available scopes with descriptions and groupings. Useful for building scope selection UIs.

Scope: api_keys:read

Response:

{
  "scopes": ["account:read", "account:write", ...],
  "groups": { "account": { "label": "Account", "scopes": [...] }, ... },
  "descriptions": { "account:read": "View your profile, email, and avatar", ... }
}
DELETE/api/v1/api-keys/:id

Revoke an API key.

Scope: api_keys:write

Sessions

GET/api/v1/sessions

List active browser sessions and CLI tokens. The current session is marked.

Scope: sessions:read

Response:

[
  { "id": "...", "kind": "web", "label": "Browser", "ip": "...", "current": true, "expires_at": "..." },
  { "id": "...", "kind": "cli", "label": "Helix CLI", "last_used_at": "..." }
]
DELETE/api/v1/sessions/:id

Revoke a session. Pass {"kind":"cli"} in the body for CLI tokens.

Scope: sessions:write

Notifications

GET/api/v1/me/notifications

List your notifications (last 50).

Scope: notifications:read
POST/api/v1/me/notifications/:id/read

Mark a notification as read.

Scope: notifications:write
POST/api/v1/me/notifications/read-all

Mark all notifications as read.

Scope: notifications:write

Plans

GET/api/v1/plans

List visible plans for the current reseller. Useful for plan selection UIs.

Scope: billing:read

Admin

Admin endpoints require the super_admin or reseller_admin role plus the admin:read or admin:write scope. All endpoints are at /api/v1/admin.

GET/api/v1/admin/overview

Platform overview: user/deployment counts, 24h requests, timeseries, recent activity.

Scope: admin:read
GET/api/v1/admin/users

Search users by email or name.

Scope: admin:read
ParameterInDescription
qquerySearch query
POST/api/v1/admin/users/:id/impersonate

Impersonate a user. Sets a session cookie.

Scope: admin:write
POST/api/v1/admin/users/:id/suspend

Suspend a user account.

Scope: admin:write
POST/api/v1/admin/users/:id/resume

Reactivate a suspended user.

Scope: admin:write
GET/api/v1/admin/deployments

Search deployments by name, project, or owner email.

Scope: admin:read
ParameterInDescription
qquerySearch query
POST/api/v1/admin/deployments/:id/suspend

Suspend a deployment.

Scope: admin:write
POST/api/v1/admin/deployments/:id/resume

Resume a suspended deployment.

Scope: admin:write
GET/api/v1/admin/plans

List all plans with subscriber counts.

Scope: admin:read
POST/api/v1/admin/plans

Create a new plan.

Scope: admin:write
PUT/api/v1/admin/plans/:id

Update a plan.

Scope: admin:write
DELETE/api/v1/admin/plans/:id

Delete a plan (fails if subscribers exist).

Scope: admin:write
GET/api/v1/admin/billing-accounts

Search billing accounts.

Scope: admin:read
PUT/api/v1/admin/billing-accounts/:id/plan

Override a billing account's plan.

Scope: admin:write
{ "plan_id": "..." }
GET/api/v1/admin/branding

Get the current reseller's branding configuration.

Scope: admin:read
PUT/api/v1/admin/branding

Update reseller branding (name, colors, links, etc).

Scope: admin:write
POST/api/v1/admin/branding/logo

Upload reseller logo. Max 2 MB.

Scope: admin:write
POST/api/v1/admin/branding/favicon

Upload reseller favicon.

Scope: admin:write
DELETE/api/v1/admin/branding/logo

Remove reseller logo.

Scope: admin:write
DELETE/api/v1/admin/branding/favicon

Remove reseller favicon.

Scope: admin:write
GET/api/v1/admin/system

System health: D1 row count, active deployments.

Scope: admin:read
GET/api/v1/admin/resellers

List all resellers (super_admin only).

Scope: admin:read
POST/api/v1/admin/resellers

Provision a new reseller (super_admin only).

Scope: admin:write
POST/api/v1/admin/backfill/app-dns

Backfill wildcard DNS records for all projects.

Scope: admin:write
POST/api/v1/admin/setup-stripe-webhook

Create a Stripe webhook endpoint for this panel.

Scope: admin:write

Error codes

The API returns standard HTTP codes with a JSON body of the form { "error": "..." }.

StatusMeaning
400Validation failed (missing/invalid field)
401Missing or invalid Bearer token
402Plan limit reached (deployment count, custom domains, requests)
403Forbidden — insufficient scope, project restriction, or role
404Resource not found
409Conflict (e.g. slug already taken, already a member)
410Gone (e.g. expired invite)
413Payload too large (e.g. logo upload > 2 MB)
415Unsupported media type (e.g. invalid image type)
426Upgrade required (WebSocket endpoint called without upgrade)
500Internal error — safe to retry with backoff
503Service unavailable (e.g. containers not enabled)

When a scope is missing, the response also includes a required_scope field:

{ "error": "insufficient scope: requires 'deployments:write'", "required_scope": "deployments:write" }

Rate limits

The API does not impose hard rate limits today, but abusive patterns (more than ~100 requests/second per key) may be throttled. Deploy endpoints perform real work against the edge runtime and should not be called in tight loops.

Code examples

List your projects

curl https://cirrus-host.com/api/v1/projects \
  -H "Authorization: Bearer $HELIX_TOKEN"

Create a project

curl -X POST https://cirrus-host.com/api/v1/projects \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"my-api","name":"My API","description":"Backend services"}'

Deploy a worker

# Build first
esbuild src/worker.ts --bundle --format=esm --outfile=dist/worker.js

# Upload
curl -X POST https://cirrus-host.com/api/v1/projects/my-api/deployments \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -F "bundle=@dist/worker.js" \
  -F 'metadata={"name":"api","message":"v1.2.0","compatibility_date":"2025-04-01"}'

Set an environment variable

curl -X POST https://cirrus-host.com/api/v1/deployments/$DEP_ID/env \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"DATABASE_URL","value":"postgres://..."}'

Create a scoped API key

curl -X POST https://cirrus-host.com/api/v1/api-keys \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CI Deploy",
    "scopes": ["deployments:write", "projects:read"],
    "allowed_projects": ["my-api"],
    "expires_in_days": 90
  }'

Provision a database and bind it

# Create the database
curl -X POST https://cirrus-host.com/api/v1/projects/my-api/resources \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"database","name":"primary"}'

# Bind it to a deployment
curl -X POST https://cirrus-host.com/api/v1/deployments/$DEP_ID/bindings \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"resource_id":"RESOURCE_ID","name":"DB"}'

Query a database

curl -X POST https://cirrus-host.com/api/v1/resources/$RES_ID/database/query \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sql":"SELECT * FROM users WHERE email = ?","params":["alice@example.com"]}'

Get deployment metrics

curl "https://cirrus-host.com/api/v1/deployments/$DEP_ID/metrics?since=7d" \
  -H "Authorization: Bearer $HELIX_TOKEN"

Stream logs (WebSocket)

websocat "https://cirrus-host.com/api/v1/deployments/$DEP_ID/tail" \
  -H "Authorization: Bearer $HELIX_TOKEN"

Add a custom domain

curl -X POST https://cirrus-host.com/api/v1/deployments/$DEP_ID/domains \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hostname":"api.example.com"}'

Helper function

const API = "https://cirrus-host.com/api/v1";
const TOKEN = process.env.HELIX_TOKEN;

async function api(path, opts = {}) {
  const res = await fetch(API + path, {
    ...opts,
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      "Content-Type": "application/json",
      ...opts.headers,
    },
    body: opts.body ? JSON.stringify(opts.body) : undefined,
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();
}

List projects

const projects = await api("/projects");
console.log(projects);

Create a project and deploy

// Create project
const project = await api("/projects", {
  method: "POST",
  body: { slug: "my-api", name: "My API" },
});

// Deploy (multipart)
const form = new FormData();
form.append("bundle", new Blob([bundleCode], { type: "application/javascript" }));
form.append("metadata", JSON.stringify({
  name: "api",
  message: "initial deploy",
  compatibility_date: "2025-04-01",
}));

const deploy = await fetch(`${API}/projects/my-api/deployments`, {
  method: "POST",
  headers: { Authorization: `Bearer ${TOKEN}` },
  body: form,
}).then(r => r.json());

console.log("Live at:", deploy.url);

Provision resources and add bindings

// Create a database
const db = await api("/projects/my-api/resources", {
  method: "POST",
  body: { type: "database", name: "primary" },
});

// Create a KV store
const kv = await api("/projects/my-api/resources", {
  method: "POST",
  body: { type: "kv", name: "cache" },
});

// Bind both to a deployment
await api(`/deployments/${deployId}/bindings`, {
  method: "POST",
  body: { resource_id: db.id, name: "DB" },
});
await api(`/deployments/${deployId}/bindings`, {
  method: "POST",
  body: { resource_id: kv.id, name: "CACHE" },
});

Create a scoped API key

const key = await api("/api-keys", {
  method: "POST",
  body: {
    name: "CI Deploy",
    scopes: ["deployments:write", "projects:read", "env:write"],
    allowed_projects: ["my-api"],
    expires_in_days: 90,
  },
});
console.log("Secret (store this!):", key.secret);

Monitor metrics

const metrics = await api(`/deployments/${depId}/metrics?since=24h`);
console.log("Requests:", metrics.total_requests);
console.log("p99 CPU:", metrics.p99_cpu_ms, "ms");

Helper function

import os, requests

API = "https://cirrus-host.com/api/v1"
TOKEN = os.environ["HELIX_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

def api(method, path, **kwargs):
    r = requests.request(method, API + path, headers=HEADERS, **kwargs)
    r.raise_for_status()
    return r.json()

List projects

projects = api("GET", "/projects")
for p in projects:
    print(f"{p['slug']} — {p['name']} ({p['deployment_count']} deployments)")

Create a project and deploy

# Create project
project = api("POST", "/projects", json={"slug": "my-api", "name": "My API"})

# Deploy
with open("dist/worker.js", "rb") as f:
    r = requests.post(
        f"{API}/projects/my-api/deployments",
        headers={"Authorization": f"Bearer {TOKEN}"},
        files={"bundle": ("worker.js", f)},
        data={"metadata": '{"name":"api","message":"v1.0"}'},
    )
    deploy = r.json()
    print("Live at:", deploy["url"])

Provision and bind a database

# Create database
db = api("POST", "/projects/my-api/resources", json={"type": "database", "name": "primary"})

# Bind to deployment
api("POST", f"/deployments/{dep_id}/bindings", json={"resource_id": db["id"], "name": "DB"})

# Query it
result = api("POST", f"/resources/{db['id']}/database/query",
             json={"sql": "SELECT COUNT(*) as n FROM users"})
print("Users:", result["result"])

Create a scoped API key

key = api("POST", "/api-keys", json={
    "name": "CI Deploy",
    "scopes": ["deployments:write", "projects:read"],
    "allowed_projects": ["my-api"],
    "expires_in_days": 90,
})
print("Secret:", key["secret"])

Usage monitoring

usage = api("GET", "/usage")
print(f"Requests: {usage['period_requests']:,} / {usage['included_requests']:,}")
print(f"CPU: {usage['period_cpu_ms']:,}ms / {usage['included_cpu_ms']:,}ms")
for day in usage["daily"][-7:]:
    print(f"  {day['date']}: {day['requests']:,} req, {day['errors']} err")

Helper

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
)

var (
    apiBase = "https://cirrus-host.com/api/v1"
    token   = os.Getenv("HELIX_TOKEN")
)

func api(method, path string, body any) (map[string]any, error) {
    var r io.Reader
    if body != nil {
        b, _ := json.Marshal(body)
        r = bytes.NewReader(b)
    }
    req, _ := http.NewRequest(method, apiBase+path, r)
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    var result map[string]any
    json.NewDecoder(resp.Body).Decode(&result)
    return result, nil
}

List projects

projects, _ := api("GET", "/projects", nil)
fmt.Println(projects)

Create a scoped API key

key, _ := api("POST", "/api-keys", map[string]any{
    "name":             "CI Deploy",
    "scopes":           []string{"deployments:write", "projects:read"},
    "allowed_projects": []string{"my-api"},
    "expires_in_days":  90,
})
fmt.Println("Secret:", key["secret"])

Guides

Building a full-stack app with a database

This guide walks through creating a project, provisioning a database, deploying a worker, and binding them together.

# 1. Create the project
helix init my-project/api
cd my-project/api

# 2. Provision a database
helix db create primary

# 3. Add the binding to helix.json
# "bindings": [{ "type": "db", "name": "DB", "resource": "primary" }]

# 4. Write your worker
cat <<'EOF' > src/worker.ts
export default {
  async fetch(request: Request, env: any) {
    // Initialize tables on first request
    await env.DB.exec(`
      CREATE TABLE IF NOT EXISTS visits (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        path TEXT, visited_at TEXT DEFAULT (datetime('now'))
      )
    `);

    const url = new URL(request.url);
    await env.DB.prepare("INSERT INTO visits (path) VALUES (?)").bind(url.pathname).run();

    const { results } = await env.DB.prepare(
      "SELECT path, COUNT(*) as count FROM visits GROUP BY path ORDER BY count DESC LIMIT 10"
    ).all();

    return Response.json({ top_pages: results });
  }
};
EOF

# 5. Deploy
helix deploy

Building an app with object storage

# Provision a bucket
helix bucket create uploads

# helix.json bindings:
# { "type": "bucket", "name": "UPLOADS", "resource": "uploads" }

# In your worker:
export default {
  async fetch(request: Request, env: any) {
    const url = new URL(request.url);

    if (request.method === "PUT") {
      const key = url.pathname.slice(1);
      await env.UPLOADS.put(key, request.body, {
        httpMetadata: { contentType: request.headers.get("content-type") || "application/octet-stream" },
      });
      return Response.json({ ok: true, key });
    }

    if (request.method === "GET") {
      const key = url.pathname.slice(1);
      const obj = await env.UPLOADS.get(key);
      if (!obj) return new Response("Not found", { status: 404 });
      return new Response(obj.body, {
        headers: { "Content-Type": obj.httpMetadata?.contentType || "application/octet-stream" },
      });
    }

    return new Response("Method not allowed", { status: 405 });
  }
};

Building an app with KV caching

# Provision a KV store
helix kv create cache

# helix.json bindings:
# { "type": "kv", "name": "CACHE", "resource": "cache" }

# In your worker:
export default {
  async fetch(request: Request, env: any) {
    const url = new URL(request.url);
    const cacheKey = `page:${url.pathname}`;

    // Check cache first
    const cached = await env.CACHE.get(cacheKey);
    if (cached) {
      return new Response(cached, {
        headers: { "Content-Type": "text/html", "X-Cache": "HIT" },
      });
    }

    // Generate response
    const html = `<h1>Hello from ${url.pathname}</h1><p>Generated at ${new Date().toISOString()}</p>`;

    // Cache for 5 minutes
    await env.CACHE.put(cacheKey, html, { expirationTtl: 300 });

    return new Response(html, {
      headers: { "Content-Type": "text/html", "X-Cache": "MISS" },
    });
  }
};

CI/CD with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - name: Deploy to Cirrus Host
        env:
          HELIX_TOKEN: ${{ secrets.HELIX_TOKEN }}
        run: |
          curl -X POST https://cirrus-host.com/api/v1/projects/my-api/deployments \
            -H "Authorization: Bearer $HELIX_TOKEN" \
            -F "bundle=@dist/worker.js" \
            -F 'metadata={"name":"api","message":"'"$GITHUB_SHA"'"}'
Tip: Create a scoped API key with only deployments:write and projects:read, restricted to your project slug. This way a leaked CI secret can only deploy to that one project.
curl -X POST https://cirrus-host.com/api/v1/api-keys \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"GitHub Actions","scopes":["deployments:write","projects:read"],"allowed_projects":["my-api"]}'

Deploying a container

# Deploy a Ghost CMS container
curl -X POST https://cirrus-host.com/api/v1/projects/my-blog/deployments/container \
  -H "Authorization: Bearer $HELIX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "ghost",
    "image": "ghost:5",
    "port": 2368,
    "size": "shared-cpu-1x@1024mb",
    "env": {
      "url": "https://ghost.my-blog.helx.dev",
      "database__client": "sqlite3"
    }
  }'