The Apps API
Every client — the web page, the AI skill, the MCP tools and the Apps client — is a caller of this API, so it is the complete surface. Machine-readable: /api/openapi-apps.json (OpenAPI 3; imports into a custom GPT as an Action).
§01Base URL and authentication
Base URL https://pitchstation.ai; every path starts with /api/apps. Send a personal access token as Authorization: Bearer pst_…. The token's user must hold the app.deploy capability; without it every route answers 403 CAPABILITY_REQUIRED. A scoped token also needs the Apps scopes (apps:read, apps:deploy; the Apps preset at /tokens.html has them), or the route answers 403 SCOPE_REQUIRED. Accepting the publisher terms needs a signed-in browser session: a scoped token gets 403 SESSION_REQUIRED. Everything is owner-scoped: you see and change only your own apps and the targets you may use. Request and response bodies are JSON. The user manual explains each task step by step.
Clients take the origin from PITCHSTATION_URL and never hard-code it, so the same clients work against a self-hosted or separately hosted Apps service.
§02Conventions
| Convention | What it means for you |
|---|---|
| Dry run by default | Deploy, rollback, stop, PATCH env, resources, hardening, settings, domains, policy, POST backups, backup-config, restore, move and POST hosts preview unless the body says "dryRun": false. A dry run returns the redacted plan (never commands, env values or hashes) and changes nothing on a host; a deploy dry run that names an imageArchive uses an archive already uploaded. These act at once: POST /api/apps, DELETE /api/apps/{id}, PUT env, PUT targets/{id}/runtime, PUT and DELETE images, access links, operation retry, cancel and reconcile, move abort and remove-source, DELETE hosts/{hostId} and terms accept. |
| Plan, then apply | Domains and policy changes are saved as a plan with an id. Apply by sending that planId. Plans expire after 15 minutes. |
If-Match (ETag) | Configuration changes carry the version you reviewed, e.g. "app-7-v2" from GET domain-config. A mismatch is CONFIG_VERSION_CONFLICT; a missing header is 428 PRECONDITION_REQUIRED. |
Idempotency-Key | Required to apply: 8–128 letters, digits, dots, underscores or hyphens. Repeating the identical request with the same key returns the original operation; the same key with a different request is IDEMPOTENCY_KEY_REUSED. |
Prefer: respond-async | Deploy and rollback return 202 at once with the operation, a Location header and statusUrl. Without it the server waits for the result for up to 15 minutes, but the production edge closes a silent request long before that (nginx after 300 seconds, the CDN in front of it possibly sooner), so send Prefer: respond-async and poll the operation. |
| Secrets stay in | Env values, basic-auth hashes, SSH commands and raw remote logs are never returned. Errors name the fields to fix, not their values. |
§03Operations
An accepted deploy, rollback, domains, policy or env change becomes an operation with a UUID. Acceptance is not success.
| Status | Meaning |
|---|---|
| queued | Accepted; waiting for the app's host lease. |
| running | Steps are executing on the host; phase and steps show where. |
| waiting_external | Blocked on something outside the platform, usually DNS. Fix it, then verify or retry. |
| cancel_requested | Will stop at the next safe checkpoint. |
| succeeded | Done; result carries the live URL and a readiness snapshot. |
| failed · cancelled | Stopped. error has a typed code. Retry re-runs only the failed safe steps. |
reconciliation_required means a remote outcome is uncertain (for example, a migration exited non-zero or the connection dropped mid-step). The platform will not guess: retry is refused until someone inspects the host and records what is actually there. The operation's reconcile.by says who: owner (the step touched only your containers — use the reconcile endpoint, the Apps page or the client) or operator (routing, certificates, a restore, or a quarantined host lease after a timeout or a dropped connection — an admin resolves it from the admin page, no restart). Database changes are never rolled back automatically.§04Endpoints
| Method and path | Purpose and notes |
|---|---|
| Targets | |
| GET /api/apps/targets | Hosts you may use: your own container hosts plus live shared hosts, each with shared, live, arch, region, free slots and committed versus capacity memory; and quota {poolApps, limit} for shared hosts; utilization is the host's last observed memory, disk and load (every 3 minutes). A shared host never shows its SSH address or other tenants' usage. |
| PUT /api/apps/targets/{id}/runtime | Operator: promote a registered host to {"runtime":"container","maxApps","cpuCores","memMb"}. One host can serve static websites and apps. |
| Apps and releases | |
| GET /api/apps | Your apps: status, current release, host port and the publishing summary (domains, access, route). |
| POST /api/apps | Create: {slug, name, targetId?, placement?, arch?, resources?, publication?, webService, containerPort, healthPath, migrateCmd?, migrateService?, hooks?, composeFile?, env?} → the app, its permanent hostPort and placement. Omit targetId to be placed (placement: {region?, arch?}; the response says why, and why each other host was rejected). publication: {access, password?, expiresAt?, applicationPolicy?} sets access from the first request — private with a password, or public with applicationPolicy: "none". Shared hosts count against your quota (QUOTA_EXCEEDED). A write, not a preview. |
| GET /api/apps/{id} | App detail with its 20 most recent releases: the ids and timestamps you can roll back to, each release's status and health, and currentRelease. The client's releases --app ID prints them. |
| DELETE /api/apps/{id} | Remove the registry record; it acts at once. On your own host it does not stop containers, and it keeps the app's hostnames reserved for good (even its slug cannot be created again), unless you add ?removeFromHost=1. On a shared host, or with that flag, the app must be stopped (APP_RUNNING); its containers, routing and budget slice are removed from the host, its hostnames and budget are released, and its data volumes are kept. A running move (MOVE_IN_PROGRESS) or a stopped copy an earlier move left on another host (HOST_STATE_CONFLICT) must be finished or removed first. |
| PUT /api/apps/{id}/env | Older env call: replaces the whole stored set, applied at the next deploy; it acts at once. On a managed app, an invalid key or value (not an identifier, PORT, HOST_PORT, COMPOSE_*, DOCKER_*, not a string, or multi-line) is refused as ENV_INVALID, without naming it. On a legacy app the set is stored unchecked, and the next deploy names any key it cannot write. |
| PATCH /api/apps/{id}/env | {set, unset, scopes?, dryRun}. Preview by default; apply needs If-Match + Idempotency-Key and restarts the current release. Build-time keys (E11): scopes declares keys build or runtime (null returns a key to its default: build for NEXT_PUBLIC_*, VITE_*, REACT_APP_*, NUXT_PUBLIC_*, EXPO_PUBLIC_*, GATSBY_*, PUBLIC_*). A change to a build-time key only takes effect after a redeploy: the dry run lists buildScopedKeys with rebuildRequired, and the app carries buildStale until a deploy succeeds. Scopes sent alone are stored without a restart or an operation. |
| POST /api/apps/{id}/deploy | {tarballBase64, dryRun, note?, releaseTs?, previewPassword?, expected?, imageArchive?}. Dry run by default. With Prefer: respond-async + key: 202 and an operation to poll. imageArchive (E1b): an uploaded archive id, or {appId, id} referencing an archive from another app you own. A live deploy retains its own archive copy before it queues, independently of source deletion; preview does not copy it. The host loads its images instead of building, and the dry run refuses a wrong architecture (IMAGE_ARCH_MISMATCH) or a built service without an archived image: (IMAGE_ARCHIVE_INCOMPLETE). A migration or hook naming a service the Compose file does not define is refused (DEPLOY_SETTINGS_INVALID). |
| PATCH /api/apps/{id}/tags | Replace owner labels with {tags:{customer:"acme"}} (or null to clear). At most 20 keys, 40 characters per key and 160 per value; no control characters or secrets. Requires apps:deploy. Tags appear in app list/detail and spend; GET /api/apps?customer=acme filters your own apps. |
| GET /api/apps/{id}/images | The app's image archives (E1b): id (the file's SHA-256), size, images with their tags and architecture; and the limits (maxMb, keep). |
| PUT /api/apps/{id}/images | Upload docker save <image:tag>… | gzip as the raw body (Content-Type: application/octet-stream), in one request with Content-Length and X-Content-SHA256. At most 1,024 MB; the newest three per app are kept, plus the one the running release came from. 201 with the archive (200 if already uploaded). IMAGE_ARCHIVE_INVALID names the reason. |
| DELETE /api/apps/{id}/images/{archiveId} | Remove an archive; refused (HOST_STATE_CONFLICT) for the one the running release was deployed from. |
| POST /api/apps/{id}/rollback | {releaseId?, dryRun}. Preview by default; recreates a retained release with its Compose file and keeps the current domains, access, budget and last applied env. Without releaseId it goes to the newest healthy release other than currentRelease: normally the previous one, but after a rollback the newer release you rolled back from. The dry run names it in to (the release's release_ts). After a failed deploy a plain rollback skips the last good release, so name it. Images the host built come back only if each release tags them differently; images named by tag come back. Migrations are not undone. |
| POST /api/apps/{id}/preview | Returns a one-time preview credential. It does not turn a gate on: use the policy operation. |
| POST /api/apps/{id}/stop | Dry run by default; {"dryRun":false} stops the containers and keeps volumes and releases. |
| Publishing configuration | |
| GET /api/apps/publishing-capabilities | What this server allows: configuration read, operations, whether live changes need the pilot, the adapter in use. |
| GET /api/apps/{id}/domain-config | The configuration and its ETag, saved plans, DNS records to add, recent operations and the last readiness. |
| PUT /api/apps/{id}/domain-config | Plan {domains} (dry run default), or apply {"dryRun":false, planId}. If-Match always; Idempotency-Key to apply. |
| PATCH /api/apps/{id}/publication-policy | Plan {policy:{access, password?, expiresAt?, eventAt?, applicationPolicy?, publicPaths?, cachePaths?, accounts?}} or apply a plan. Same version and key rules as domains. applicationPolicy declares the app's own restrictions (none for a public app with no login of its own). Private apps (milestone 4): publicPaths — up to five legacy path prefixes or exact {path:"/api/integrations/pitchstation/webhook", methods:["POST"]} entries served without the gate. Exact entries allow POST only (other methods return 405); expiry still applies and the app verifies signatures and replay protection; cachePaths — up to five prefixes whose responses may stay in the viewer's browser cache (a response the app marks immutable qualifies anywhere; everything else is no-store); accounts — {users:[username], teams:[id | handle | @owner/handle]} who may use Sign in with PitchStation on the login page. |
| POST /api/apps/{id}/domain-config/verify | {planId?}: DNS and ownership check against authoritative and independent resolvers. No certificate, no host change. |
| Operations and evidence | |
| GET /api/apps/{id}/operations | Recent operations for this app. |
| GET /api/apps/{id}/operations/{operationId} | Status, phase, attempt, per-step results and the result (live URL, readiness). |
| POST /api/apps/{id}/operations/{operationId}/retry | Retry the failed safe steps of the same operation. Refused when reconciliation is required. |
| POST /api/apps/{id}/operations/{operationId}/cancel | Request cancellation at the next safe checkpoint. |
| POST /api/apps/{id}/operations/{operationId}/reconcile | Resolve an uncertain operation yourself when its summary says reconcile.by: "owner" (the uncertain step touched only your containers). {"mode":"inspect"} pauses the app's queue of operations (the app keeps serving) and returns read-only host evidence and an inspectionId (15 minutes); {"mode":"confirm", inspectionId, attest:{migrationsReviewed:true, note}} records it and unblocks the app. The failed operation stays failed; deploy or roll back again to converge. Routing and host-lease cases are for an admin (RECONCILE_NOT_ALLOWED). |
| Status and logs (owner or admin) | |
| GET /api/apps/{id}/status | The app's containers: service, state, health, restarts, last exit code, out-of-memory kills, start time, image, and CPU/memory/process usage against the app's budget. Read-only (never waits for a deploy), cached 15 s; ?fresh=1 re-reads. |
| GET /api/apps/{id}/logs | ?service=&tail=&since=: recent container logs, interleaved by time. service must be one the status call reported; tail 1–1,000 (default 200); since like 10m or an ISO time; at most 256 KiB. Stored environment values and common token shapes are masked; each read is recorded. Ten host reads a minute per user and app (RATE_LIMITED). |
| PATCH /api/apps/{id}/resources | {resources:{memMb, cpus, pids}, dryRun}: the app's budget (defaults 512 MB, 1 CPU, 256 processes). The dry run shows committed versus capacity. Where budgets are enforced (shared hosts), apply with an Idempotency-Key returns an operation that updates the app's slice live, without a restart; elsewhere it is stored and shown. |
| PATCH /api/apps/{id}/hardening | {hardening:{readOnlyRootfs:["web"]}, dryRun}: run the listed services with a read-only root filesystem and a tmpfs at /tmp (write data to a named volume). Stored now, applied by the next deploy, rollback or env change. On a strict host (the host list says hardening: "strict") every service also runs with no-new-privileges, and the Compose file must pass the strict allowlist. |
| PATCH /api/apps/{id}/settings | {settings:{migrateCmd?, migrateService?, hooks?, composeFile?}, dryRun} (E9, E8): the migration and the service it runs in; hooks.preDeploy / hooks.postDeploy, each up to five {service, cmd, timeoutSec} (pre-deploy hooks run after the images exist and before the migration and stop the deploy on failure; post-deploy hooks run after the health check and only warn); and the Compose file's path when it is not at the source root. null clears a setting. Stored now, used by the next deploy. Commands run inside the service's container as words (like sh splits them): shell operators outside single quotes are refused with DEPLOY_SETTINGS_INVALID; wrap one that needs a shell in sh -c '…'. Commands are never returned. |
| GET /api/apps/terms | The current publisher terms (version, text) and whether you accepted them. With APP_TERMS_REQUIRED=1, creating an app, a live deploy, and quoting or reserving an isolated host need them accepted (TERMS_REQUIRED); deploy dry runs do not. |
| POST /api/apps/terms/accept | {version}: accept the current terms; recorded once per user and version. A signed-in browser session or a full-access token; a scoped token (the Apps preset included) gets 403 SESSION_REQUIRED, so people accept on the Apps page. |
| POST /api/app-abuse-reports | Public, no account: {url, category, description, email?} with category one of malware, phishing, spam, illegal, copyright, harassment, other. Returns a reference; an admin reviews every report and can take the app down. Rate-limited per address (RATE_LIMITED). The form is at pitchstation.ai/abuse.html. |
| GET /api/apps/{id}/access-links | Each link also shows its use, opens: {count, firstOpenedAt, lastSeenAt}, which the host reports every five minutes (link ids and times only). Per-recipient access links of a private app, each with its state (active, expired, revoked) and, while active, its URL. A link opens the app without the password for one person, with a session of up to 12 hours each time it is opened; its sessions end when the link is revoked. |
| POST /api/apps/{id}/access-links | {label, expiresAt?} (up to 50 active links per app, expiry within a year): returns the link and its URL https://<primary>/__ps/enter?t=… with 202 and an access-links operation that re-applies routing; the link works once it succeeds. Public apps have no links (POLICY_INVALID, field access). |
| DELETE /api/apps/{id}/access-links/{linkId} | Revoke one link: 202 and an access-links operation; once it succeeds the link and every session it opened stop working. |
| GET /api/app-enter/{id} | A browser navigation from a private app's login page, when the owner listed accounts: in the visitor's own PitchStation session (else to sign in and back), the owner, a listed person or a member of a listed team is sent to https://<primary>/__ps/enter with a five-minute hand-off token; anyone else sees a page saying they are not on the list. Membership is checked at every sign-in; turning accounts off ends every account session. Audited. |
| PATCH /api/apps/{id}/backup-config | {backup, dryRun} with backup either {kind:"command", service, cmd, restoreCmd} (a command run inside one of your services whose output is the dump, and the one that reads it back from stdin) or {kind:"volumes", volumes:["data"]} (a gzipped tar of named volumes, restored with the app stopped), plus keep (1–10 on the host, default 5), schedule (none or daily) and beforeDeploy (default true). null removes it. The dry run shows the exact host command. |
| GET /api/apps/{id}/backups | The configuration and the backups with their trigger (deploy, manual, scheduled), size, hash and where they are: on the host (the newest keep) and on the control plane (the newest 10, which also leave the server with the platform backups). |
| POST /api/apps/{id}/backups | {dryRun}: take a backup now; apply returns 202 and a backup operation. Once backups are configured, a deploy of an app that already has a release also takes one first, unless beforeDeploy is false: before its first pre-deploy hook, else its migration, else its start. |
| GET /api/apps/{id}/backups/{backupId}/download | The control plane's copy of one backup, for you to keep. |
| POST /api/apps/{id}/restore | {backupId, dryRun, confirm, review}: replace the app's data with a backup. The dry run returns plan.review and the original backup recipe targets; apply needs that review fingerprint, confirm set to the app slug and an Idempotency-Key (202, restore operation). A backup no longer on the host is copied back first. Changes to backup configuration cannot change an accepted restore. Historical backups without recipe metadata remain downloadable for manual recovery. A failed restore leaves the data state uncertain, and only an operator can reconcile it; code is never changed. |
| GET /api/apps/host-catalog | Isolated hosts you can reserve: size classes S–XL (2–16 GB), arm64 or amd64, in us-west-2 (Oregon) or ap-southeast-1 (Singapore), each with its all-in monthly price (compute, disk, public IPv4). Both regions are priced from the us-west-2 (Oregon) table in this release, so a Singapore quote shows the Oregon price. |
| GET /api/apps/hosts | Your reserved and running hosts (state, price, cost so far, address, apps) and your spend against your monthly cap. |
| POST /api/apps/hosts | {name, sizeClass, arch, region, dryRun}: reserve an isolated host of your own. The dry run quotes the price and checks it against your monthly cap, which dev instances share (COST_CAP over it). Apply returns 201 reserved: an administrator launches it within a day. It runs strict Compose rules, user-namespace remapping and restricted egress, and is yours alone to place apps on once approved. |
| DELETE /api/apps/hosts/{hostId} | Let a host go: a reservation is cancelled; a launched host with no apps is handed to the operator to terminate (HOST_STATE_CONFLICT while apps remain). It acts at once, with no dry run. hostId is the id GET /api/apps/hosts lists, not a target id. |
| GET /api/apps/spend | Your monthly cap, what is committed (dev instances and app hosts), each host's cost so far, and each app's share of its host's price by memory budget, with who pays for it (you, or the platform on a shared host). |
| POST /api/apps/{id}/move | {targetId | placement:{region, arch}, stateless, dryRun}: move the app to another host you may use. The dry run shows the destination and why, the hostname change (a platform hostname changes across base domains; custom domains keep their claim and need their DNS pointed at the new address as soon as the source stops, because the destination is routed only once every name resolves to it), how the data travels (a final backup, restored on the destination; stateless: true for apps without data) and the steps. Apply returns 202 and a move: pull the release, stop all source services, take the final volume backup with the source kept stopped, build on the destination (or load the release's image archive) and restore, switch, route there (DNS, certificates, readiness), keep the stopped source for 7 days. A failure before the switch starts the source again. Stateful moves require a volume backup covering all app data; command backups are refused with MOVE_UNSUPPORTED because no write-quiescence procedure is supported. After a restart, a switched move is finalized from a successful routing result or marked failed for destination recovery, retaining its stopped source without automatic removal. |
| GET /api/apps/{id}/moves | The app's moves with their state (accepted, running, switched, done, rolled_back, failed, interrupted) and every step. |
| GET /api/apps/{id}/moves/{moveId} | One move. |
| POST /api/apps/{id}/moves/{moveId}/abort | Abort an interrupted or queued move only while the app is still on its source (the source is started again). Already-switched moves must recover routing on the destination. |
| POST /api/apps/{id}/moves/{moveId}/remove-source | After a finished move, remove the stopped source copy now instead of at the end of its retention: containers, routing and budget; data volumes are kept. |
| Readiness | |
| GET /api/apps/{id}/readiness | The last readiness result (valid for 5 minutes). |
| POST /api/apps/{id}/readiness | Run bounded public HTTPS checks now: DNS, TLS, access policy, identity marker and critical assets, per hostname. |
| GET /api/apps/{id}/publication-health | Monitor state and certificate status for each host. |
| GET /api/apps/{id}/publication-receipt | A redacted receipt of what is published, safe to share (never passwords). |
Reusable definitions: POST /api/apps accepts fromApp: ID for an app you own, with a new slug, target/placement and optional overrides. It copies web service, container port, health path, migration settings, Compose path, resources, architecture, hardening and backup configuration. Environments, credentials, gates, access links, domains, grants, hooks, releases and volume data are not copied. Choose a fresh publication policy when the template is private. Supply new environment values and optional tags.
Image evidence: publication receipts and completed deploy operations include imageDigest for the web service, imageDigests by service, and imageCapturedAt. These are actual container image IDs observed at deploy, frozen on the release. archiveDigest is the separate compressed archive checksum. imageEvidenceStatus is captured or unavailable; missing inspection evidence is null; rollback of mutable tags does not certify the original image is still running.
Operator assignment: an administrator may use PUT /api/admin/app-quotas/USERNAME with {maxPoolApps:25,tier:"operator",defaultResources:{memMb:512,cpus:1,pids:256}}. The ordinary quota of 3 and default 512 MB budget stay unchanged for other owners. Host slots and memory admission still apply. This is capacity policy, not a commercial price or a host purchase.
§05Errors
When the fix is a specific input, the error carries fields with the names (never values) to change — for example readiness returns ACCESS_POLICY_MISMATCH with fields: ["applicationPolicy"] for a public app whose owner has not declared none.
Errors are a fixed vocabulary: { code, status, retryable, actor, message, nextAction, fields?, appId?, operationId? }. actor says who must act: publisher (you) or operator (the people running the hosts).
| Code | HTTP | Retry | Actor | Next action |
|---|---|---|---|---|
| DOMAIN_INVALID | 400 | no | publisher | Use a hostname without a scheme, port, path, wildcard or IP address. |
| DOMAIN_ALREADY_BOUND | 409 | no | publisher | This hostname is reserved. Detach it from its current publication before reuse. |
| OWNERSHIP_UNVERIFIED | 409 | yes | publisher | Add the displayed ownership record at your DNS provider, then verify again. |
| DNS_TARGET_MISMATCH | 409 | yes | publisher | Replace the conflicting DNS records with the displayed target records. |
| DNS_PROPAGATING | 409 | yes | publisher | DNS observations disagree or are unavailable. Wait for propagation, then verify again. |
| DNS_IPV6_CONFLICT | 409 | yes | publisher | Remove the unexpected AAAA record or configure an approved IPv6 target. |
| TLS_VALIDATION_FAILED | 502 | yes | operator | Check the hostname, port 80 challenge path and certificate issuer diagnostics. |
| TLS_RATE_LIMITED | 429 | yes | operator | Wait for the certificate issuer retry window before trying again. |
| ROUTE_APPLY_FAILED | 502 | yes | operator | Routing could not be applied. Check the recorded recovery outcome before retrying. |
| ACCESS_POLICY_MISMATCH | 409 | no | publisher | Observed access differs from the requested policy. Check platform and application restrictions separately. |
| PUBLIC_CONTENT_MISMATCH | 409 | yes | publisher | Verify the expected application identity and critical assets; a successful HTTP status alone is insufficient. |
| PUBLICATION_EXPIRED | 409 | no | publisher | Renew the platform expiry. Application-managed restrictions require an application change. |
| CONFIG_VERSION_CONFLICT | 409 | yes | publisher | Reload the current configuration and make a new plan. |
| PRECONDITION_REQUIRED | 428 | no | publisher | Send the current configuration ETag in If-Match. |
| IDEMPOTENCY_KEY_REUSED | 409 | no | publisher | This idempotency key was used for a different request. Use a new key for a new intent. |
| IDEMPOTENCY_KEY_REQUIRED | 400 | no | publisher | Supply an Idempotency-Key of 8–128 printable letters, digits, dots, underscores or hyphens. |
| PLAN_EXPIRED | 409 | yes | publisher | This plan has expired. Generate and review a new plan. |
| POLICY_INVALID | 400 | no | publisher | Check access, password, expiry and event date. Dates must include a timezone. |
| CONFIG_REVIEW_REQUIRED | 409 | no | operator | Inventory the existing routes and restrictions before managing this legacy application. |
| ADAPTER_UNSUPPORTED | 409 | no | operator | This target or application policy adapter is not supported for this operation. |
| LIVE_DISABLED | 409 | no | operator | Live publishing is switched off on this server. An administrator turns it on; dry-run planning is available. |
| OPERATION_BUSY | 409 | yes | publisher | Another operation owns this application or target. Wait for it to settle. |
| OPERATION_NOT_RETRYABLE | 409 | no | operator | This operation needs reconciliation, or has no safely retryable failed step. |
| OPERATION_CANCELLED | 409 | no | publisher | The operation was cancelled at a safe checkpoint. See effective state and cleanup status. |
| RECONCILIATION_REQUIRED | 409 | no | operator | A remote outcome is uncertain. Inspect the target before authorizing another mutation; database rollback is not automatic. |
| STEP_FAILED | 502 | yes | operator | The deployment step failed. Inspect redacted step metadata and target diagnostics. |
| TARGET_UNREACHABLE | 502 | yes | operator | The target could not be reached over SSH, so nothing was changed. Check the host, network and key, then retry. |
| TARGET_FENCED | 409 | yes | operator | A newer publisher owns this target, so this writer was stopped before changing anything. Retry to publish with a fresh lease. |
| SOURCE_INVALID | 400 · 413 | no | publisher | The source bundle is invalid, contains unsafe paths or secrets, or exceeds the upload limits. (413, with reason too-large and detail.limitBytes: the request body is over the server's upload limit.) |
| SOURCE_ROOT_REQUIRED | 400 | no | operator | Server-side sourceDir must be within an explicitly configured APP_SOURCE_ROOTS directory. |
| ENV_INVALID | 400 | no | publisher | Use valid environment key names and string values; unset keys explicitly. |
| SECRET_UNAVAILABLE | 503 | no | operator | Encrypted publishing configuration is unavailable. Check DEPLOY_SECRET; no empty configuration was substituted. |
| PROBE_BLOCKED | 409 | no | operator | The public probe destination is not an approved public address for this target. |
| NOT_FOUND | 404 | no | publisher | No such application, plan or operation. |
| INTAKE_FAILED | 500 | yes | operator | The upload could not be processed because of an unexpected server error. Retry once; if it persists, report the correlationId. |
| QUERY_INVALID | 400 | no | publisher | Use a service name reported by the status call, a tail of 1–1,000 lines and a since such as 10m, 2h or an ISO date. |
| RATE_LIMITED | 429 | yes | publisher | Too many requests of this kind: over 10 status or log reads a minute by one person for one app, or over 5 abuse reports in 10 minutes from one address (or 300 an hour in total). Wait for Retry-After where it is sent (status and logs), otherwise ten minutes, then try again. |
| HOST_NOT_APPROVED | 409 | no | operator | This host is not approved for live publishing. An admin runs the host check and approves it; dry-run planning is available. |
| HOST_ONBOARDING_REFUSED | 409 | no | operator | The host already serves sites this onboarding would affect. Review the listed files; tenant sites are never changed automatically. |
| COMPOSE_POLICY_VIOLATION | 400 | no | publisher | The Compose file uses a setting the platform does not allow on this host. fields names each one, e.g. services.web.privileged. |
| RESOURCES_INVALID | 400 | no | publisher | Use memory of 64–16,384 MB, 0.1–16 CPUs and 32–4,096 processes. |
| RESOURCE_ADMISSION | 409 | no | publisher | This budget would exceed the host capacity after its reserve. Lower the budget or choose another host; detail shows the arithmetic. |
| RESOURCES_UNSUPPORTED | 409 | no | operator | This host cannot enforce per-app budgets (it needs the systemd cgroup driver on cgroup v2). Run the host check. |
| HOST_FULL | 409 | no | publisher | That host has no free application slot. Choose another host or remove an app (formerly TARGET_FULL). |
| PLACEMENT_UNAVAILABLE | 409 | yes | publisher | No shared host has a free slot and budget headroom for this request; detail.rejected says why for each. Try another region or architecture. |
| QUOTA_EXCEEDED | 409 | no | publisher | You have reached your quota of apps on shared hosts. Delete one or ask an admin to raise it. |
| APP_RUNNING | 409 | no | publisher | Stop the app before deleting it: an app on a shared host must not leave containers running without an owner. |
| RECONCILE_NOT_ALLOWED | 409 | no | operator | This uncertainty involves the host itself (routing, nginx or the host lease), so an operator must reconcile it. |
| INSPECTION_EXPIRED | 409 | yes | publisher | The inspection is missing, older than 15 minutes, or the operation changed since. Inspect again. |
| ATTESTATION_REQUIRED | 400 | no | publisher | Confirm you reviewed the migration state (attest.migrationsReviewed: true) and describe what you found. |
| APP_TAKEN_DOWN | 409 | no | operator | An administrator has taken this app down (abuse or terms). It cannot be deployed or re-enabled until the takedown is lifted. |
| TERMS_REQUIRED | 403 | no | publisher | Accept the current PitchStation Apps terms on the Apps page (/apps.html) while signed in; a scoped API token cannot accept them. They are required before creating an app, a live deploy, and quoting or reserving an isolated host. |
| REPORT_INVALID | 400 | no | publisher | Give the app address (https://…), one of the listed categories and a description of 10–4,000 characters. |
| BACKUP_NOT_CONFIGURED | 409 | no | publisher | Say how this app's data is backed up first (PATCH /api/apps/{id}/backup-config); a restore of a command backup also needs its restoreCmd. |
| BACKUP_FAILED | 502 | yes | publisher | The backup produced nothing usable. Read the step output, fix the command, service or volume names, and try again; the running app was not changed. |
| RESTORE_REVIEW_REQUIRED | 409 | no | publisher | Review a fresh restore dry run and send its review fingerprint. Backups without recorded recipes require manual recovery. |
| RESTORE_CONFIRMATION_REQUIRED | 400 | no | publisher | A restore replaces the app's data. Review the dry run, then send confirm with the app's slug. |
| COST_CAP | 402 | no | publisher | This host would take your committed monthly spend (dev instances and app hosts) over your cap. Choose a smaller size or ask an admin to raise the cap. |
| HOST_STATE_CONFLICT | 409 | no | publisher | The host is not in a state that allows this (for example, it still runs apps, or it was already launched or terminated). |
| DEPLOY_SETTINGS_INVALID | 400 | no | publisher | Check the deploy setting named in fields: a Compose service name, a command without shell operators (wrap one that needs a shell in sh -c '…'), at most five hooks per stage of 1–1,800 seconds, and a Compose file path inside the source. |
| IMAGE_ARCHIVE_INVALID | 400 | no | publisher | The image archive was refused (detail.reason): upload the output of docker save <image:tag>… | gzip in one request, with its SHA-256 in X-Content-SHA256, within the size limit. |
| IMAGE_ARCHIVE_UNAVAILABLE | 409 | no | publisher | That image archive is not on the server (never uploaded, or removed after newer uploads). Upload it again (PUT /api/apps/{id}/images) and deploy with its id. |
| IMAGE_ARCH_MISMATCH | 409 | no | publisher | An image in the archive is built for a different CPU architecture than the host (detail). Build for the host (docker build --platform linux/arm64 or linux/amd64) or choose a host that matches. |
| IMAGE_ARCHIVE_INCOMPLETE | 409 | no | publisher | With an image archive nothing is built on the host, so every service with build: needs an image: tag the archive holds (fields and detail name them). |
| BUILD_LIMIT_EXCEEDED | 409 | no | publisher | The image build ran out of the host's build memory (detail.limitMb) and was stopped; nothing else changed. Make the build lighter, or build elsewhere and deploy an image archive (PUT /api/apps/{id}/images). |
| MOVE_UNSUPPORTED | 409 | no | publisher | This move cannot be made as asked (see detail.reason): for example, the app has no release, its data is too large to carry, or both hosts share one wildcard without per-app DNS. |
| MOVE_IN_PROGRESS | 409 | yes | publisher | The app is being moved to another host. Wait for the move to finish (GET its moves) before changing it. |
These seven codes come from outside the typed catalogue: their responses carry code and error, without retryable or actor.
| Code | HTTP | When | Next action |
|---|---|---|---|
| CAPABILITY_REQUIRED | 403 | The token's user lacks the app.deploy capability (capability names it). | Ask an administrator. |
| SESSION_REQUIRED | 403 | A scoped token called a route that needs a web session: accepting the publisher terms. | Accept the terms on the Apps page while signed in. |
| SCOPE_REQUIRED | 403 | A scoped token lacks the scope the route needs (required names it). | Make a token with the Apps preset at /tokens.html. |
| NOT_CONTAINER_TARGET | 400 · 409 | The host is registered for static websites, not containers. | Use a container host from GET /api/apps/targets, or ask an operator to register the host with runtime: container. |
| NO_SECRET | 503 | DEPLOY_SECRET is not configured on the server, so env values cannot be stored encrypted. | Tell the operator. |
| BAD_SLUG | 400 | The slug is not 1–40 lowercase letters, digits and hyphens, starting and ending with a letter or digit. Capitals are lowercased for you. | Fix the slug. |
| NO_PORT | 400 | The host has no free port left in its app range (41000–41999). | Choose another host. |
§06Limits
- Upload: 80 MiB compressed, 256 MiB expanded, 20,000 files, paths up to 1,024 bytes; request bodies up to 120 MB of JSON.
- Step budgets: build 20 min, migrate 15 min, up 10 min, health 5 min; any remote step 30 min.
- Plans expire after 15 minutes; readiness results after 5 minutes.
- Preview password 12–72 UTF-8 bytes; expiry values must carry a timezone.
Host admission counts current apps, queued move destinations and pending resource increases together. A resource decrease frees capacity only after the host confirms success. Failed or interrupted destination cleanup retains its port and capacity reservation until recovery succeeds. Forgetting a deployed app retains its allocation because its containers may remain on the host. Stop and takedown verify that no project container remains running; takedown results separately report owner blocking, routing containment and process termination.