API
Runner HTTP API reference — auth, per-resource routes and statuses.
Base: the runner listens on 127.0.0.1:8080 in-container; Caddy serves it at api.<domain>. All routes below are prefixed with that host unless noted.
Auth
| Credential | Sent as | Used for |
|---|---|---|
RUNNER_TOKEN |
Authorization: Bearer <token> (or x-runner-token / x-host-token / x-agent-token fallback) |
Runner API, control UI server calls |
| Profile API key | Git Basic git:<key> |
git push / git fetch on smart-HTTP remotes |
| Collaborator role | Assigned per app | view = fetch only, push = create + fast-forward, admin = anything |
curl -H "Authorization: Bearer $RUNNER_TOKEN" https://api.<domain>/v1/apps
The control UI uses the bearer token server-side; browsers never see it. Git smart-HTTP verifies the API key via the UI’s /internal/git-auth and enforces the collaborator role per slug. Unknown slugs 404 at auth.
A profile API key stops working the moment its owner is banned: every consumer (git smart-HTTP’s /internal/git-auth, /mcp, and event ingest) refuses a key whose account is banned, and the ban deletes the account’s keys. An unban does not restore a deleted key — mint a new one.
Public (no credential): /health, /ready, /v1/git/*, /v1/edge/*.
/v1/edge/* is internal edge plumbing (fallback page, TLS ask gate, wake hop) — public and credential-less by design; not a client API.
Apps
| Method | Route | Notes |
|---|---|---|
GET / POST |
/v1/apps |
List; create (quota-checked, see Limits) |
GET |
/v1/apps/by-slug/{slug} |
One app by slug (the create flow and the git-auth hook have a slug, not an id) |
GET / PATCH / DELETE |
/v1/apps/{id} |
PATCH sets desired_state: running|stopped; an unknown key is rejected (422). DELETE answers 204 once the purge finished, or 202 {"deleting": true} when it still runs in the background after 3 s; the app is gone from every listing either way |
POST |
/v1/apps/{id}/rename |
Renames the app; rotates scoped credentials once a provider exists (none today — see Tenancy) |
POST |
/v1/apps/{id}/sleep |
Parks the app; next request wakes it |
Deploys
| Method | Route | Notes |
|---|---|---|
GET |
/v1/apps/{id}/deploys |
Deploy history |
GET |
/v1/apps/{id}/deploys/stream |
SSE stream of deploys |
GET |
/v1/apps/{id}/deploys/{deploy_id}/log |
Build/deploy log |
POST |
/v1/apps/{id}/deploys/{deploy_id}/cancel |
Stops an in-flight deploy; its row closes cancelled, a finished deploy is 409 |
POST |
/v1/apps/{id}/deploy?role= |
Deploys one branch’s current tip once (body: ref); push role |
POST |
/v1/apps/{id}/rollback |
Redeploys a previous deploy; wakes asleep apps first |
POST |
/v1/apps/{id}/git-remote |
Clone coordinates: url (the public remote) plus username and the internal layout (s3Remote, endpoint, bucket, prefix). No remote key |
Env
| Method | Route | Notes |
|---|---|---|
GET / POST |
/v1/apps/{id}/env |
List; set (applies from the next deploy; not visible to Worker code at runtime) |
DELETE |
/v1/apps/{id}/env/{name} |
Removes one var |
Domains
| Method | Route | Notes |
|---|---|---|
GET / POST |
/v1/apps/{id}/domains?role= |
List (view); claim a custom hostname (admin; pending in multi, verified at once in single) |
POST |
/v1/apps/{id}/domains/verify?role=admin |
Check the claim’s TXT proof; flips it to verified |
DELETE |
/v1/apps/{id}/domains/{hostname}?role=admin |
Detaches the hostname |
Source
| Method | Route | Notes |
|---|---|---|
GET |
/v1/apps/{id}/tree?ref= |
File tree at ref (branch, tag or sha; defaults to the deployed sha, else HEAD). Each file carries path, size, the git blob sha and mode (100644/100755/120000); ≤ 5000 files, truncated above that |
GET |
/v1/apps/{id}/blob/{*path}?ref= |
File contents at ref |
POST |
/v1/apps/{id}/source/commit |
Browser-edit commit: up to 100 files (text, or base64 uploads chosen by a per-file optional encoding), optional deletes paths and optional moves ({ from, to } pairs), a message and the session actor ({userId, name}), optional branch and fromSha so a protected main can send the edit to a new branch, and optional expectedTip — a compare-and-set: a branch that moved since the caller read it is refused 409 before any object is written; body cap 40 MiB (the runner caps a commit at 25 MiB decoded); wakes asleep apps |
Git forge
| Method | Route | Notes |
|---|---|---|
GET |
/v1/apps/{id}/git/refs |
Branches with tip, age, author and ahead/behind vs main, plus the deployed sha |
GET |
/v1/apps/{id}/git/log?ref=&path=&skip=&limit= |
One page of history (NUL-separated git log, 1..100 commits, nextSkip), optionally for one file |
GET |
/v1/apps/{id}/git/commits/{sha} |
One commit: metadata, body, per-file stats and the patch against its first parent (or the empty tree) |
GET |
/v1/apps/{id}/git/compare?base=&head= |
base..head commits, the three-dot diff, and whether a squash merge would conflict (git merge-tree --write-tree) |
POST |
/v1/apps/{id}/git/branches |
Create a branch (body: name, from); 409 when it exists |
DELETE |
/v1/apps/{id}/git/branches/{*name} |
Delete a branch; the default branch is refused (400) |
Reads resolve against the push mirror, which carries every pushed branch; a branch create or delete publishes exactly like a push (tip bundle, manifest, deploy when main moves).
Storage and source reads classify the backend failure: a missing object, a missing D1 table, or a not-yet-deployed source is 404, an invalid path is 400, and every other mirror/S3/celld failure is 500. (These used to be folded into 409.)
Pull requests
Same-repo branches only (no forks); merging is always a squash. actor is {userId, name} and role is the caller’s app role (view/push/admin).
| Method | Route | Notes |
|---|---|---|
GET |
/v1/apps/{id}/pull_requests?state=&skip=&limit= |
Newest first, with comment counts and per-state totals; state is open/closed/merged/all |
POST |
/v1/apps/{id}/pull_requests |
Open one (body: actor, title, body, base, head); 409 when an open PR exists for the pair |
GET |
/v1/apps/{id}/pull_requests/{number} |
PR + comments, reviews, the live base..head compare, per-comment outdated, counted approvals and the mergeState |
PATCH |
/v1/apps/{id}/pull_requests/{number} |
Edit title/body and close/reopen (state); the author or an admin |
POST |
/v1/apps/{id}/pull_requests/{number}/comments |
Comment; a line comment carries path, line, side and commitSha |
PATCH / DELETE |
/v1/apps/{id}/comments/{comment_id} |
Edit (author) or delete (author or admin) a comment |
POST |
/v1/apps/{id}/pull_requests/{number}/reviews |
approved / changes_requested at the current head; never the PR author |
POST |
/v1/apps/{id}/pull_requests/{number}/merge |
Squash-merge (title, message, deleteBranch); 409 on conflicts, insufficient approvals or a moved base |
GET / PUT |
/v1/apps/{id}/branch_rules |
Per-app protection: requirePr (the push role cannot move main directly) and requiredApprovals (0/1/2) |
Counted approvals are the latest review per reviewer that is approved, not dismissed, recorded at the current head and not by the author; admins bypass the threshold. A push moves every open PR whose head branch moved and dismisses its older reviews. Merging writes one commit whose parent is the base tip, publishes it like a push (so a merge into main deploys) and optionally deletes the head branch.
Storage
| Method | Route | Notes |
|---|---|---|
GET |
/v1/apps/{id}/storage |
Storage overview |
GET |
/v1/apps/{id}/storage/d1/{database_id}/tables |
D1 tables with row counts |
GET |
/v1/apps/{id}/storage/d1/{database_id}/tables/{table}/schema |
Table columns, foreign keys, indexes, DDL |
POST |
/v1/apps/{id}/storage/d1/{database_id}/tables/{table}/rows |
One page of rows (body: page, page_size, sort, filters, search) |
POST |
/v1/apps/{id}/storage/d1/{database_id}/write |
Execute a D1 write (insert / update / delete) |
POST |
/v1/apps/{id}/storage/d1/{database_id}/delete-rows |
Delete 1..100 rows by key in one atomic batch |
GET |
/v1/apps/{id}/storage/do/{class_name} |
Durable Object instances (id, scope, ?read=1 preview) |
GET |
/v1/apps/{id}/storage/r2/{bucket}?prefix=&cursor= |
One page of a bucket folder: folders, files (size, content type) and the continuation cursor |
GET / PUT / DELETE |
/v1/apps/{id}/storage/r2/{bucket}/object?key= |
Read a text preview / upload one object (streamed body; celld r2 put) / delete it |
POST |
/v1/apps/{id}/storage/r2/{bucket}/delete |
Delete 1..100 objects by key in one batch (body: keys) |
GET |
/v1/apps/{id}/storage/r2/{bucket}/raw?key= |
Raw object bytes with the object’s own content type |
Metrics and telemetry
| Method | Route | Notes |
|---|---|---|
GET |
/v1/apps/{id}/metrics?hours=24 |
Hourly counters (requests, 5xx errors, p50/p95 latency in ms, memory peak/mean in bytes, CPU ms) |
GET |
/v1/apps/{id}/metrics/version |
Change counter for the dashboard’s poll gate |
GET |
/v1/apps/{id}/logs |
App logs: 24 h of ingested lines plus the live stdout tail |
GET |
/v1/apps/{id}/logs/stream |
SSE log stream |
GET |
/v1/apps/{id}/errors?status=open |
Grouped errors in one status (open/resolved/ignored), with counts and a 24 h series |
GET |
/v1/apps/{id}/errors/{fingerprint} |
One error issue with its occurrences, parsed frames and logs |
PATCH |
/v1/apps/{id}/errors/{fingerprint} |
Triage: body status (open/resolved/ignored) |
GET |
/v1/apps/{id}/errors/ingest-key?role= |
The app’s error-ingest identity (numeric project id + key); view role |
POST |
/v1/apps/{id}/errors/ingest-key/rotate?role= |
Mint a replacement ingest key, keeping the project id; admin role |
GET |
/v1/apps/{id}/errors/stream?status=open |
SSE stream of grouped errors in one status (errors.list shape) |
Every observability read takes an optional environmentId (the same for logs, metrics and events): absent or empty means production.
POST /v1/errors/ingest records one Sentry envelope’s events (body: projectId, key, events) and answers { "fingerprints": [...] }; a wrong key or an unknown project number is one 404 that reveals neither.
Limits
| Method | Route | Notes |
|---|---|---|
GET |
/v1/apps/{id}/limits |
The app’s edge rate limits (requests per minute; null = platform default, 0 = off), fleet memory cap override and sleep override, beside the platform defaults |
PUT |
/v1/apps/{id}/limits |
Sets them (body: clientRpm, appRpm, memoryMb, sleepAfterS, role); admin role, otherwise 403 |
Events
| Method | Route | Notes |
|---|---|---|
GET / POST |
/v1/apps/{id}/events |
List; log an event |
GET |
/v1/apps/{id}/events/stream |
SSE event stream |
GET |
/v1/apps/{id}/events/channels |
Known channels |
POST |
/v1/apps/{id}/identify |
Identify a user |
GET |
/v1/apps/{id}/users/{user_id}/props |
Stored user props |
GET / POST |
/v1/apps/{id}/insights |
List; set an insight |
Admin
| Method | Route | Notes |
|---|---|---|
GET |
/v1/admin/stats |
Fleet-wide stats |
POST |
/v1/admin/snapshot |
Writes the state snapshot to the bucket |
Git smart-HTTP
Stock Git protocol; no git-remote-s3 on clients. Push to main to deploy.
| Method | Route | Notes |
|---|---|---|
GET |
/v1/git/{slug}/info/refs |
Advertisement (view suffices) |
POST |
/v1/git/{slug}/git-upload-pack |
Fetch (view suffices) |
POST |
/v1/git/{slug}/git-receive-pack |
Push (push = create + fast-forward; admin = anything) |
git remote add noite https://git:<key>@git.<domain>/<slug>
git push noite main
Statuses
| Code | Meaning |
|---|---|
400 |
Invalid path or body (e.g. blob, source/commit, a malformed ref) |
401 |
Missing or wrong bearer token / API key |
404 |
Unknown app, slug, deploy, key, object, table or source |
409 |
Domain already taken; conflicting sleep/wake transition; lost source/commit race |
422 |
PATCH /v1/apps/{id} body with unknown keys |
429 |
Edge rate limit (+ Retry-After) |
500 |
Runner internal failure (mirror, S3, DB) |
503 |
Fleet shedding load (+ Retry-After) or failed wake |
Every error answer is plain text: the runner’s own message (ApiError), never a store internal.