API reference

Exhaustive per-endpoint contracts. For how to actually use them, start with Running flows or the dashboard guide.

Every deployed instance publishes its machine-readable OpenAPI 3.1 contract at GET /openapi.json. The document is intentionally available without authentication so clients and development tools can discover authentication requirements before they are configured. Its info.version identifies the version deployed at that origin. A self-hosted interactive viewer is available at /api-docs; it loads the same JSON document.

Timestamps are epoch milliseconds as JSON numbers, everywhere.

Authentication

Three credential types. Requests are checked in this order, and an explicit credential always wins over an ambient cookie.

Credential Presented as Names its org how?
Org token Authorization: Bearer <token>, or ?token= Implicitly — the token is bound to one org
Personal access token Authorization: Bearer <token> X-Org-Id: <numeric id>, or ?orgId=
Session cookie Set by POST /api/session X-Org-Id: <numeric id>, or ?orgId=

The ?token= and ?orgId= query fallbacks exist because EventSource, <img> and <a> cannot set custom headers.

A bearer token never carries a person's identity. If an org token is presented alongside a stale admin cookie, the cookie identity is stripped — otherwise a CI token beside an admin session would look like an admin request.

Auth error responses

Status Body When
401 {"error":"missing bearer token"} No credential of any kind
403 {"error":"invalid token"} Token not found or revoked
403 {"error":"this token belongs to a person, not an org - name the org with the X-Org-Id header"} PAT with no (or non-integer) X-Org-Id
403 {"error":"not a member of that org"} PAT naming an org you are not in, or an archived one
403 {"error":"admin access required"} Non-admin on /api/admin/*
401 {"error":"login required"} /api/orgs/* or /api/me/orgs with no identity
401 {"error":"this needs a logged-in session, not a token"} PAT on a token-management route

CI API

Flat paths, no /api prefix. These response shapes are frozen — a contract test suite pins them, because existing CI integrations depend on them. Nothing here will change shape.

POST /run

multipart/form-data. Queues flows to run against whichever enrolled device is free. Devices are a pool — many can be enrolled at once, jobs run concurrently across them (subject to maxConcurrentJobs, see Operating the farm), and a job is bound to a device only when the scheduler claims it, never at submit time.

Parts

Part Type Required Meaning
apk file yes The built testing APK
flowsArchive file yes .tar.gz of the e2e/ directory
flows field yes JSON array of flow identifiers, non-empty, all strings
env field no JSON object of string → string, forwarded as -e KEY=VALUE

202 Accepted — { "id": string, "status": "queued" }

Errors

Status Body Cause
400 {"error":"apk file is required"} Missing apk
400 {"error":"flowsArchive file is required"} Missing flowsArchive
400 {"error":"flows must be a non-empty JSON array of strings"} Malformed flows
400 {"error":"env must be a JSON-encoded object"} env not a string field
400 {"error":"env is not valid JSON"} env unparseable
400 {"error":"env must be a JSON object of string values"} env is an array, null, or scalar
400 {"error":"env key \"…\" is not a valid identifier"} Key fails ^[A-Za-z_][A-Za-z0-9_]*$
400 {"error":"env value for KEY must be a string"} Non-string value
400 {"error":"env value for KEY must not contain a newline or NUL"} Value contains \n, \r or NUL
400 {"error":"env value for KEY is too short to mask safely (minimum 4 characters)"} Secret-named key with a value under 4 characters
413 {"error":"apk exceeds the upload limit of 512 MB - an admin can change it on the Admin page"} A file above the configured cap
400 {"error":"unexpected file field \"…\""} A file part other than apk or flowsArchive
400 {"error":"upload rejected: LIMIT_FILE_COUNT"} More than two files, or an implausible number of parts or fields
503 {"error":"no device is enrolled - add a device and enable it from the Devices page first"} The pool is empty — no device enrolled at all. Never returned just because every enrolled device is currently busy; a job queues instead

Every 400 deletes the uploaded files before responding — a rejected request never leaves uploads on disk. The same holds on the 413 path.

Upload cap. Each file is capped at an admin-configurable limit, 512 MB by default, editable under Admin → Uploads. A saved change applies to the next request; no restart. An admin can clear the limit entirely, restoring unbounded uploads. See Operating the farm.

GET /run/:id

200

{
  "id": "string",
  "org": "string",
  "flows": ["string"],              // flow steps, in order
  "status": "queued|running|done|failed",
  "results": [
    { "flow": "string", "passed": true, "stdout": "string", "stderr": "string" }
  ],
  "error": "string",                // present only when status is "failed"
  "createdAt": 1756800000000
}

results holds one entry per flow that reached a verdict. If a setup stage failed instead, results holds exactly one pseudo-entry named <device>, <install> or <extract>, with passed: false.

This shape does not carry deviceId, even though the pool means a job now runs on one of several devices — the frozen CI contract fixes this exact key set, so which device ran a job is a dashboard-API detail (GET /api/jobs/:id's job.deviceId), not a CI one.

404 — {"error":"job not found"}. Also returned for a job owned by a different org: existence is never confirmed across org boundaries.

GET /jobs

200 — a JSON array of the same objects as GET /run/:id, newest first, capped at 200. The cap is a response limit, not retention.

GET /status

200 — a small self-refreshing HTML page listing the caller's jobs. Cache-Control: no-store, Vary: Cookie.


Dashboard API

Everything under /api/*. Free to evolve; not covered by the frozen contract suite.

Session

Not behind the org gate — these routes do their own checks, because login must be reachable by someone who is not authenticated yet.

POST /api/session

Body { username, password }. 200 — { user: { id, username, email, isAdmin }, orgs: [...] }, plus a Set-Cookie with an httpOnly, Secure, SameSite=Lax session cookie. 400 missing fields. 403 {"error":"invalid username or password"} — returned identically for an unknown username, a wrong password, and a deactivated account, so none can be distinguished.

GET /api/session

200 — same shape as above. 401 {"error":"not logged in"}.

PATCH /api/session/password

Body { currentPassword, newPassword }. Self-service; proves the caller still is who the session says, so an unattended session cannot lock the owner out. 200 { ok: true }. 400 missing fields or too-short new password. 403 {"error":"current password is incorrect"}. 401 not logged in.

DELETE /api/session

204, clearing the cookie.

Identity and personal tokens

GET /api/me/orgs

Accepts a session or a PAT, with no org named — this is the discovery call. 200 — { orgs: [{ id, name }] }. 401 {"error":"login required"}.

GET /api/me/tokens

Session only. 200 — { tokens: [{ id, userId, label, value, createdAt, lastUsedAt }] }, value in plaintext, Cache-Control: no-store.

POST /api/me/tokens

Session only. Body { label }. 201 — { token: { … } }, the new token in plaintext. 400 {"error":"label is required"}.

DELETE /api/me/tokens/:tokenId

Session only. 204.

A PAT is rejected on the three token routes above by design: a token that could mint tokens could mint its own successor and outlive its revocation.

Orgs

All require a logged-in member of :id. Admins bypass the membership check for any existing org. A non-member gets 404, not 403 — no confirmation the org exists. An archived org is 404 for everyone but admins.

Route Success Notes
GET /api/orgs/:id/members 200 { members: [...] }
POST /api/orgs/:id/members 201 { ok: true } Body { userId: number }. 400 if missing, 404 {"error":"unknown user"}
DELETE /api/orgs/:id/members/:userId 204 409 {"error":"cannot remove the last member of an org"}
GET /api/orgs/:id/tokens 200 { tokens: [...] } Plaintext values, Cache-Control: no-store
POST /api/orgs/:id/tokens 201 { token: {...} } Body { label }; 400 if missing
DELETE /api/orgs/:id/tokens/:tokenId 204

Admin

All require req.isAdmin; a bearer-token request never sets it, so CI credentials are rejected here too. All return 403 {"error":"admin access required"} otherwise.

Route Success Notes
GET /api/admin/orgs 200 { orgs: [...] }
POST /api/admin/orgs 201 { org } Body { name, initialMemberUserIds? }. An absent or empty member list falls back to the creating admin — a zero-member org would be unreachable forever. 409 on duplicate name
PATCH /api/admin/orgs/:id 200 { org } Body { name }. 404 unknown org
DELETE /api/admin/orgs/:id 204 Archives, does not delete
GET /api/admin/users 200 { users: [...] }
POST /api/admin/users 201 { user } Body { username, email, password, isAdmin? }. 409 on duplicate username
PATCH /api/admin/users/:id 200 { user } Body { isAdmin?, password? }. A too-short password 400s before any change, so a 200 always means the reset happened
DELETE /api/admin/users/:id 204 Deactivates, does not delete
GET /api/admin/retention 200 { maxAgeDays, maxTotalBytes, maxJobs, totalArtifactBytes }
PUT /api/admin/retention 200 Same shape. Each threshold is a non-negative integer or null (disabled); anything else 400s
GET /api/admin/upload 200 { maxUploadBytes, effectiveMaxUploadBytes, defaultMaxUploadBytes } — stored value, value enforced right now, and the built-in default
PUT /api/admin/upload 200 Same shape. Body { maxUploadBytes }: a positive integer of bytes, or null for no limit. 0, negatives, non-integers and an absent key all 400 — 0 would mean "reject every upload", and an omitted key must not read as "unlimited"
GET /api/admin/scheduler 200 { maxConcurrentJobs } — null means no cap
PUT /api/admin/scheduler 200 Same shape. Body { maxConcurrentJobs }: a positive integer, or null for no cap. An absent key 400s — see Operating the farm for when to lower it

Jobs

GET /api/jobs?limit=<n>

200 — { jobs: [...] }, newest first. Each job carries the row fields plus a cheap progress summary: flowCount, passed, failed, running. limit defaults to 50, clamped to 1–200 at both ends.

GET /api/jobs/:id

200 — { job, steps }, where each step includes its artifacts array.

{
  // envJson: JSON string of the env this job ran with, secret values already
  // masked — or null if none was sent. This is an implementation detail,
  // not a stable integration contract.
  // deviceId: the enrolled device this job ran (or is running) on, set the
  // moment the scheduler claims it - null while still queued.
  "job":  { "id", "org", "status", "error", "createdAt", "startedAt", "finishedAt", "envJson", "deviceId" },
  "steps": [{
    "id": 42, "jobId": "…", "ordinal": 0,
    "kind": "setup|flow",
    "name": "map/filter",
    "status": "queued|running|passed|failed|skipped",
    "exitCode": 0, "startedAt": …, "finishedAt": …,
    "resolvedPath": "…", "artifactDir": "…",
    "warnings": ["…"],
    "stdoutPreview": "…", "stderrPreview": "…",
    "artifacts": [{ "id": 7, "flowRunId": 42, "kind": "screenshot", "relPath": "…", "bytes": 1234, "createdAt": … }]
  }]
}

404 — {"error":"job not found"}, including for another org's job.

GET /api/jobs/:id/events

Server-Sent Events, text/event-stream. Emits the full GET /api/jobs/:id payload immediately, then again on every step landing, and closes the stream once the job reaches done or failed. 404 if the job is not yours.

Files

GET /api/flow-runs/:id/log?stream=stdout|stderr

:id is a step id. stream defaults to stdout. 200 text/plain; charset=utf-8. 404 {"error":"log not found"}.

GET /api/artifacts/:id

200 raw bytes, Content-Type from the file extension (.png, .jpg, .jpeg, .mp4, .webm, .json, .xml, .log; otherwise application/octet-stream).

Supports HTTP Range: Accept-Ranges: bytes always, 206 Partial Content with Content-Range for a valid Range header, 416 with Content-Range: bytes */<size> for an unsatisfiable one. A malformed Range falls back to a normal 200 rather than erroring.

404 {"error":"artifact not found"} — unknown id, another org's artifact, or a row whose file is gone.

Ownership is derived from the foreign-key chain (artifact → step → job → org), never from parsing the stored path.

Both file routes send Cache-Control: no-store, Vary: Cookie and X-Content-Type-Options: nosniff: these responses can carry screenshots of a real logged-in app session.

Devices

Farm-wide, not org-scoped, but still behind the org gate. Devices are a pool: any number can be registered, and any number of enrolled (isActive: true) devices can hold a running job at the same time — there is no single "the active device" anymore. isActive marks pool membership, not which one is currently in use.

GET /api/devices

200 — { devices: [{ id, label, model, isActive, state, resetPolicy, setupChecks: [{ id, title, detail, ok }] }], runnerVersion, artifactBytes }.

setupChecks has one entry per required device setting; ok is null when it could not be read (device not device, or the read failed), so only false is a warning. The model and every check are read in one bounded adb shell call per online device.

state comes from a single bounded (5s) adb devices call answering for every row at once. A device not currently attached reports "offline" and is never removed. artifactBytes is farm-wide — the live running total across every device, at the top level of the response, not per device. 502 {"error":"device status unavailable"} if adb fails.

POST /api/devices

Body { id, label? }, where id is the adb serial. 201 — { device, setupInstructions: [...] }. Performs no adb calls. 400 missing id. 409 already registered.

POST /api/devices/:id/enable

Adds the device to the pool. 200 — { device }. Idempotent: already-enabled returns 200 unchanged. 404 {"error":"unknown device"}. No busy-job guard — enabling a free device disrupts nothing.

POST /api/devices/:id/disable

Drains the device: it finishes any job it currently holds but is never claimed again after that. Does not interrupt a running flow. 200 — { device }. Idempotent. 404 unknown device.

POST /api/devices/:id/activate

Deprecated alias for enable, kept only so a dashboard tab open from before this deploy keeps working. Same response shape as enable. Prefer enable/disable in new code.

DELETE /api/devices/:id

Removes a device permanently — the "no way to remove a device" gap from earlier cycles is closed. 204. 404 unknown device. 409 {"error":"device is still enrolled - disable it first"} if isActive. 409 {"error":"a job is still running on this device - wait for it to finish"} if it currently holds a running job.

POST /api/devices/:id/reboot

202 — { ok: true }. 404 unknown device. 409 if this device currently holds a running job — per-device, not farm-wide, so a busy device never blocks rebooting an idle one. 502 if adb reboot fails.

POST /api/devices/:id/setup-checks/:checkId/fix

Admin only. Applies that check's setting over adb, then reads it back. 200 — { check: { id, title, detail, ok: true } }, recorded as a setup_fix device event. 403 not an admin. 404 unknown device or check. 409 device not connected and authorized. 502 the write failed, or the read-back did not show the new value (setting did not take effect (read back: ...)) - nothing is recorded.

GET /api/devices/:id/screenshot

200 image/png — one live frame, streamed straight through, never written to disk or added to the artifacts table. 404 unknown device. 502 on capture failure.

GET /api/devices/:id/events?limit=<n>

200 — { events: [{ id, deviceId, kind, detail, createdAt }] }, newest first. kind is one of added, activated, state_change, reboot, screenshot. limit defaults to 30, clamped 1–100.

GET /api/version

200 — { version, maestroVersion }.

MCP client bundle

GET /api/mcp-client

200 — the bundled client as text/javascript, Content-Disposition: attachment; filename="mcp.mjs". 503 {"error":"the MCP client bundle is not available in this build"} if this build was not packaged with one.

View this page on GitHub ↗ · nmehlei/DeviceYard