Appearance
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:
| Method | Header | Access |
|---|---|---|
| API key | x-api-key: <key> | Scopes from the key: a key with no scopes is full access; a scoped key is limited to its scopes |
| Session | Cookie (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. - Admin —
adminrole and full access (scoped keys can never reach admin routes).
Route summary
| Method + path | Purpose | Access |
|---|---|---|
GET /me | Authenticated user + token scopes | any valid credential except setup-only ci:deploy |
GET /account | Account security summary | full access |
POST /account/set-password | Set a password on a GitHub-only account | full access (session semantics) |
GET /account/usage | Plan capabilities + current usage | full access |
GET /github/repos | Linked GitHub account's repos | full access |
POST /tokens | Create a scoped API token | session only |
GET /projects | List projects | projects:read |
POST /projects | Create a project | projects:write |
GET /projects/:id | Project + recent deployments | projects:read |
PUT /projects/:id | Queue slug rename (202) | projects:write |
PATCH /projects/:id | Repo/Actions settings, deploy mode, and/or password protection | projects:write |
DELETE /projects/:id | Delete project | projects:write |
POST /projects/:id/deployments | Deploy source (multipart) or OCI image (JSON) (202) | deploy, or exact one-project ci:deploy |
GET /projects/:id/registry-credentials | OCI credential host/timestamp metadata only | projects:read |
PUT /projects/:id/registry-credentials | Create/rotate exact-host OCI pull credential | projects:write |
DELETE /projects/:id/registry-credentials | Delete exact-host credential with {confirm:true} | projects:write |
GET /projects/:id/env | Read env vars (values included) | env:read |
PUT /projects/:id/env | Replace env vars | env:write |
GET /projects/:id/metrics?span= | Request/traffic series | metrics:read |
GET /projects/:id/resources?span= | CPU/RAM series + disk + plan limits | metrics:read |
GET /projects/:id/analytics?span= | Top paths/referrers/countries/devices/browsers/OS, statuses, visitors | metrics:read |
GET /projects/:id/runtime-logs?lines= | Running container logs | logs:read |
POST /projects/:id/stop | Stop the running deployment (202) | lifecycle |
POST /projects/:id/start | Start a stopped deployment (202) | lifecycle |
GET /projects/:id/jobs?channel=&deployment= | Safe scheduled-job definitions + plan limits | jobs:read |
PATCH /projects/:id/jobs/:jobId | Set durable {enabled} override | jobs:write |
POST /projects/:id/jobs/:jobId/run | Queue a manual logical run (202) | jobs:write |
GET /projects/:id/job-runs | Cursor-paginated safe run history | jobs:read |
GET /projects/:id/job-runs/:runId | Logical run + safe attempt metadata | jobs:read |
GET /projects/:id/job-runs/:runId/logs | Redacted attempt logs | jobs:read + logs:read |
POST /projects/:id/job-runs/:runId/cancel | Idempotent cancellation request | jobs:write |
GET /deployments/:id | Deployment detail | projects: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/screenshot | Captured PNG of the live site | projects:read |
POST /deployments/:id/screenshot | Request a fresh screenshot (202) | projects:write |
POST /deployments/:id/rollback | Roll back to this deployment (202) | deploy |
POST /deployments/:id/promote | Code-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/branches | Ordered Production/Preview/Dev statuses | projects: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_URL | database:credentials or projects:write (Pro gate) |
POST /projects/:id/database/query?channel= | Selected-branch SQL, optional {rollback} dry run | database:query or projects:write (Pro gate) |
GET /projects/:id/database/schema | Table browser introspection (tables, views, columns, indexes, foreign keys) | projects:read (Pro gate) |
GET /projects/:id/database/tables/:table/rows | Paginated table rows | projects:read (Pro gate) |
POST /projects/:id/database/tables/:table/rows | Insert a row {values} | projects:write (Pro gate) |
PATCH /projects/:id/database/tables/:table/rows | Update a row {pk, changes} | projects:write (Pro gate) |
DELETE /projects/:id/database/tables/:table/rows | Delete a row {pk} | projects:write (Pro gate) |
GET /projects/:id/database/tables/:table/count | Exact row count | projects:read (Pro gate) |
GET /projects/:id/database/migrations | Applied-migrations history (_apco_migrations) | projects:read (Pro gate) |
GET /projects/:id/database/usage | Live size, read-only state, connections, size history | projects:read (Pro gate) |
GET /projects/:id/database/saved-queries | List saved SQL snippets | projects:read (Pro gate) |
POST /projects/:id/database/saved-queries | Save a named SQL snippet {name, sql} | projects:write (Pro gate) |
DELETE /projects/:id/database/saved-queries/:queryId | Remove a saved query | projects:write (no plan gate) |
GET /projects/:id/database/backups?channel= | Selected-channel backups; allChannels=true lists all | projects:read (no plan gate) |
GET /projects/:id/database/backups/:backupId | Non-secret metadata by authoritative ID | projects:read (no plan gate) |
POST /projects/:id/database/backups?channel= | Queue selected-branch backup (202) | projects:write (Pro gate) |
DELETE /projects/:id/database/backups/:backupId | Remove a backup's metadata | projects:write (no plan gate) |
GET /projects/:id/database/backups/:backupId/download | Stream the gzipped SQL dump | projects:read (no plan gate) |
POST /projects/:id/database/rotate-credentials?channel= | Rotate one branch password (202) | projects:write (Pro gate) |
GET /projects/:id/domains | List custom domains + DNS records + routingTarget | projects:read |
POST /projects/:id/domains | Add custom domain {domain} (202) | projects:write (Pro gate) |
PATCH /projects/:id/domains/:domainId | Set {routingTarget: "production"|"preview"|"dev"|"disabled"} (202) | projects:write (Preview/Dev plan gates) |
POST /projects/:id/domains/:domainId/verify | Re-run DNS verification (202) | projects:write |
DELETE /projects/:id/domains/:domainId | Detach custom domain (202) | projects:write (no plan gate) |
GET/POST /admin/plans, PUT/DELETE /admin/plans/:id | Plans CRUD (incl. dbConnectionLimit, maxBackups) | admin |
GET /admin/users, DELETE /admin/users/:id | User list / delete | admin |
POST /admin/users/:id/{ban,unban,role,plan} | Moderation + role/plan | admin |
GET /admin/projects, DELETE /admin/projects/:id | All projects / delete | admin |
POST /admin/projects/:id/{suspend,resume} | Suspend / resume (202) | admin |
GET /admin/databases | Overview of every managed database | admin |
GET /admin/stats, GET /admin/series?days= | Platform stats / per-day series | admin |
GET /admin/waitlist, POST /admin/invites | Waitlist / invites | admin |
POST /waitlist | Join the waitlist | public (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 422VALIDATIONerror.projectIds(optional): 1–25 project UUIDs owned by the caller; omit for all projects.ci:deployis setup-only: it must be the only scope andprojectIdsmust 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);nullclears 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 resolvedapco.yml; optionalchannelmay forceproduction,preview, ordev; omission follows the project's deploy mode).source— the gzipped tar archive (source.tar.gz).metadata(optional) — strict JSON string withtrigger,gitSha,gitRef,gitRepository, andgitPrNumber. 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 (scopeenv:read).PUTbody{ "vars": { "KEY": "value", ... } }— replaces the project's entire env atomically (scopeenv:write). Returns{ ok, keys: [...] }. 422QUOTA_EXCEEDEDover the plan'smaxEnvVars. 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" } // 202maxDatabases 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 }rowsare positional arrays, capped at 500 (truncated: truebeyond that);rowCountis the driver's count.- On Postgres,
rollback: trueruns 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 withDRY_RUN_UNAVAILABLEbecause 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 400INVALID_BODY. - Never returns the connection URL. 409
DATABASE_PROVISIONING/DATABASE_NOT_READYwhile 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 } ] }sizeBytesis measured live with the selected engine's catalog and also written back to the project's stored snapshot.sizeLimitMbcomes from the owner's current plan and applies independently to this branch.connections.limitcomes from plandbConnectionLimit, also per branch.readOnly: trueblocks only this branch; the other channel databases are unaffected.historyis 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 pending → running → complete 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" } // 202maxBackups 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
| Route | Behavior |
|---|---|
GET /admin/users | All users with plan, moderation state, project counts, 30-day request totals, disk usage, suspended-project counts |
DELETE /admin/users/:id | Delete 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/ban | Body optional { reason?, expiresInSeconds? }. Sets ban fields, revokes sessions → { ok, id, banned: true }. 400 SELF_ACTION |
POST /admin/users/:id/unban | Clears ban fields → { ok, id, banned: false } |
POST /admin/users/:id/role | Body { "role": "admin" | "user" } → { ok, id, role }. 400 SELF_ACTION on your own role |
POST /admin/users/:id/plan | Body { "plan": "<plan slug>" } — validated against the plans table (422 UNKNOWN_PLAN); enqueues apply-limits per owned project → { ok, id, plan } |
Projects
| Route | Behavior |
|---|---|
GET /admin/projects | Every project with owner email, latest deployment status, request totals, suspension + password state |
DELETE /admin/projects/:id | Delete any project (no ownership check); same DB-drop + teardown flow as the user-facing delete |
POST /admin/projects/:id/suspend | Marks suspended (suspendReason: "admin"), queues container stop + suspended-route swap → 202 { ok, id, queued: true, suspended: true }. 409 ALREADY_SUSPENDED |
POST /admin/projects/:id/resume | The 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
| Route | Behavior |
|---|---|
GET /admin/databases | Every 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.
| Route | Behavior |
|---|---|
GET /admin/plans | All plans with per-plan user counts |
POST /admin/plans | Create 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/:id | Update; connection-limit changes enqueue branch reconciliation for affected users; container resource changes enqueue apply-limits |
DELETE /admin/plans/:id | 409 PLAN_IS_DEFAULT / PLAN_IN_USE guards |
Stats & series
| Route | Response |
|---|---|
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
| Route | Behavior |
|---|---|
GET /admin/waitlist | List waitlist entries |
POST /admin/invites | Body { "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.