HTTP API
Everything the dashboard does goes through a JSON API under /api, and you can call it too. Anything you can click has an endpoint, and curl can call it.
Conventions
The dashboard is just a client of this API, so anything you can click has an endpoint. It is not versioned.
- JSON, camelCase. IDs are 12-character lowercase base32 strings. Times are RFC 3339 UTC. A
204has no body. - Errors are
{"error": "message"}with a real status. Unexpected failures are logged and reported as a generic500 internal error. - Authentication is the
shed_sessioncookie. Auth, setup, the webhook, and the OAuth endpoints under/oauthand/.well-knownare public; everything else answers 401 without a session./mcptakes only a bearer token, never the cookie; see MCP. Unknown/api/*paths are a JSON 404, not the dashboard. - Mutations from a browser must come from the origin of
server.url, and POST, PUT, and PATCH needContent-Type: application/jsoneven with an empty body. See request protections.
| Status | Meaning |
|---|---|
400 | Invalid input, or a request the target can't satisfy, such as a backup of a service with no volumes. |
401 | No session, or the user is no longer on allowed_users. |
403 | untrusted request origin. |
404 | Unknown route or id. |
409 | Conflict: a name in use, a service busy with a backup or restore, fenced, stopped, or being deleted. |
413 / 415 | Webhook body too large, or a mutation without a JSON content type. |
429 | The webhook guard is full, or too many client registrations from one address. |
502 / 503 | GitHub failed on repos and branches, or shed is shutting down. |
Streams
Build logs, runtime logs, and shed's own log are server-sent events. Each frame is event: log with one line per data:, event: status when a deployment's status changes (data {"status":"…"}), and event: end when there's nothing more. An idle stream sends a : ping comment every 15 seconds. Reconnecting clients get history replayed. Runtime log lines start with the container's RFC 3339 timestamp and a space.
curl -N https://your-shed/api/services/<id>/logs \ -H 'Cookie: shed_session=<your session token>'
Try it
The API has no tokens. You authenticate with the shed_session cookie, so the simplest way to script it is to sign in with your browser and copy that cookie's value. It is HttpOnly, so page scripts can't read it; find it in your browser's developer tools under Application, Cookies. It is valid for 30 days.
A read
curl -s https://shed.example.com/api/projects \ -H 'Cookie: shed_session=<your session token>'
[ { "id": "k3m9x2q7a1bz", "name": "blog", "createdAt": "2026-10-04T09:12:44Z", "services": [ { "id": "p8d4n6t2c5wy", "name": "web", "kind": "app", "status": "active" }, { "id": "h2v7r1j9e4ms", "name": "db", "kind": "postgres", "status": "active" } ] } ]
A mutation
POST, PUT, and PATCH need Content-Type: application/json, even with no body. curl sends no Origin header, so the origin check passes and the cookie is what authorizes the call. A browser-based client must instead run on the origin of server.url.
curl -s -X POST https://shed.example.com/api/services/<id>/backups \ -H 'Cookie: shed_session=<your session token>' \ -H 'Content-Type: application/json'
Work that runs in the background answers 202 with the new row, which you can poll. Failures are {"error": "message"}:
{ "error": "service is busy with a backup or restore" }
Build your own
Pick any endpoint from the reference below and type your server's URL. The command is built in your browser and sends nothing.
curl -s 'https://shed.example.com/api/me' \ -H 'Cookie: shed_session=<your session token>'
Endpoints
Placeholders like {id} are ids. POST endpoints that start work answer 202 with the new row.
Session and setup
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | / | User | ||
| GET | / | 302 to GitHub | Public. Sets the state cookie. next, a path on this server, is where sign-in ends. | |
| GET | / | 302 to next or / | Public. Sets shed_session. | |
| POST | / | 204 | Public route, origin and JSON checks apply. | |
| GET | / | Setup | Public. | |
| GET | / | HTML form | Public. Auto-submits the app manifest. | |
| GET | / | 302 to GitHub | Public. | |
| POST | / | ImportApp | Setup | Public, setup token required. |
Projects and services
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | / | Project[] | ||
| POST | / | {name} | Project | |
| GET | / | ProjectDetail | ||
| PATCH | / | {name} | Project | |
| DELETE | / | 204 | Tears down everything. | |
| POST | / | NewService | Service | Creates the service and runs its first deploy. |
| GET | / | Service | ||
| PATCH | / | ServicePatch | Service | |
| DELETE | / | 204 | Containers, volumes, and images. | |
| POST | / | Service | Cancels deploys, stops the container, removes routes. | |
| POST | / | Service | 409 if never deployed or fenced. | |
| POST | / | Service | 409 if stopped, never deployed, or fenced. | |
| POST | / | Service | Keeps the current data. 409 while held. | |
| GET | / | Record<string,string> | ||
| PUT | / | Record<string,string> | Record<string,string> | Replaces all variables. |
| GET | / | Record<string,string> | References expanded, injected variables included. | |
| POST | / | {host?} | Domain | No host generates one. |
| DELETE | / | 204 | ||
| POST | / | {mountPath} | Volume | |
| DELETE | / | 204 | Removes the data. |
Deployments, logs, and metrics
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | / | Deployment[] | Newest first, 50. | |
| POST | / | Deployment | Deploys the branch head or image. 409 if fenced. | |
| GET | / | Deployment | ||
| POST | / | Deployment | Reuses the image, so it is a rollback. 409 if fenced. | |
| POST | / | Deployment | ||
| GET | / | SSE | Replays the build log, follows while building. | |
| GET | / | SSE | Runtime logs: last 500 lines, then follow. | |
| GET | / | Metrics | range is 1h, 6h, 24h, or 7d. Default 1h. | |
| GET | / | HostMetrics | Same ranges. | |
| GET | / | SSE | shed's own log: last 1000 lines, then follow. |
Backups
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | / | ServiceBackups | Newest first, 100. | |
| PUT | / | BackupPolicyInput | BackupPolicy | |
| POST | / | 202, Backup | Run now. 409 if one is queued, running, or uploading. 400 without volumes or a deployment. | |
| GET | / | SystemBackups | ||
| PUT | / | BackupPolicyInput | BackupPolicy | |
| POST | / | 202, Backup | ||
| GET | / | archive | Decrypted, still zstd-compressed. Content-Disposition names it. | |
| POST | / | 202, Restore | 409 if busy or fenced. 400 for shed.db, unsuccessful, or vanished backups. | |
| DELETE | / | 204 | Local file and S3 object. 409 while active or being restored. | |
| GET | / | BackupSettings | Never includes the S3 secret. | |
| PUT | / | BackupSettingsInput | BackupSettings | |
| POST | / | BackupSettingsInput | 204 | 400 with the S3 error. A blank secret uses the stored one. |
| GET | / | { identity } | The age secret key. 404 if none. |
GitHub
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | / | Repo[] | ||
| GET | / | string[] | ||
| POST | / | 202 | Public. HMAC-signed by GitHub. |
Updates
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | / | UpdateStatus | ||
| POST | / | UpdateStatus | Checks GitHub now. 409 if unsupported or busy. | |
| POST | / | 202 UpdateStatus | 409 if unsupported, no newer release, or busy. | |
| PUT | / | {autoDownload} | UpdateStatus | |
| POST | / | 202 UpdateStatus | Then shed restarts. 409 if nothing is downloaded or busy. |
Agents (OAuth and MCP)
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | / | RFC 9728 metadata | Public, CORS. | |
| GET | / | RFC 8414 metadata | Public, CORS. | |
| POST | / | RFC 7591 JSON | 201 client | Public, CORS. 429 past the per-address limit, 503 past the cap. |
| GET | / | 302 to /authorize or sign-in | Public. 400 HTML page for an invalid request. | |
| GET | / | 302 to /authorize | Resumes a request after sign-in, once. 400 HTML page if expired. | |
| POST | / | form | token response | Public, CORS. |
| POST | / | form | 200 | Public, CORS. RFC 7009. |
| GET | / | OAuthRequest | 404 if unknown, expired, decided, or another user's. | |
| POST | / | {approve} | {redirect} | Decides the request once. |
| GET | / | OAuthGrant[] | The signed-in user's, newest first. | |
| DELETE | / | 204 | Revokes all its tokens. | |
| POST | / | JSON-RPC | JSON-RPC | MCP Streamable HTTP. Bearer token only. GET and DELETE answer 405. |
GET /* serves the dashboard with an index.html fallback, so client-side routes survive a reload. Missing assets are 404.
Types
These are the shapes behind the responses above, written as TypeScript types.
Account and setup
type User = { login: string; name: string; avatarUrl: string }; type Setup = { githubConfigured: boolean; appSlug: string; installUrl: string }; type ImportApp = { token: string; appId: number; clientId: string; clientSecret: string; webhookSecret: string; privateKey: string; };
Projects and services
type ServiceKind = "app" | "postgres" | "mysql" | "mongo" | "redis"; type ServiceStatus = "offline" | "deploying" | "active" | "failed" | "crashed" | "stopped"; type Project = { id: string; name: string; createdAt: string; services: { id: string; name: string; kind: ServiceKind; status: ServiceStatus }[]; }; type ProjectDetail = Omit<Project, "services"> & { services: Service[] }; type Service = { id: string; projectId: string; name: string; kind: ServiceKind; repo: string; branch: string; rootDir: string; image: string; dockerfilePath: string; startCommand: string; port: number; healthcheckPath: string; publicPort: number; cpuLimit: number; // cores, 0 = unlimited memoryLimit: number; // bytes, 0 = unlimited autoDeploy: boolean; waitForCi: boolean; status: ServiceStatus; privateHost: string; // "<name>" domains: Domain[]; volumes: Volume[]; latestDeployment: Deployment | null; restoreFence: RestoreFence | null; // set while a restore runs or after one failed createdAt: string; }; type RestoreFence = { restoreId: string; phase: "retaining" | "replacing" | "loading"; createdAt: string; }; // Create: kind "app" needs repo+branch or image; database kinds need only name. type NewService = { name: string; kind: ServiceKind; repo?: string; branch?: string; image?: string }; // Patch: any subset of the editable Service fields (name excluded). type ServicePatch = Partial<Pick<Service, "repo" | "branch" | "rootDir" | "image" | "dockerfilePath" | "startCommand" | "port" | "healthcheckPath" | "publicPort" | "cpuLimit" | "memoryLimit" | "autoDeploy" | "waitForCi">>; type Domain = { id: string; host: string; generated: boolean; url: string }; type Volume = { id: string; mountPath: string; createdAt: string }; type Repo = { fullName: string; defaultBranch: string; private: boolean };
Deployments
type DeploymentStatus = | "queued" | "waiting" | "building" | "deploying" | "active" | "failed" | "crashed" | "removed" | "canceled" | "skipped"; type Deployment = { id: string; serviceId: string; status: DeploymentStatus; trigger: "push" | "manual" | "redeploy" | "create"; commitSha: string; commitMessage: string; commitAuthor: string; image: string; error: string; createdAt: string; startedAt: string | null; finishedAt: string | null; };
Metrics
// Container resource usage. Sample i is at start + i*step seconds; series // are the same length, oldest first, null where nothing was running. type Metrics = { range: "1h" | "6h" | "24h" | "7d"; start: string; step: number; // step in seconds cpuLimit: number; // cores, 0 = unlimited memoryLimit: number; // bytes, 0 = unlimited cpu: (number | null)[]; // percent of one core (200 = two cores busy) memory: (number | null)[]; // bytes in use netRx: (number | null)[]; // bytes/s received netTx: (number | null)[]; // bytes/s sent diskRead: (number | null)[]; // bytes/s diskWrite: (number | null)[]; // bytes/s }; // Resource usage of the whole host, bucketed like Metrics. type HostMetrics = { range: "1h" | "6h" | "24h" | "7d"; start: string; step: number; cpus: number; // online CPUs memoryTotal: number; // bytes diskTotal: number; // bytes, filesystem holding data.dir cpu: (number | null)[]; // percent of one core (max cpus*100) memory: (number | null)[]; // bytes in use (total - available) diskUsed: (number | null)[]; // bytes used on that filesystem netRx: (number | null)[]; // bytes/s, physical interfaces netTx: (number | null)[]; diskRead: (number | null)[]; // bytes/s, physical disks diskWrite: (number | null)[]; };
Backups
type BackupCompression = "fastest" | "default" | "better" | "best"; type BackupPolicy = { enabled: boolean; schedule: string; // cron, UTC unless "CRON_TZ=<zone> ..." compression: BackupCompression; keepLocal: number; // 0 only with upload; local kept until uploaded upload: boolean; // ignored while S3 is not configured keepRemote: number; // >= 1 when upload nextRunAt: string | null; // null when disabled }; type BackupPolicyInput = Omit<BackupPolicy, "nextRunAt">; type BackupStatus = "queued" | "running" | "uploading" | "succeeded" | "failed"; type Backup = { id: string; serviceId: string | null; // null = shed.db trigger: "schedule" | "manual" | "pre-restore"; method: "dump" | "volume" | "sqlite"; status: BackupStatus; fileName: string; // download name, e.g. "postgres-20261004-030000.sql.zst" size: number; // archive bytes encrypted: boolean; local: boolean; remote: boolean; remoteError: string; error: string; createdAt: string; finishedAt: string | null; }; type Restore = { id: string; serviceId: string; backupId: string; status: "running" | "succeeded" | "failed"; error: string; createdAt: string; finishedAt: string | null; }; type ServiceBackups = { policy: BackupPolicy; backups: Backup[]; restore: Restore | null }; type SystemBackups = { policy: BackupPolicy; backups: Backup[] }; type S3Settings = { endpoint: string; // URL, e.g. "https://s3.us-east-1.amazonaws.com" region: string; bucket: string; prefix: string; accessKeyId: string; pathStyle: boolean; hasSecret: boolean; }; type BackupSettings = { s3: S3Settings | null; encryption: { enabled: boolean; recipient: string }; // recipient "" until a key exists }; // secretAccessKey omitted or "" keeps the stored secret. s3 null removes the destination. type BackupSettingsInput = { s3: (Omit<S3Settings, "hasSecret"> & { secretAccessKey?: string }) | null; encryption: { enabled: boolean }; };
Updates
type Release = { version: string; url: string; notes: string; publishedAt: string }; type UpdateStatus = { current: string; // running version, e.g. "v1.3.2" or "dev" latest: Release | null; // null until a check succeeds available: boolean; // latest is newer than current checkedAt: string | null; state: "idle" | "checking" | "downloading" | "restarting"; staged: string; // verified version ready to install, "" if none error: string; // last check or download failure, "" if none autoDownload: boolean; unsupported: string; // why this build can't update itself, "" if it can };
Agents
type OAuthRequest = { id: string; clientName: string; // self-reported at registration, not verified clientUri: string; // "" when the client registered none redirectHost: string; // where the code will be sent scopes: string[]; }; type OAuthGrant = { id: string; clientName: string; redirectHost: string; scopes: string[]; createdAt: string; lastUsedAt: string | null; // null until first used };