Skip to content

HTTP API

The control plane exposes a JSON API at https://apco.space/api/v1 in the hosted installation. The CLI, MCP server, and dashboard all use these routes — anything they can do, you can do directly over HTTP. Self-hosters can configure a distinct control-plane origin.

Conventions

Authentication

Every route authenticates through a single choke point (getApiUser), which accepts either:

MethodHeaderAccess
API keyx-api-key: <key>Scopes from the key: a key with no scopes is full access; a scoped key is limited to its scopes
SessionCookie (dashboard) or Authorization: Bearer <session token>Always full access

Banned users resolve to no user on both paths (401). Keys are created via apco login (full access) or POST /tokens / the dashboard Tokens page (scoped).

Response envelope

Success responses are { "ok": true, ...payload }. Errors are:

json
{ "ok": false, "error": { "code": "QUOTA_EXCEEDED", "message": "Project limit reached for your plan." } }

See error codes for the full catalog with statuses. Asynchronous actions return 202 with a jobId and/or deploymentId/databaseId to poll.

Project and deployment url fields are authoritative. They may use a content domain distinct from the control-plane/API hostname, so clients must not derive app hosts from the request origin. During a domain migration, project payloads also include legacyUrl; it is omitted when canonical and legacy domains are the same. The examples below show the hosted installation's current defaults.

Access levels

  • Scope — allowed for scoped API keys carrying that scope (and all full-access callers).
  • Full access — sessions and permissionless keys only; scoped keys get 403 FORBIDDEN_SCOPE.
  • Session — dashboard/bearer sessions only; API keys get 403 SESSION_REQUIRED.
  • Adminadmin role and full access (scoped keys can never reach admin routes).

Route summary

Method + pathPurposeAccess
GET /meAuthenticated user + token scopesany valid credential except setup-only ci:deploy
GET /accountAccount security summaryfull access
POST /account/set-passwordSet a password on a GitHub-only accountfull access (session semantics)
GET /account/usagePlan capabilities + current usagefull access
GET /github/reposLinked GitHub account's reposfull access
POST /tokensCreate a scoped API tokensession only
GET /projectsList projectsprojects:read
POST /projectsCreate a projectprojects:write
GET /projects/:idProject + recent deploymentsprojects:read
PUT /projects/:idQueue slug rename (202)projects:write
PATCH /projects/:idRepo/Actions settings, deploy mode, and/or password protectionprojects:write
DELETE /projects/:idDelete projectprojects:write
POST /projects/:id/deploymentsDeploy source (multipart) or OCI image (JSON) (202)deploy, or exact one-project ci:deploy
GET /projects/:id/registry-credentialsOCI credential host/timestamp metadata onlyprojects:read
PUT /projects/:id/registry-credentialsCreate/rotate exact-host OCI pull credentialprojects:write
DELETE /projects/:id/registry-credentialsDelete exact-host credential with {confirm:true}projects:write
GET /projects/:id/envRead env vars (values included)env:read
PUT /projects/:id/envReplace env varsenv:write
GET /projects/:id/metrics?span=Request/traffic seriesmetrics:read
GET /projects/:id/resources?span=CPU/RAM series + disk + plan limitsmetrics:read
GET /projects/:id/analytics?span=Top paths/referrers/countries/devices/browsers/OS, statuses, visitorsmetrics:read
GET /projects/:id/runtime-logs?lines=Running container logslogs:read
POST /projects/:id/stopStop the running deployment (202)lifecycle
POST /projects/:id/startStart a stopped deployment (202)lifecycle
GET /projects/:id/jobs?channel=&deployment=Safe scheduled-job definitions + plan limitsjobs:read
PATCH /projects/:id/jobs/:jobIdSet durable {enabled} overridejobs:write
POST /projects/:id/jobs/:jobId/runQueue a manual logical run (202)jobs:write
GET /projects/:id/job-runsCursor-paginated safe run historyjobs:read
GET /projects/:id/job-runs/:runIdLogical run + safe attempt metadatajobs:read
GET /projects/:id/job-runs/:runId/logsRedacted attempt logsjobs:read + logs:read
POST /projects/:id/job-runs/:runId/cancelIdempotent cancellation requestjobs:write
GET /deployments/:idDeployment detailprojects:read, or the ci:deploy key that created it
GET /deployments/:id/logs?after=Build log (offset-based)logs:read, or the ci:deploy key that created it
GET /deployments/:id/screenshotCaptured PNG of the live siteprojects:read
POST /deployments/:id/screenshotRequest a fresh screenshot (202)projects:write
POST /deployments/:id/rollbackRoll back to this deployment (202)deploy
POST /deployments/:id/promoteCode-only Preview artifact → Production (202)deploy
POST /projects/:id/database?channel=Enable one empty channel database (202)projects:write (Pro gate)
GET /projects/:id/database?channel=One branch status (no secrets)projects:read
GET /projects/:id/database/branchesOrdered Production/Preview/Dev statusesprojects:read (no plan gate)
DELETE /projects/:id/database?channel=Drop one branch (202)projects:write (no plan gate)
POST /projects/:id/database/dev-credentials?channel=Selected branch DATABASE_URLdatabase:credentials or projects:write (Pro gate)
POST /projects/:id/database/query?channel=Selected-branch SQL, optional {rollback} dry rundatabase:query or projects:write (Pro gate)
GET /projects/:id/database/schemaTable browser introspection (tables, views, columns, indexes, foreign keys)projects:read (Pro gate)
GET /projects/:id/database/tables/:table/rowsPaginated table rowsprojects:read (Pro gate)
POST /projects/:id/database/tables/:table/rowsInsert a row {values}projects:write (Pro gate)
PATCH /projects/:id/database/tables/:table/rowsUpdate a row {pk, changes}projects:write (Pro gate)
DELETE /projects/:id/database/tables/:table/rowsDelete a row {pk}projects:write (Pro gate)
GET /projects/:id/database/tables/:table/countExact row countprojects:read (Pro gate)
GET /projects/:id/database/migrationsApplied-migrations history (_apco_migrations)projects:read (Pro gate)
GET /projects/:id/database/usageLive size, read-only state, connections, size historyprojects:read (Pro gate)
GET /projects/:id/database/saved-queriesList saved SQL snippetsprojects:read (Pro gate)
POST /projects/:id/database/saved-queriesSave a named SQL snippet {name, sql}projects:write (Pro gate)
DELETE /projects/:id/database/saved-queries/:queryIdRemove a saved queryprojects:write (no plan gate)
GET /projects/:id/database/backups?channel=Selected-channel backups; allChannels=true lists allprojects:read (no plan gate)
GET /projects/:id/database/backups/:backupIdNon-secret metadata by authoritative IDprojects:read (no plan gate)
POST /projects/:id/database/backups?channel=Queue selected-branch backup (202)projects:write (Pro gate)
DELETE /projects/:id/database/backups/:backupIdRemove a backup's metadataprojects:write (no plan gate)
GET /projects/:id/database/backups/:backupId/downloadStream the gzipped SQL dumpprojects:read (no plan gate)
POST /projects/:id/database/rotate-credentials?channel=Rotate one branch password (202)projects:write (Pro gate)
GET /projects/:id/domainsList custom domains + DNS records + routingTargetprojects:read
POST /projects/:id/domainsAdd custom domain {domain} (202)projects:write (Pro gate)
PATCH /projects/:id/domains/:domainIdSet {routingTarget: "production"|"preview"|"dev"|"disabled"} (202)projects:write (Preview/Dev plan gates)
POST /projects/:id/domains/:domainId/verifyRe-run DNS verification (202)projects:write
DELETE /projects/:id/domains/:domainIdDetach custom domain (202)projects:write (no plan gate)
GET/POST /admin/plans, PUT/DELETE /admin/plans/:idPlans CRUD (incl. dbConnectionLimit, maxBackups)admin
GET /admin/users, DELETE /admin/users/:idUser list / deleteadmin
POST /admin/users/:id/{ban,unban,role,plan}Moderation + role/planadmin
GET /admin/projects, DELETE /admin/projects/:idAll projects / deleteadmin
POST /admin/projects/:id/{suspend,resume}Suspend / resume (202)admin
GET /admin/databasesOverview of every managed databaseadmin
GET /admin/stats, GET /admin/series?days=Platform stats / per-day seriesadmin
GET /admin/waitlist, POST /admin/invitesWaitlist / invitesadmin
POST /waitlistJoin the waitlistpublic (no auth)

Identity & account

GET /me

Identity introspection (CLI whoami). Ordinary scopes are exempt from a dedicated read scope, but setup-only ci:deploy is denied because its contract excludes account identity.

json
{ "ok": true,
  "user": { "id": "6f0f3c1e-...", "email": "dev@example.com", "name": "Dev", "plan": "pro" },
  "scopes": null, "projectIds": null }

scopes is null for full access, or the scoped token's grant list; projectIds is null for unrestricted project access or the token's sorted allowlist.

GET /account

Account-security summary: { ok, hasPassword, emailVerified, linkedProviders } (e.g. linkedProviders: ["github"]). Never returns hashes or provider tokens.

POST /account/set-password

Body { "newPassword": "<8-128 chars>" }. Sets a password on a GitHub-only account. 409 PASSWORD_ALREADY_SET when a credential account already exists (use change-password via the auth flow instead).

GET /account/usage

The plan + usage endpoint that feature gates are built on — clients must read capabilities from here instead of hardcoding plan slugs.

json
{ "ok": true,
  "plan": {
    "slug": "pro", "name": "Pro", "isDefault": false,
    "memoryMb": 1024, "cpuMillicores": 1000, "pidsLimit": 256,
    "maxProjects": 10, "maxDeploysPerHour": 30, "maxUploadMb": 100,
    "maxImageMb": 1024, "maxEnvVars": 50,
    "rateLimitPerMinute": null, "monthlyRequests": null, "monthlyBandwidthMb": null,
    "storageMb": 4096, "dbSizeMb": 512, "dbConnectionLimit": 10,
    "maxDatabases": 3, "maxBackups": 5, "maxSseConnections": 100,
    "devSyncEnabled": true, "databaseEnabled": true,
    "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z"
  },
  "usage": {
    "projects": 4, "monthlyRequests": 12894, "monthlyBandwidthMb": 512,
    "diskBytes": 734003200, "deploysLastHour": 2
  } }

plan is the full plans-table row minus its internal id, plus the derived databaseEnabled (maxDatabases > 0). Monthly windows are the current UTC calendar month.

GET /github/repos

Lists the linked GitHub account's repos. 400 GITHUB_NOT_CONNECTED when no GitHub account is linked; 400 GITHUB_TOKEN_INVALID when the token is stale.

Tokens

POST /tokens

Session-only — API-key callers get 403 SESSION_REQUIRED (a key cannot mint further keys). Body:

json
{ "name": "monitor", "expiresIn": 2592000,
  "scopes": ["projects:read", "logs:read"], "projectIds": ["d2c1a9b4-..."] }
  • name: 1–64 chars.
  • expiresIn (optional): seconds until expiry; omit for no expiry.
  • scopes (optional): non-empty subset of the scope catalog; omit entirely for a full-access token. An empty array or unknown scope is a 422 VALIDATION error.
  • projectIds (optional): 1–25 project UUIDs owned by the caller; omit for all projects.
  • ci:deploy is setup-only: it must be the only scope and projectIds must contain exactly one id. The project GitHub Actions card creates this shape.

Response (201) — the plaintext key is returned exactly once:

json
{ "ok": true,
  "token": { "id": "kt_...", "name": "monitor", "start": "apco_",
             "key": "apco_XXXX...", "scopes": ["projects:read", "logs:read"],
             "projectIds": ["d2c1a9b4-..."],
             "expiresAt": "2026-08-06T12:00:00.000Z", "createdAt": "2026-07-07T12:00:00.000Z" } }

Projects

GET /projects

Lists the caller's projects, newest first:

json
{ "ok": true, "projects": [
  { "id": "d2c1a9b4-...", "name": "my-app", "slug": "my-app", "type": "ssr",
    "url": "https://my-app.apco.space",
    "framework": "next", "repoUrl": null, "repoId": null, "status": "running",
    "suspendedAt": null, "suspendReason": null, "passwordProtected": false,
    "createdAt": "...", "updatedAt": "..." } ] }

status is the latest deployment's status (null when never deployed).

POST /projects

Body { "name": string, "slug"?: string, "type": "static" | "ssr" | "dockerimage" }. slug: "auto" or absent means "generate one from the name". Type is immutable. Returns 201 { project: { id, name, slug, url, legacyUrl?, type, createdAt, updatedAt } }.

Errors: 422 VALIDATION (bad slug pattern, or a reserved slug—platform names and the dev-/preview- prefixes), 403 PLAN_FEATURE_UNAVAILABLE (dockerimage with maxImageMb: 0), 403 QUOTA_EXCEEDED (plan's maxProjects), 409 SLUG_CONFLICT / CONFLICT.

GET /projects/:id

Project detail plus a merged deployment window: the 10 newest production rows and the 3 newest dev rows, ordered newest first (production rows are always present even under heavy dev-sync churn).

json
{ "ok": true,
  "project": { "id": "...", "name": "...", "slug": "...", "type": "ssr", "framework": "...",
               "url": "https://my-app.apco.space",
               "repoUrl": null, "repoId": null, "productionBranch": "main",
               "suspendedAt": null, "suspendReason": null,
               "passwordProtected": false, "createdAt": "...", "updatedAt": "..." },
  "deployments": [
    { "id": "...", "url": "https://my-app.apco.space", "status": "running", "channel": "production", "imageRef": "...",
      "staticPath": null, "sourceSize": 184320, "error": null,
      "trigger": "github_actions", "gitSha": "0123456789abcdef...",
      "gitRef": "refs/heads/main", "gitRepository": "me/app", "gitPrNumber": null,
      "release": { "configured": true, "status": "succeeded", "startedAt": "...",
        "finishedAt": "...", "exitCode": 0, "error": null, "skippedReason": null },
      "createdAt": "...", "startedAt": "...", "finishedAt": "..." } ] }

Deployment status values: queued, building, deploying, running, failed, superseded, canceled, stopped. channel is production, preview, or dev.

Deployment release.status values are not_configured, pending, running, succeeded, failed, and skipped. release.error and release.skippedReason are bounded operational summaries, not command output; use the deployment log for redacted [release] output.

For dockerimage, deployment rows contain artifact: {kind: "oci", requestedReference, digest, os, architecture} and omit source provenance/size. The digest is null only before pull/inspection completes. Registry credentials are never returned.

PUT /projects/:id

Queue a zero-downtime slug rename. Body { "slug": "<new-slug>" } (3–30 chars [a-z0-9-], not reserved). Returns 202 { ok, queued: true, newSlug }; poll GET /projects/:id until project.slug flips. Errors: 403 SUSPENDED, 422 VALIDATION (bad/identical slug), 409 SLUG_CONFLICT.

PATCH /projects/:id

Update settings — at least one supported field must be present:

json
{ "repoUrl": "https://github.com/me/app", "repoId": "123",
  "productionBranch": "main", "password": "hunter22" }
  • password: string protects the site with a branded password page (bcrypt, first 72 bytes); null clears it. Takes effect without a redeploy; changing or clearing the password invalidates all existing visitor sessions, since the session cookie is derived from the password hash.
  • repoUrl / repoId: link (string) or unlink (null) a GitHub repo.
  • productionBranch: 1–255 character Git branch used by generated Actions workflows; configuration only, not authorization.
  • deployMode: "auto" or "preview"; controls the channel only when a deploy request does not force one.

Returns { ok, project } (same shape as GET).

DELETE /projects/:id

Deletes the project and everything under it (deployments, env vars, metrics via cascade), queues the managed-database drop first when one exists, and fires container/route teardown. Returns { ok, id }.

POST /projects/:id/deployments

Create a deployment. The content type must match the immutable project type.

Source (static/ssr) uses multipart/form-data with two required fields and one optional field:

  • manifest — the normalized manifest snapshot as a JSON string (the CLI sends its resolved apco.yml; optional channel may force production, preview, or dev; omission follows the project's deploy mode).
  • source — the gzipped tar archive (source.tar.gz).
  • metadata (optional) — strict JSON string with trigger, gitSha, gitRef, gitRepository, and gitPrNumber. It is source attribution only and never grants access or selects a channel.

dockerimage uses application/json:

json
{ "manifest": {
    "version": 2, "name": "image-app", "type": "dockerimage", "port": 8080,
    "image": { "reference": "registry-1.docker.io/nginxinc/nginx-unprivileged:1.29-alpine" },
    "health": { "path": "/", "timeoutSeconds": 60 },
    "channel": "preview" },
  "metadata": { "trigger": "github_actions" } }

There is no source field or upload side effect. Multipart against an OCI project and JSON against a source project are rejected. OCI manifests cannot select Dev.

The dashboard may additionally send preserveManifestFromDeploymentId with one exact deployment id from the same project. APCO rehydrates advanced OCI settings (database, release, jobs, and future fields) server-side before applying the dashboard-owned image/port/health changes. Executable commands remain internal and are never returned to the browser. Direct API and CLI deploys omit this field and remain authoritative replacements.

Returns 202 with the resolved target (extra fields are backward-compatible):

json
{ "ok": true, "deploymentId": "8a41f3aa-...", "channel": "preview",
  "url": "https://preview-my-app-8a41f3aa.apco.space",
  "project": { "id": "d2c1a9b4-...", "slug": "my-app", "type": "ssr" },
  "source": { "rootDirectory": "apps/web", "manifestPath": "apco.yml",
    "watchPaths": ["apps/web/**", "packages/shared/**"] },
  "release": { "configured": true, "status": "pending", "startedAt": null,
    "finishedAt": null, "exitCode": null, "error": null, "skippedReason": null } }

Poll GET /deployments/:id. A ci:deploy key may poll/read build logs only for deployments whose row was created by that same key. If the manifest enables a database, direct CI deployment requires it to be ready; CI cannot provision it. release is a safe execution summary only; the command and environment values are never serialized.

OCI acceptance returns artifact with the canonical requested reference and null digest/platform fields; polling fills them after pull/inspection. It also enforces maxImageMb > 0, explicit tag/digest syntax, public registry DNS, and Production/Preview plan gates before queueing.

Project status and deployment detail responses include the same normalized source provenance. Their optional manifest read model is command-free: build/start/release/job/dev commands and env-file configuration are omitted, while release configuration and job schedule metadata remain inspectable. GitHub App binding create/update accepts rootDirectory and watchPaths; paths use the manifest's traversal-safe normalization.

When the cluster scheduler feature flag is enabled, project/deployment reads may additionally return portableArtifact and placement. These are topology-safe summaries: artifact kind/state/digest/size/platform and placement state/route convergence/last transition. Node id/address, reservations, task payloads, leases, registry repository internals, and credential material never enter project or CI responses. Admin-only /admin/nodes and /admin/nodes/:id expose the safe fleet/detail views.

Errors: 403 SUSPENDED, 403 EMAIL_NOT_VERIFIED, 403 PREVIEW_UNAVAILABLE, 403 PLAN_FEATURE_UNAVAILABLE, 409 DATABASE_NOT_READY, 429 RATE_LIMITED (plan's maxDeploysPerHour, per user across all projects), 413 UPLOAD_TOO_LARGE (source only), 422 VALIDATION / OCI_REFERENCE_INVALID; for channel=dev: 403 PLAN_FEATURE_UNAVAILABLE (plan lacks devSyncEnabled) or 422 VALIDATION (static/OCI project).

GET /projects/:id/registry-credentials

For a dockerimage project, returns {credentials: [{host, configured: true, createdAt, updatedAt}]}. It never selects or returns username, password/token, or encrypted columns.

PUT /projects/:id/registry-credentials

Body {host, username, secret} creates or atomically rotates the exact normalized host. The host must pass the same public-DNS policy as an image reference. At most five credentials may exist per project. Returns {credential: {host, configured, createdAt, updatedAt}, rotated}; input secret fields are never echoed. Create/rotate is audit logged with host only.

DELETE /projects/:id/registry-credentials

Body {host, confirm: true} deletes one exact normalized host and records a host-only audit event. Missing confirmation returns DESTRUCTIVE_CONFIRMATION_REQUIRED; a missing host returns NOT_FOUND.

GET /projects/:id/env / PUT /projects/:id/env

  • GET{ ok, vars: { "KEY": "value", ... } } — decrypted values included (scope env:read).
  • PUT body { "vars": { "KEY": "value", ... } }replaces the project's entire env atomically (scope env:write). Returns { ok, keys: [...] }. 422 QUOTA_EXCEEDED over the plan's maxEnvVars. Redeploy for changes to reach running SSR containers.

GET /projects/:id/metrics?span=

Request/traffic series. span is one of 1h, 24h, 3d, 7d (hourly buckets), 90d, all (daily buckets); default 7d. A legacy hours=N param (1–720, hourly) is supported; span wins when both are present.

json
{ "ok": true, "metrics": {
  "bucketSize": "hour",
  "totalRequests": 1289, "totalBytesIn": 102400, "totalBytesOut": 20480000,
  "totalStatuses": { "s2xx": 1200, "s3xx": 50, "s4xx": 30, "s5xx": 9 },
  "series": [ { "bucket": "2026-07-07T09:00:00.000Z", "count": 42, "bytesIn": 2048,
                "bytesOut": 409600, "s2xx": 40, "s3xx": 1, "s4xx": 1, "s5xx": 0 } ] } }

The series is sparse — clients fill bucket gaps.

GET /projects/:id/resources?span=

Container CPU/RAM series (same span grammar), latest sample, per-project disk usage, and the owner's plan limits:

json
{ "ok": true, "resources": {
  "bucketSize": "hour",
  "series": [ { "bucket": "...", "cpuPctAvg": 3.1, "cpuPctMax": 12.4,
                "memBytesAvg": 104857600, "memBytesMax": 134217728, "samples": 60 } ],
  "current": { "bucket": "...", "cpuPctAvg": 2.8, "cpuPctMax": 9.9,
               "memBytesAvg": 100000000, "memBytesMax": 120000000, "samples": 60 },
  "disk": { "staticBytes": 0, "imageBytes": 734003200 },
  "limits": { "memoryMb": 1024, "cpuMillicores": 1000, "pidsLimit": 256, "storageMb": 4096 } } }

GET /projects/:id/analytics?span=

Web analytics (same span grammar; top lists capped at 25):

json
{ "ok": true, "analytics": {
  "paths": [ { "value": "/", "count": 812 } ],
  "referrers": [ { "value": "news.ycombinator.com", "count": 120 } ],
  "countries": [ { "value": "US", "count": 400 }, { "value": "DE", "count": 120 } ],
  "devices": [ { "value": "desktop", "count": 600 }, { "value": "mobile", "count": 300 } ],
  "browsers": [ { "value": "Chrome", "count": 500 }, { "value": "Safari", "count": 200 } ],
  "operatingSystems": [ { "value": "Windows", "count": 350 }, { "value": "Mac OS", "count": 250 } ],
  "statuses": { "s2xx": 1200, "s3xx": 50, "s4xx": 30, "s5xx": 9 },
  "visitors": [ { "day": "2026-07-07", "count": 87 } ] } }

Country values are ISO 3166-1 alpha-2 codes (requires GeoLite2-Country.mmdb on the worker). Device types: desktop, mobile, tablet, bot, plus rare device types from ua-parser-js. Bot traffic (Googlebot, GPTBot, etc.) is classified as device bot with browser/OS (bot).

Visitor counts are approximate daily uniques (salted-hash based; the hash is never exposed).

GET /projects/:id/runtime-logs?lines=

Running container's logs, proxied from the worker. lines clamps to 1–2000 (default 200).

Scheduled jobs

Definitions are created by activating an SSR deployment whose normalized manifest contains jobs. They are scoped to the exact active Production, Dev, legacy Preview, or GitHub PR Preview artifact. Serializers omit commands, image references, environments, and container identity.

GET /projects/:id/jobs accepts channel=production|preview|dev (Production default) and an optional exact deployment UUID. A bare Preview query returns the stable legacy Preview scope only; use an exact deployment for a PR Preview. The response contains {jobs, limits} with safe schedule settings, exact scope/deployment provenance, effective/source enable flags, pause reason, timestamps, and a safe latest run.

PATCH /projects/:id/jobs/:jobId accepts { "enabled": true|false }. The user override survives later deployments of the same scope/name. Enabling cannot override enabled: false in the manifest or a platform pause.

POST /projects/:id/jobs/:jobId/run returns 202 {run} after committing a manual logical run. The server rechecks suspension, plan access, artifact state, and exact active route pointer under the project lock. Manual execution is allowed while automatic scheduling is disabled.

GET /projects/:id/job-runs filters by optional channel, deployment, status, and name, plus limit (1–100) and opaque cursor. It returns {runs, nextCursor} newest first. GET .../:runId adds safe attempt metadata. GET .../:runId/logs?attempt= adds redacted, 1 MiB-capped attempt output and requires both read scopes. POST .../:runId/cancel is idempotent: queued/waiting work cancels immediately; a running attempt moves through cancel_requested and TERM/KILL handling.

Logical statuses are queued, waiting, running, cancel_requested, succeeded, failed, timed_out, canceled, and skipped. Attempts represent explicit retries beneath one stable logical run id. See Scheduled jobs.

json
{ "ok": true, "log": "2026-07-07T09:01:03Z Listening on :3000\n...", "lines": 200 }

409 RUNTIME_LOGS_UNAVAILABLE when no container is running; 502 when the log service is unreachable.

POST /projects/:id/stop / POST /projects/:id/start

Queue a lifecycle job for the production deployment (dev-channel deployments are never affected). Returns 202 { ok, queued: true, deploymentId, jobId? }; poll GET /projects/:id until the latest production deployment is stopped/running.

Errors: 409 STOP_UNAVAILABLE (nothing running) / START_UNAVAILABLE (nothing stopped); start also 403 SUSPENDED (an admin must resume).

Deployments

GET /deployments/:id

json
{ "ok": true, "deployment": {
  "id": "...", "projectId": "...", "status": "running", "channel": "production",
  "imageRef": "...", "staticPath": null, "sourceSize": 184320, "error": null,
  "trigger": "github_actions", "gitSha": "0123456789abcdef...",
  "gitRef": "refs/heads/main", "gitRepository": "me/app", "gitPrNumber": null,
  "release": { "configured": true, "status": "succeeded",
    "startedAt": "...", "finishedAt": "...", "exitCode": 0,
    "error": null, "skippedReason": null },
  "manifest": { "...": "the deploy-time manifest snapshot" },
  "createdAt": "...", "startedAt": "...", "finishedAt": "..." } }

OCI detail includes the safe artifact object and errorCode; source-only fields are omitted. No registry authentication is ever serialized.

GET /deployments/:id/screenshot / POST /deployments/:id/screenshot

GET returns the captured PNG of the live site (image/png); 404 until a capture has run. POST queues a fresh capture (202 { ok, queued }) — the platform also captures automatically after every production/preview deploy and manual start, and refreshes screenshots older than a day during nightly housekeeping. Captures are rate-limited per deployment (one per 10 minutes): 429 RATE_LIMITED during the cooldown, 409 SCREENSHOT_UNAVAILABLE when the deployment isn't a live production/preview one. Poll GET /deployments/:id for a changed screenshotCapturedAt to know when the new image is ready.

GET /deployments/:id/logs?after=

Offset-based build-log tailing: after is a byte offset into the log; the response returns the slice from there plus the new total offset — pass it back to poll incrementally.

json
{ "ok": true, "log": "[railpack] detected node app\n...", "offset": 5120 }

POST /deployments/:id/rollback

Creates a fresh same-channel deployment that reuses this deployment's immutable artifact. For OCI, only a superseded Production deployment is eligible and the stored digest is used/recovered; tags are not resolved again. Returns 202 { ok, deploymentId } (the new deployment). If the manifest contains a release command, the new row records it as skipped; rollback never replays historical one-off work.

Errors: 403 SUSPENDED; 400/409 ROLLBACK_UNAVAILABLE when the source is not a Production/Dev superseded deployment or its artifact (image / static build) is gone.

POST /deployments/:id/promote

Creates a Production deployment from a running Preview artifact without rebuilding. Promotion never copies, merges, restores, or replaces data: the new container resolves only the Production database, and Preview remains unchanged. If configured, the release command runs once as part of the new Production deployment against Production data before its candidate starts; release failure leaves current Production and the Preview untouched. If the manifest requires a database and Production is missing/unready, the request fails with DATABASE_NOT_READY before creating the Production deployment; it never provisions as a side effect.

Database

Production, Preview, and Dev are isolated physical databases. A project chooses one immutable engine—postgres or mariadb—when its first branch is created; every branch then uses that engine. Existing projects and omitted engine values default to Postgres. Every branch-bound route accepts ?channel=production|preview|dev; omission means Production for compatibility. Invalid or conflicting repeated values return VALIDATION. Backup item routes are the exception: backupId is authoritative, and optional channel only asserts its recorded source.

Database routes are project-scoped and Pro-gated (403 PLAN_FEATURE_UNAVAILABLE when maxDatabases is 0) except cleanup/discovery routes: GET /database/branches, DELETE /database, saved-query deletion, and backup list/metadata/download/delete. Saved queries remain project-scoped rather than channel-scoped.

POST /projects/:id/database?channel=

Idempotently provision one empty branch (Production default). The optional JSON body is { "engine": "postgres" | "mariadb" }; omission defaults to Postgres only when the project has no engine yet and otherwise preserves the fixed choice. Branch creation never clones schema or data:

json
{ "ok": true, "channel": "preview", "engine": "mariadb",
  "databaseId": "5e9d...", "status": "provisioning" }   // 202

maxDatabases counts distinct database-enabled projects. The first branch consumes one allowance; the other two branches for that project are bundled. The quota check is serialized per user and sourced from the plans table. A different later engine returns 409 DATABASE_ENGINE_CONFLICT; a disabled MariaDB rollout returns 503 DATABASE_ENGINE_UNAVAILABLE.

GET /projects/:id/database?channel=

Status only — never returns secrets:

json
{ "ok": true, "channel": "dev", "engine": "mariadb", "enabled": true, "status": "ready",
  "sizeBytes": 1048576, "sizeLimitMb": 512, "connectionLimit": 10 }

When that branch is absent, enabled is false and status is null.

GET /projects/:id/database/branches

Returns the immutable project engine plus three ordered entries—Production, Preview, Dev—including explicit missing states and the engine on each entry. It needs projects:read and remains available after downgrade so users can discover retained branches and clean them up.

DELETE /projects/:id/database?channel=

Queues only the selected branch drop (202 { ok, channel, engine, databaseId, status: "dropping" }). Completed backups survive and the project engine stays fixed even after its final branch is removed. A running channel deployment is not stopped and loses DB access as connections terminate; redeploy/restart after recreation.

POST /projects/:id/database/dev-credentials?channel=

Returns one branch's standing URL; apco dev --db explicitly requests Dev:

json
{ "ok": true, "channel": "dev", "engine": "mariadb",
  "databaseUrl": "mysql://apco_muser_...:...@db.apco.space:3306/apco_mdb_...",
  "expiresAt": null,
  "note": "These are the project's standing database credentials (rotated on re-provision, not time-limited). The database host must be network-reachable from your machine for this URL to work." }

There are no ephemeral roles yet—expiresAt is always null. The server rewrites host/port with APCO_DB_PUBLIC_HOST/APCO_DB_PUBLIC_PORT for Postgres or APCO_MARIADB_PUBLIC_HOST/APCO_MARIADB_PUBLIC_PORT for MariaDB when the operator has intentionally exposed that engine; otherwise the private internal address remains. The database must be network-reachable from the client. Errors: 403 SUSPENDED, 404, 409 DATABASE_PROVISIONING / DATABASE_NOT_READY.

POST /projects/:id/database/query?channel=

Runs SQL as the selected branch's distinct tenant role. Body { "sql": "select * from todos" } (≤ 100 KB), plus optional "rollback": true.

json
{ "ok": true, "channel": "preview", "engine": "postgres",
  "columns": ["id", "text", "done"],
  "rows": [[1, "ship docs", false]],
  "rowCount": 1, "truncated": false, "durationMs": 4, "rolledBack": false }
  • rows are positional arrays, capped at 500 (truncated: true beyond that); rowCount is the driver's count.
  • On Postgres, rollback: true runs the SQL inside a transaction that is always rolled back—the response reflects what the statement(s) would have done, but nothing is persisted. MariaDB rejects this mode with DRY_RUN_UNAVAILABLE because DDL and other statements may commit implicitly; use a disposable Preview/Dev branch for destructive rehearsal.
  • Multi-statement SQL reports the last statement's result.
  • Safe engine errors (syntax, permission, constraints, ...) come back as 400 QUERY_ERROR. Bad request bodies are 400 INVALID_BODY.
  • Never returns the connection URL. 409 DATABASE_PROVISIONING / DATABASE_NOT_READY while not ready.

GET /projects/:id/database/schema?channel=

The Database Studio table browser. Introspects Postgres's public schema or the current MariaDB database. Every response includes channel and engine:

json
{ "ok": true,
  "tables": [
    { "name": "todos", "approxRowCount": 42, "sizeBytes": 81920,
      "columns": [
        { "name": "id", "dataType": "integer", "nullable": false,
          "default": "nextval('todos_id_seq'::regclass)", "isPrimaryKey": true },
        { "name": "text", "dataType": "text", "nullable": false, "default": null, "isPrimaryKey": false } ],
      "indexes": [ { "name": "todos_pkey", "definition": "CREATE UNIQUE INDEX todos_pkey ON public.todos USING btree (id)" } ],
      "foreignKeys": [] } ],
  "views": [] }

Postgres row counts are pg_class.reltuples estimates and sizeBytes uses pg_total_relation_size; MariaDB uses information_schema.TABLES.TABLE_ROWS plus data/index bytes. Both are estimates and may lag. Returns 404 until the database is ready.

GET /projects/:id/database/tables/:table/rows?channel=

Paginated row window for the table browser. Query params: limit (default 50, max 200), offset, orderBy (a column name), dir (asc|desc). Orders by the primary key when orderBy is omitted (no order if the table has none).

json
{ "ok": true,
  "columns": [ { "name": "id", "dataType": "integer" }, { "name": "text", "dataType": "text" } ],
  "rows": [ [1, "ship docs"] ],
  "rowCount": 1, "approxTotal": 42, "truncated": false, "durationMs": 3 }

approxTotal is the same engine-native estimate as the schema route. 404 TABLE_NOT_FOUND; 400 COLUMN_NOT_FOUND for a bad orderBy.

POST /projects/:id/database/tables/:table/rows?channel=

Insert one row: body { "values": { "text": "ship docs", "done": false } }. Postgres returns the inserted row via RETURNING *; MariaDB returns rowCount and reloads the grid. An empty values object inserts engine-native defaults. 400 COLUMN_NOT_FOUND for an unknown column.

PATCH /projects/:id/database/tables/:table/rows?channel=

Update one row: body { "pk": { "id": 1 }, "changes": { "done": true } }{ "rowCount": 1, "durationMs": 2 }. pk must cover exactly the table's primary-key columns (400 INVALID_BODY otherwise). 400 NO_PRIMARY_KEY if the table has no primary key at all; 400 COLUMN_NOT_FOUND for an unknown column in pk/changes.

DELETE /projects/:id/database/tables/:table/rows?channel=

Delete one row: body { "pk": { "id": 1 } }{ "rowCount": 1, "durationMs": 2 }. Same pk/primary-key rules as PATCH.

GET /projects/:id/database/tables/:table/count?channel=

Exact count(*) for a table (the schema/rows routes otherwise return an engine-native estimate):

json
{ "ok": true, "count": 1042, "durationMs": 8 }

404 TABLE_NOT_FOUND. A tenant-role statement timeout (10s) bounds worst-case cost; a timeout surfaces as 400 QUERY_ERROR.

GET /projects/:id/database/migrations?channel=

Reads the app's own _apco_migrations convention table. apco db migrate writes it on either engine; Postgres apps may also use @apco/db's runMigrations:

json
{ "ok": true, "channel": "dev", "engine": "mariadb", "tableExists": true,
  "migrations": [ { "name": "001_todos.sql", "appliedAt": "2026-07-01T00:00:00.000Z" } ] }

tableExists: false (empty migrations) when the app has never run a migration — this is not an error.

GET /projects/:id/database/usage?channel=

Live usage snapshot for the Studio's usage panel:

json
{ "ok": true, "channel": "dev", "engine": "mariadb",
  "sizeBytes": 10485760, "sizeLimitMb": 512, "readOnly": false,
  "connections": { "active": 2, "limit": 10 },
  "history": [ { "sampledAt": "2026-07-07T00:00:00.000Z", "sizeBytes": 10420000 } ] }
  • sizeBytes is measured live with the selected engine's catalog and also written back to the project's stored snapshot.
  • sizeLimitMb comes from the owner's current plan and applies independently to this branch.
  • connections.limit comes from plan dbConnectionLimit, also per branch.
  • readOnly: true blocks only this branch; the other channel databases are unaffected.
  • history is up to the newest 30 size samples, oldest first (nightly housekeeping records one sample per database per run).

GET /projects/:id/database/saved-queries

List saved SQL snippets for the project's SQL console, alphabetical by name:

json
{ "ok": true, "queries": [
  { "id": "8b2a...", "name": "Recent signups", "sql": "select * from users order by created_at desc limit 50",
    "createdAt": "...", "updatedAt": "..." } ] }

POST /projects/:id/database/saved-queries

Save a named snippet: body { "name": "Recent signups", "sql": "select ..." } (name 1–100 chars, sql ≤ 100 KB) → 201 { query }. Capped at 20 per project: 403 QUOTA_EXCEEDED past the cap. (projectId, name) is unique: 409 CONFLICT on a duplicate name (not a silent overwrite). 422 VALIDATION for a bad body.

DELETE /projects/:id/database/saved-queries/:queryId

Removes a saved query → { "deleted": true }. Deliberately not plan-gated.

GET /projects/:id/database/backups?channel=

Lists Production backups by default. channel=preview|dev selects one source; mutually exclusive allChannels=true lists the project across every branch:

json
{ "ok": true, "backups": [
  { "id": "c9f1...", "channel": "preview", "engine": "mariadb",
    "status": "complete", "sizeBytes": 20480, "error": null,
    "filename": "c9f1....sql.gz", "createdAt": "...", "completedAt": "..." } ] }

status is pendingrunningcomplete or failed. Deliberately not plan-gated, so a downgraded user still sees and can download/delete existing backups.

GET /projects/:id/database/backups/:backupId

Returns non-secret metadata, including the recorded source channel and engine. The ID is authoritative; optional channel is a consistency assertion and returns VALIDATION on mismatch. This route remains usable when the source branch row no longer exists.

POST /projects/:id/database/backups?channel=

Queues an engine-native dump (pg_dump or mariadb-dump) for the selected branch. The project allows only one pending/running backup across all channels:

MariaDB dumps omit stored routines, events, and triggers; keep those advanced objects in application migrations. Table/view schema and table data are included.

json
{ "ok": true, "channel": "dev", "engine": "mariadb",
  "backupId": "c9f1...", "status": "pending" }   // 202

maxBackups is a project-wide completed total from the plan. BACKUP_IN_PROGRESS identifies the source channel holding the slot; quota errors direct callers to all-channel discovery.

DELETE /projects/:id/database/backups/:backupId

Removes by authoritative ID → { "deleted": true }. Optional channel only asserts the recorded source. The live database is untouched and the route is not plan-gated.

GET /projects/:id/database/backups/:backupId/download

Streams the ID's dump as application/gzip. Optional channel is only an assertion. 409 BACKUP_NOT_READY unless complete; not plan-gated.

There is no server-side restore. The CLI can explicitly copy a platform backup into a selected target branch of the same project with apco db restore <id> --channel <target> --yes; it selects psql or mariadb from the target URL. Local dump files must match the target engine. Restore is separate from code-only promotion.

POST /projects/:id/database/rotate-credentials?channel=

Rotates the tenant role's password:

json
{ "ok": true, "channel": "dev", "engine": "mariadb", "status": "rotating",
  "note": "Your running deployment will lose database access until you redeploy or stop/start the project." }

202 — the worker changes only the selected branch role. Its running deployment loses access until redeployed/restarted; the other branch credentials are unchanged.

Admin

All /admin/* routes require the admin role and full access (scoped keys are always rejected). Errors: 403 FORBIDDEN (role), 403 FORBIDDEN_SCOPE (scoped key).

Users

RouteBehavior
GET /admin/usersAll users with plan, moderation state, project counts, 30-day request totals, disk usage, suspended-project counts
DELETE /admin/users/:idDelete a user and everything they own (queues per-project teardown; removes API keys explicitly). 400 SELF_ACTION on your own account
POST /admin/users/:id/banBody optional { reason?, expiresInSeconds? }. Sets ban fields, revokes sessions → { ok, id, banned: true }. 400 SELF_ACTION
POST /admin/users/:id/unbanClears ban fields → { ok, id, banned: false }
POST /admin/users/:id/roleBody { "role": "admin" | "user" }{ ok, id, role }. 400 SELF_ACTION on your own role
POST /admin/users/:id/planBody { "plan": "<plan slug>" } — validated against the plans table (422 UNKNOWN_PLAN); enqueues apply-limits per owned project → { ok, id, plan }

Projects

RouteBehavior
GET /admin/projectsEvery project with owner email, latest deployment status, request totals, suspension + password state
DELETE /admin/projects/:idDelete any project (no ownership check); same DB-drop + teardown flow as the user-facing delete
POST /admin/projects/:id/suspendMarks suspended (suspendReason: "admin"), queues container stop + suspended-route swap → 202 { ok, id, queued: true, suspended: true }. 409 ALREADY_SUSPENDED
POST /admin/projects/:id/resumeThe only way out of a suspension (admin or quota). Queues a start job; the worker clears the flag → 202 { ok, id, queued: true }. 409 NOT_SUSPENDED

Databases

RouteBehavior
GET /admin/databasesEvery physical channel database with channel, owner, live size/connections, current dbSizeMb/dbConnectionLimit, and overLimit; no credentials

Plans

Plan limits live in the plans table only — these routes are how they change.

RouteBehavior
GET /admin/plansAll plans with per-plan user counts
POST /admin/plansCreate a plan (limits include source maxUploadMb, OCI maxImageMb, database, jobs, and channel capabilities). maxImageMb: 0 disables OCI. isDefault: true switches the default transactionally. 409 SLUG_CONFLICT
PUT /admin/plans/:idUpdate; connection-limit changes enqueue branch reconciliation for affected users; container resource changes enqueue apply-limits
DELETE /admin/plans/:id409 PLAN_IS_DEFAULT / PLAN_IN_USE guards

Stats & series

RouteResponse
GET /admin/stats{ stats: { projects, users, deployments: { total, byStatus }, requests24h, usersByPlan, projectUsage: { staticBytes, imageBytes, totalBytes }, disk, system } }
GET /admin/series?days=Per-day platform series (days 1–365, default 30): { series: { days, signups: [{day,count}], requests: [{day,count,bytesOut}], deployments: [{day,count}] } } — sparse, day is YYYY-MM-DD (UTC)

Waitlist & invites

RouteBehavior
GET /admin/waitlistList waitlist entries
POST /admin/invitesBody { "email": "..." }{ ok, inviteCode }

Public

POST /waitlist

No authentication. Body { "email": "..." }{ ok, message }. Idempotent — never reveals whether the email was already on the list. 422 INVALID_EMAIL.

APCO Cloud — ship apps with one command.