Docs · reference

REST API

Every route of the denv control plane, with auth, request and response shapes, and a curl example.

Base URL: the control plane (https://api.denv.ai for the managed service, http://localhost:8080 locally). All request and response bodies are JSON unless noted. Response field names for stored objects are the Go struct field names (ID, Name, CurrentVersionID, …); request field names are lower snake case.

Authentication

Credential Header Used by
Team API key dak_… Authorization: Bearer dak_… CLI, CI, Python adapters. Minted by the device flow or POST /v1/keys. Revoked keys are refused.
User session dst_… Authorization: Bearer dst_… plus optional X-Denv-Team: <team id> Console. Issued by GitHub sign-in, valid 30 days. Without the header, the user's first team is used.
None — /health, /auth/*, device code and token, /denvd*.
Host secret / join token inside the WebSocket hello frame /v1/hosts/stream (sidecars only).
Webhook signature X-Hub-Signature-256 /webhooks/github.

Auth columns below: key (API key or session), user (session only), none.

Errors

Every error is {"error": "<message>"} with an HTTP status. Common statuses: 400 bad input, 401 bad credential, 403 session without a team or not a member, 404 not found or not in your team, 409 conflict (duplicate name, used token), 413 body too large, 428 device authorization pending, 502 the host or an upstream refused the command, 503 feature not configured on this server, 504 sandbox did not become healthy in time.

CORS

When DENV_CONSOLE_URL is set, /v1/* and /auth/* answer CORS for that origin and for http://localhost:* / http://127.0.0.1:*, allowing Authorization, Content-Type, X-Denv-Team and GET, POST, PUT, DELETE, OPTIONS. OPTIONS returns 204.

The examples below assume:

export DENV_SERVER=http://localhost:8080 DENV_API_KEY=dak_…

Auth, teams, and keys

GET /auth/github/start — none

Query return (optional): where to send the browser after sign-in. Must be the console origin or a loopback URL; default <DENV_CONSOLE_URL>/app/. Redirects (302) to GitHub with scopes read:user user:email. 503 when GitHub sign-in is not configured.

curl -i "$DENV_SERVER/auth/github/start?return=http://localhost:3100/app/"

GET /auth/github/callback — none

GitHub's redirect target. Creates or updates the user, gives a first-time user a personal team named after their login, mints a session, and redirects to <return>#token=dst_…. 400 on unknown or expired state.

POST /auth/dev-login — none

Only when the server runs with DENV_DEV_LOGIN=1; otherwise 404. Body {email, name?}. Response {token: "dst_…"}.

curl -s -X POST $DENV_SERVER/auth/dev-login -H 'Content-Type: application/json' -d '{"email":"you@example.com"}'

POST /v1/device/code — none

Starts a CLI login. Response:

{"device_code":"ddc_…","user_code":"XXXX-XXXX","verification_uri":"<console>/app/device/","verification_uri_complete":"<console>/app/device/?code=XXXX-XXXX","expires_in":900,"interval":5}

POST /v1/device/approve — user

Body {user_code, team_id, name?}. The caller must be a member of team_id. Mints an API key named name (default cli) and marks the code approved. Response {approved: true, key_id, team_id}.

POST /v1/device/token — none

Body {device_code}. 428 {"error":"authorization_pending"} until approved; 400 {"error":"invalid_device_code"} for an unknown code; 410 if the approved key was already handed out. Response {api_key: "dak_…", team: {ID, Name, CreatedAt}}. The key is returned exactly once.

GET /v1/me — key

Response {team, teams: [{ID, Name, CreatedAt, Role}], user?}. With an API key, teams has one entry with role key; with a session, user is present and teams lists memberships.

curl -s $DENV_SERVER/v1/me -H "Authorization: Bearer $DENV_API_KEY"

GET /v1/teams — user

Response {teams: [{ID, Name, CreatedAt, Role}]}.

POST /v1/teams — user

Body {name}. Creates a team with the caller as owner. Response 201 {team}.

GET /v1/keys — key

Response {keys: [{ID, TeamID, Name, CreatedBy, CreatedAt, LastUsedAt, RevokedAt}]}. Key material is never returned.

POST /v1/keys — key

Body {name?} (default key). Response 201 {key: {…}, api_key: "dak_…"}; api_key is shown once.

curl -s -X POST $DENV_SERVER/v1/keys -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' -d '{"name":"ci"}'

DELETE /v1/keys/{id} — key

Revokes a key of the caller's team. Response {revoked: true}; 404 if not found.

Projects

GET /v1/templates — key

Query kind (optional). Response {kinds: ["rl-environment","agent"], templates: [{name, kind, description, requires?, compose, warm?}]}.

GET /v1/projects — key

Response {projects: [{ID, TeamID, Name, Kind, Source, CreatedAt}]}.

POST /v1/projects — key

Body {name, kind?, source?}. kind is rl-environment (default) or agent. Creates the project and its alpha environment. Response 201 {project, environments: [alpha]}; 409 on a duplicate name.

curl -s -X POST $DENV_SERVER/v1/projects -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"ops-world","kind":"rl-environment","source":"github:you/ops-world"}'

GET /v1/projects/{project}/envs — key

{project} is an id or a name. Response {environments: [{ID, ProjectID, Name, Stage, HostID, ParentVersionID, CurrentVersionID, Status, CreatedAt, Endpoints}]}.

POST /v1/projects/{project}/deploy — key

Body {env?, compose, warm?}. env defaults to alpha; compose is the compose file text; warm is an argv array run after health. The compose file is parsed and validated here (400 with the reason on refusal). Response 202 {deployment, phases: [...]}.

curl -s -X POST $DENV_SERVER/v1/projects/ops-world/deploy -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg c "$(cat compose.yaml)" '{env:"alpha",compose:$c}')"

GET /v1/projects/{project}/source — key

Response {source: {ProjectID, Repo, DefaultBranch, ComposePath, InstallationID, DeployBranches, LastDeployedSHA}}.

POST /v1/projects/{project}/source — key

Body {default_branch?, compose_path?, installation_id?, deploy_branches?}. Empty strings and 0 leave a field unchanged; deploy_branches is a nullable boolean. installation_id must be an installation linked to the team. Response {source}.

POST /v1/projects/{project}/deploy-from-source — key

Deploys from GitHub. Body {branch?, env?}: branch defaults to the repo's default branch; env defaults to alpha for the default branch, otherwise the branch name with every character outside [a-z0-9] replaced by -. Requires the GitHub App (503 otherwise) and a linked installation that can see the repo (409 otherwise). Response 202 {deployment, phases, branch, env}.

Environments

GET /v1/envs/{env} — key

Response {environment, connected} where connected reports whether the environment's host currently has a stream (or is a driver host).

GET /v1/envs/{env}/versions — key

Response {versions: [{ID, ProjectID, EnvID, Number, Spec, Snapshot, Healthy, CreatedAt}]}.

GET /v1/envs/{env}/deployments — key

Response {deployments: [{ID, ProjectID, EnvID, Kind, Status, VersionID, TargetEnvID, Params, Error, CreatedAt, FinishedAt}]}.

POST /v1/envs/{env}/fork — key

Body {name?, version?}. version defaults to the current version and must be a healthy snapshot. Response 202 {deployment, phases}; the new environment's id is deployment.EnvID.

curl -s -X POST $DENV_SERVER/v1/envs/env_01…/fork -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' -d '{"name":"rollout-01"}'

POST /v1/envs/{env}/snapshot — key

No body. Captures live state as a new version of the same environment. Response 202 {deployment, phases}; the version id is deployment.VersionID.

POST /v1/envs/{env}/rollback — key

Body {version}. The version must be a healthy snapshot of this environment. Response 202 {deployment, phases}.

POST /v1/envs/{env}/verify — key

Body {resume?}. Requires the environment's version to declare x-denv.agent.service and x-denv.verifier (400 otherwise). Response 202 {deployment, phases}. Read the reward from GET /v1/deployments/{dep}: the verdict phase's Evidence has reward, source, exit_code, refused, missing, agent_frozen, took_ms.

POST /v1/envs/{env}/exec — key

Body {service, cmd: [...], timeout_sec?} (default 120 s). Response 200 {exit_code, stdout, stderr}. A non-zero exit code is still 200; 502 means the host could not run it.

curl -s -X POST $DENV_SERVER/v1/envs/env_01…/exec -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
  -d '{"service":"opsbox","cmd":["sh","-c","id -u"]}'

PUT /v1/envs/{env}/files — key

Query service, path (both required). Body: a tar archive (Content-Type: application/x-tar), at most 32 MiB (413 otherwise). Extracted into path inside the service. Response {ok: true, bytes}.

tar -C ./fixtures -cf - . | curl -s -X PUT "$DENV_SERVER/v1/envs/env_01…/files?service=opsbox&path=/home/ubuntu/fixtures" \
  -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/x-tar' --data-binary @-

GET /v1/envs/{env}/files — key

Query service, path. Response: a tar archive of path (application/x-tar). A missing path is 502 with the host's error.

DELETE /v1/envs/{env} — key

Starts a destroy deployment. Response 202 {deployment}.

Deployments and sandboxes

GET /v1/deployments/{dep} — key

Response {deployment, phases: [{DeploymentID, Seq, Name, Status, Evidence, Error, StartedAt, FinishedAt}]}. Evidence is a JSON object whose keys depend on the phase; see Deployments.

POST /v1/sandboxes — key

One environment for one episode, returned when healthy. Body, one of two shapes:

Field Type Meaning
from_version string Fork this healthy version (whole stack, data included).
image string Or: one container from this image.
cmd string[] Command for the container (image form).
env object Environment variables (image form).
ports string[] Container ports to publish (image form). Needs egress: allow.
volumes string[] Container paths that persist and fork; default ["/work"] (image form).
egress deny | allow Default deny.
project string Project for image sandboxes; default sandboxes, created on demand.
name string Environment name; default sbx-<n>.
timeout_sec int How long to wait for health; default 600.

Response 201 {environment, deployment}; 502 if the deployment failed; 504 on timeout.

curl -s -X POST $DENV_SERVER/v1/sandboxes -H "Authorization: Bearer $DENV_API_KEY" -H 'Content-Type: application/json' \
  -d '{"image":"python:3.12-slim","cmd":["sleep","infinity"]}'

Targets, clouds, and hosts

GET /v1/clouds — key

Response {clouds: [{ID, TeamID, Provider, Name, Config, CreatedAt}]}; token, and any config key containing secret or key, is replaced by •••.

POST /v1/clouds — key

Body {provider, name?, config?}. Providers: manual, hetzner (config token, server_type default cpx31, location default nbg1, image default ubuntu-24.04), e2b and daytona (config token; untested against the live provider). The provider validates the config first (400). Response 201 {cloud}.

POST /v1/join-tokens — key

Body {cloud_id?}. Response 201 {token: "djt_…", expires_in_sec: 3600, command}.

GET /v1/hosts — key

Response {hosts: [{ID, TeamID, CloudID, Name, Status, ProviderRef, LastSeenAt, CreatedAt, Capabilities, connected}]}. Capabilities is the sidecar's report (see Protocol) or the provider's capability row.

GET /v1/targets — key

Hosts of both kinds. Response {targets: [{…host fields, kind, provider?, connected, capabilities, table?}]} where kind is sidecar or driver:<provider> and table is the static capability row for a hosted provider. Sidecars are listed first.

POST /v1/targets/connect — key

Body {provider, token?, name?, config?} for a hosted sandbox provider (e2b, daytona). Validates the token, records the cloud, and creates its driver host in one step. Response 201 {cloud, host, info, capabilities}.

POST /v1/targets/{name}/sandboxes — key

POST /v1/sandboxes pinned to the cloud named {name}. Image form only (400 otherwise); 404 if no such cloud. Response 201 {environment, deployment, target}.

GET /v1/hosts/stream — sidecar

WebSocket endpoint for denvd. Not for API clients.

GitHub

POST /webhooks/github — webhook

Verified with X-Hub-Signature-256 against DENV_GITHUB_WEBHOOK_SECRET (401 on mismatch; 503 when the app is not configured; body limit 2 MiB). Handles installation (created, unsuspend, new_permissions_accepted, deleted), push, ping. A push to a branch deploys every project whose source is that repo: the default branch to alpha; other branches to the branch-named environment only if it already exists or the project has deploy_branches on. Response {ok, sha, branch, projects: [{project, deployment?, env?, skipped?, error?}]}.

GET /v1/github/installations — key

Response {installations: [{InstallationID, TeamID, AccountLogin, CreatedAt}], configured}.

POST /v1/github/installations/{id}/link — key

Binds an installation the app has reported to the caller's team. 404 if the installation has not been seen; 409 if already linked elsewhere. Response {installation}.

GET /v1/github/repos?installation={id} — key

Repositories the linked installation can see. Response {repos: [{full_name, default_branch, private}]}.

Releases

GET /denvd, GET /denvd-linux-amd64, GET /denvd-linux-arm64 — none

The sidecar binary from the server's releases directory (DENV_RELEASES_DIR, default bin). /denvd is amd64 (?arch=arm64 selects the other). 404 when that build is not present on the server.

Events

GET /v1/events?subject=… — key

subject is env:<id>, dep:<id>, host:<id>, or cmd:<id> (progress lines). Up to 200 events, newest first, filtered to the caller's team. Response {events: [{ID, TeamID, Subject, Kind, Body, At}]}.

GET /health — none

{"status":"ok"}.