Operating

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.

Your clientbrowser · curlRoutermethod + path matchPublic routesauth · setup · webhookSession routes401 without shed_sessionUnknown /api/*404 {"error":"not found"}Dashboard (SPA)GET /* → index.html
One handler serves the API, the webhook, and the dashboard.
  • JSON, camelCase. IDs are 12-character lowercase base32 strings. Times are RFC 3339 UTC. A 204 has no body.
  • Errors are {"error": "message"} with a real status. Unexpected failures are logged and reported as a generic 500 internal error.
  • Authentication is the shed_session cookie. Auth, setup, the webhook, and the OAuth endpoints under /oauth and /.well-known are public; everything else answers 401 without a session. /mcp takes 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 need Content-Type: application/json even with an empty body. See request protections.
StatusMeaning
400Invalid input, or a request the target can't satisfy, such as a backup of a service with no volumes.
401No session, or the user is no longer on allowed_users.
403untrusted request origin.
404Unknown route or id.
409Conflict: a name in use, a service busy with a backup or restore, fenced, stopped, or being deleted.
413 / 415Webhook body too large, or a mutation without a JSON content type.
429The webhook guard is full, or too many client registrations from one address.
502 / 503GitHub 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.

sh
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

sh
curl -s https://shed.example.com/api/projects \
  -H 'Cookie: shed_session=<your session token>'
response
[
  {
    "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.

sh
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"}:

409 Conflict
{ "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 command builder
sh
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

MethodPathBodyResponseNotes
GET/api/meUser
GET/api/auth/login?next=302 to GitHubPublic. Sets the state cookie. next, a path on this server, is where sign-in ends.
GET/api/auth/callback302 to next or /Public. Sets shed_session.
POST/api/auth/logout204Public route, origin and JSON checks apply.
GET/api/setupSetupPublic.
GET/api/setup/github?token=HTML formPublic. Auto-submits the app manifest.
GET/api/setup/github/callback?code=&state=302 to GitHubPublic.
POST/api/setup/github/importImportAppSetupPublic, setup token required.

Projects and services

MethodPathBodyResponseNotes
GET/api/projectsProject[]
POST/api/projects{name}Project
GET/api/projects/{id}ProjectDetail
PATCH/api/projects/{id}{name}Project
DELETE/api/projects/{id}204Tears down everything.
POST/api/projects/{id}/servicesNewServiceServiceCreates the service and runs its first deploy.
GET/api/services/{id}Service
PATCH/api/services/{id}ServicePatchService
DELETE/api/services/{id}204Containers, volumes, and images.
POST/api/services/{id}/stopServiceCancels deploys, stops the container, removes routes.
POST/api/services/{id}/startService409 if never deployed or fenced.
POST/api/services/{id}/restartService409 if stopped, never deployed, or fenced.
POST/api/services/{id}/restore-fence/clearServiceKeeps the current data. 409 while held.
GET/api/services/{id}/variablesRecord<string,string>
PUT/api/services/{id}/variablesRecord<string,string>Record<string,string>Replaces all variables.
GET/api/services/{id}/variables/resolvedRecord<string,string>References expanded, injected variables included.
POST/api/services/{id}/domains{host?}DomainNo host generates one.
DELETE/api/domains/{id}204
POST/api/services/{id}/volumes{mountPath}Volume
DELETE/api/volumes/{id}204Removes the data.

Deployments, logs, and metrics

MethodPathBodyResponseNotes
GET/api/services/{id}/deploymentsDeployment[]Newest first, 50.
POST/api/services/{id}/deploymentsDeploymentDeploys the branch head or image. 409 if fenced.
GET/api/deployments/{id}Deployment
POST/api/deployments/{id}/redeployDeploymentReuses the image, so it is a rollback. 409 if fenced.
POST/api/deployments/{id}/cancelDeployment
GET/api/deployments/{id}/logsSSEReplays the build log, follows while building.
GET/api/services/{id}/logsSSERuntime logs: last 500 lines, then follow.
GET/api/services/{id}/metrics?range=Metricsrange is 1h, 6h, 24h, or 7d. Default 1h.
GET/api/host/metrics?range=HostMetricsSame ranges.
GET/api/logsSSEshed's own log: last 1000 lines, then follow.

Backups

MethodPathBodyResponseNotes
GET/api/services/{id}/backupsServiceBackupsNewest first, 100.
PUT/api/services/{id}/backups/policyBackupPolicyInputBackupPolicy
POST/api/services/{id}/backups202, BackupRun now. 409 if one is queued, running, or uploading. 400 without volumes or a deployment.
GET/api/backups/systemSystemBackups
PUT/api/backups/system/policyBackupPolicyInputBackupPolicy
POST/api/backups/system202, Backup
GET/api/backups/{id}/downloadarchiveDecrypted, still zstd-compressed. Content-Disposition names it.
POST/api/backups/{id}/restore202, Restore409 if busy or fenced. 400 for shed.db, unsuccessful, or vanished backups.
DELETE/api/backups/{id}204Local file and S3 object. 409 while active or being restored.
GET/api/backups/settingsBackupSettingsNever includes the S3 secret.
PUT/api/backups/settingsBackupSettingsInputBackupSettings
POST/api/backups/settings/testBackupSettingsInput204400 with the S3 error. A blank secret uses the stored one.
GET/api/backups/settings/key{ identity }The age secret key. 404 if none.

GitHub

MethodPathBodyResponseNotes
GET/api/github/reposRepo[]
GET/api/github/repos/{owner}/{repo}/branchesstring[]
POST/api/github/webhook202Public. HMAC-signed by GitHub.

Updates

MethodPathBodyResponseNotes
GET/api/updateUpdateStatus
POST/api/update/checkUpdateStatusChecks GitHub now. 409 if unsupported or busy.
POST/api/update/download202 UpdateStatus409 if unsupported, no newer release, or busy.
PUT/api/update/settings{autoDownload}UpdateStatus
POST/api/update/install202 UpdateStatusThen shed restarts. 409 if nothing is downloaded or busy.

Agents (OAuth and MCP)

MethodPathBodyResponseNotes
GET/.well-known/oauth-protected-resource[/mcp]RFC 9728 metadataPublic, CORS.
GET/.well-known/oauth-authorization-serverRFC 8414 metadataPublic, CORS.
POST/oauth/registerRFC 7591 JSON201 clientPublic, CORS. 429 past the per-address limit, 503 past the cap.
GET/oauth/authorize?…302 to /authorize or sign-inPublic. 400 HTML page for an invalid request.
GET/oauth/authorize?request=302 to /authorizeResumes a request after sign-in, once. 400 HTML page if expired.
POST/oauth/tokenformtoken responsePublic, CORS.
POST/oauth/revokeform200Public, CORS. RFC 7009.
GET/api/oauth/requests/{id}OAuthRequest404 if unknown, expired, decided, or another user's.
POST/api/oauth/requests/{id}{approve}{redirect}Decides the request once.
GET/api/oauth/grantsOAuthGrant[]The signed-in user's, newest first.
DELETE/api/oauth/grants/{id}204Revokes all its tokens.
POST/mcpJSON-RPCJSON-RPCMCP 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

title="TypeScript"
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

title="TypeScript"
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

title="TypeScript"
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

title="TypeScript"
// 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

title="TypeScript"
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

title="TypeScript"
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

title="TypeScript"
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
};