API reference

An API key is a long-lived machine credential for the same oRPC endpoint the website itself uses (/api/rpc). It lets scripts, CI jobs, and your own tools list sessions, start and stop workspaces, and check your balance — without a browser login.

Create and revoke keys on the API keys page. The secret is shown exactly once at creation: it is SHA-256 hashed at rest and can never be recovered afterwards. Only its prefix (e.g. dg_ab12cd34…) stays visible so you can tell keys apart. Keys never expire — they work until you revoke them, and revocation deletes the key instantly.

Authentication

Send the key on every request as a Bearer token:

http
Authorization: Bearer dg_YOUR_KEY

Keys look like dg_ followed by 48 hex characters. All calls below fail with 401 when the header is missing, the key is unknown, or the key was revoked.

A key acts as you: it can only see and manage your own sessions and your own balance. It is rejected with 403 for the cookie-only namespaces auth.* (login, token refresh) and apikey.* (key management) — a leaked key can't log in as you or mint more keys.

Calling convention

Every call is a JSON POST to https://dg.run/api/rpc/<path>:

  • Header Content-Type: application/json plus the Authorization header above.
  • The procedure input is wrapped in an envelope: {"json": <input>}.
  • The output comes back wrapped the same way: {"json": <output>}.

Empty inputs still need the envelope ({"json":{}}).

Browser apps on other origins can call the endpoint directly: it answers CORS preflights (OPTIONS) and serves Access-Control-Allow-Origin: * with no credentials, so fetch with an Authorization header works cross-origin while the login cookies are never sent (or readable) outside this site.

A minimal example:

bash
KEY="dg_YOUR_KEY" curl -s -X POST "https://dg.run/api/rpc/wallet/balance" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{}}' # {"json":{"balance":1.23}}

From Node.js (or any fetch runtime), one helper covers every endpoint:

js
const BASE = "https://dg.run"; const KEY = process.env.DG_API_KEY; // never hard-code keys async function call(path, input) { const res = await fetch(`${BASE}/api/rpc/${path}`, { method: "POST", headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json", }, body: JSON.stringify({ json: input }), }); if (!res.ok) throw new Error(`${path}: HTTP ${res.status}: ${await res.text()}`); return (await res.json()).json; // unwrap the envelope } const { balance } = await call("wallet/balance", {}); console.log("balance:", balance);

Endpoints

List sessions — session.list

Returns your sessions: every open one plus your most recently ended ones. Open means `status` is not `"ended"` — filter client-side.

bash
curl -s -X POST "https://dg.run/api/rpc/session/list" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{}}'
json
{ "json": { "sessions": [ { "sessionId": "a1b2c3d4e5f6071829", "status": "ready", "kind": "spot", "mode": "dev", "progress": ["Setting up your dg.run workspace...", "Starting coding environment...", "Connecting..."], "createdAt": 1727000000000, "opencodeUrl": "https://a1b2c3d4e5f6071829.dg.run" }, { "sessionId": "f9e8d7c6b5a4032918", "status": "ended", "kind": "spot", "mode": "dev", "progress": [], "createdAt": 1726900000000, "endedReason": "stopped", "opencodeUrl": null } ], "store": "mongo" } }

Field notes:

  • status is one of starting (still booting — poll session.get), ready (open it at /<sessionId> or opencodeUrl), ended (terminal; start a fresh one instead).
  • kind is spot (temporary, cheaper) or permanent (survives longer, costs more — see Wallet & billing).
  • mode is dev (interactive workspace with the OpenCode editor) or run (repo auto-cloned and startCmd launched — no editor).
  • endedReason only appears on ended sessions (e.g. stopped, or out-of-credit).
  • opencodeUrl is null until the workspace is reachable.
  • progress mirrors the loading-screen lines while starting.

New session — session.create

Starts a workspace. All inputs are optional and default to a temporary (spot) dev workspace:

  • kind — "spot" (temporary, default) or "permanent" (longer-lived, higher rate).
  • vps — machine size name from session.pricing (default "t3.small"). Unknown names fall back to default pricing.
  • mode — "dev" (default, interactive editor) or "run" (headless repo runner).
  • repoUrl, repoToken, startCmd, autoDeploy — only with `mode: "run"` (passing any of them in dev mode is a 400):
  • repoUrl (required in run mode) — https://github.com/<owner>/<repo>[.git], max 500 chars.
  • startCmd (required in run mode) — shell command that serves your app, max 500 chars. Must not use sudo/doas or touch reserved paths (/etc/, /opt/cloudcode).
  • repoToken (optional) — PAT for a private repo. Write-only: used once for the clone, never stored.
  • autoDeploy.branch (optional, default "main") — push-to-deploy tracking branch (1–64 chars: letters, digits, /, _, ., -).
bash
# Temporary (spot) session — the default curl -s -X POST "https://dg.run/api/rpc/session/create" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"kind":"spot"}}' # Permanent session curl -s -X POST "https://dg.run/api/rpc/session/create" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"kind":"permanent"}}' # Headless repo runner (serves the repo, no editor) curl -s -X POST "https://dg.run/api/rpc/session/create" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"kind":"spot","mode":"run","repoUrl":"https://github.com/acme/web","startCmd":"npm start -- --port 3000"}}' # Headless runner with push-to-deploy on the `staging` branch curl -s -X POST "https://dg.run/api/rpc/session/create" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"mode":"run","repoUrl":"https://github.com/acme/web","startCmd":"npm start","autoDeploy":{"branch":"staging"}}}'
json
{ "json": { "sessionId": "a1b2c3d4e5f6071829", "status": "starting", "store": "mongo" } }

With autoDeploy the response also includes the GitHub webhook to configure (shown once — the secret is never returned again):

json
{ "json": { "sessionId": "a1b2c3d4e5f6071829", "status": "starting", "store": "mongo", "webhookUrl": "https://dg.run/api/hooks/github/a1b2c3d4e5f6071829", "webhookSecret": "opaque-per-session-secret" } }

Add the webhookUrl to the repo's Settings → Webhooks (push events, JSON) with the webhookSecret as the secret. Only pushes to the tracked branch redeploy; everything else is acknowledged and ignored. Without autoDeploy both fields are absent (or null when APP_URL is unset server-side).

The session boots asynchronously: poll session.get until status is ready (usually seconds). Creation fails with 400 when the input is invalid (mode:'run' needs repoUrl, bad repoUrl/startCmd shape) and with 402-style insufficient-credit errors when your balance can't cover at least one hour at the session's rate — top up on the Wallet page.

Session status — session.get

Status of one session. Same shape as one entry of session.list, plus the store field.

bash
curl -s -X POST "https://dg.run/api/rpc/session/get" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>"}}'
json
{ "json": { "sessionId": "a1b2c3d4e5f6071829", "status": "ready", "kind": "spot", "mode": "dev", "progress": ["Setting up your dg.run workspace...", "Starting coding environment...", "Connecting..."], "createdAt": 1727000000000, "opencodeUrl": "https://a1b2c3d4e5f6071829.dg.run", "store": "mongo" } }

Typical lifecycle script — start, wait for ready, then stop:

bash
ID=$(curl -s -X POST "https://dg.run/api/rpc/session/create" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"kind":"spot"}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['json']['sessionId'])") for i in $(seq 1 30); do STATUS=$(curl -s -X POST "https://dg.run/api/rpc/session/get" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d "{\"json\":{\"sessionId\":\"$ID\"}}" | python3 -c "import sys,json; print(json.load(sys.stdin)['json']['status'])") echo "status: $STATUS" [ "$STATUS" = "ready" ] && break sleep 5 done

Stop session — session.stop

Shuts a session down immediately. Ended sessions can't be reopened. Stopping also revokes the session's port share links (see Ports & sharing).

bash
curl -s -X POST "https://dg.run/api/rpc/session/stop" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>"}}'
json
{ "json": { "ok": true, "sessionId": "a1b2c3d4e5f6071829", "status": "ended" } }

Stop machines you no longer need — billing runs while a session is alive.

Prices — session.pricing

Hourly prices in USD for the session kinds, plus the available machine sizes for the vps input of session.create:

bash
curl -s -X POST "https://dg.run/api/rpc/session/pricing" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{}}'
json
{ "json": { "spotHourlyUsd": 0.04, "permanentHourlyUsd": 0.08, "vpsOptions": [{ "name": "t3.small", "spotHourlyUsd": 0.04, "permanentHourlyUsd": 0.08 }] } }

spotHourlyUsd / permanentHourlyUsd are the cheapest active size (the split-button labels); vpsOptions lists every active size with its own rates.

Box stats — session.stats

CPU/RAM/disk snapshot for a session. All fields are null when the box predates the stats endpoint:

bash
curl -s -X POST "https://dg.run/api/rpc/session/stats" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>"}}'
json
{ "json": { "stats": { "cpuPct": 12.5, "memPct": 40.1, "memUsedMb": 800, "memTotalMb": 2000, "diskPct": 30.0, "diskUsedGb": 9.0, "diskTotalGb": 30.0, "uptimeSec": 3600, "load1": 0.42 } } }

Reboot — session.restart

Reboots a permanent session (spot sessions can't be rebooted — start a fresh one instead):

bash
curl -s -X POST "https://dg.run/api/rpc/session/restart" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>"}}'
json
{ "json": { "ok": true, "sessionId": "a1b2c3d4e5f6071829", "status": "starting" } }

Poll session.get until status is ready again.

Dev-server ports — session.ports.list

Lists detected localhost ports with every URL form (see Ports & sharing):

bash
curl -s -X POST "https://dg.run/api/rpc/session/ports/list" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>"}}'
json
{ "json": { "ports": [ { "port": 3000, "url": "https://dg.run/p/TOKEN", "directUrl": "https://3000--a1b2c3d4e5f6071829.dg.run", "directReady": true, "customUrl": "https://myapp.dg.run", "customReady": true, "customDomain": { "canonical": "example.com", "alias": "www.example.com" } } ] } }

Field notes:

  • url is the public share link (/p/<token>, revoked on stop). null when no share token exists yet.
  • directUrl is the <port>--<sessionId>.dg.run link. directReady is false while worldwide DNS is still picking it up — use url until then.
  • customUrl / customReady are the <name>.dg.run address and its DNS readiness (null/false when unset).
  • customDomain is the BYO-domain assignment (null when unset).

Custom addresses are manageable over the API too — this is how you change a port's subdomain programmatically. Details and DNS steps: Custom addresses.

Set subdomain — session.ports.setCustomHost

Assigns a <label>.dg.run subdomain to a port (one custom address per port — setting a name replaces any BYO domain on the same port and vice versa).

bash
curl -s -X POST "https://dg.run/api/rpc/session/ports/setCustomHost" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>","port":3000,"label":"myapp"}}'
json
{ "json": { "ok": true, "status": "active", "customUrl": "https://myapp.dg.run/", "checks": [{ "fqdn": "myapp.dg.run", "resolves": true }] } }

Rules and field notes:

  • label is normalized to lowercase. It must be 4-16 chars, a-z / 0-9 / - only, cannot start or end with a dash, no consecutive dashes (-- is reserved for <port>--<session> hosts), no pure numbers, and cannot be reserved (www, api, app, admin, docs, p, mail, ftp, blog, status, cdn, static, assets, support, help, billing).
  • port is 1-65535 (infrastructure ports such as the sidecar port and 2019 are rejected with 400).
  • The session must be ready with a public IP (400 otherwise).
  • First come, first served across all sessions: taken by another live session returns 409. Names orphaned by ended sessions are reclaimable.
  • status is pending with customUrl: null until worldwide DNS resolves to the box — poll customHostStatus until active. Opening early lands on a retry page.
  • The name is deleted automatically when the session stops.

Poll subdomain status — session.ports.customHostStatus

Read-only readiness check for a port's <label>.dg.run assignment (the dialog polls this every ~5s). Never mutates anything.

bash
curl -s -X POST "https://dg.run/api/rpc/session/ports/customHostStatus" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>","port":3000}}'
json
{ "json": { "ok": true, "status": "pending", "customUrl": null, "checks": [{ "fqdn": "myapp.dg.run", "resolves": false }] } }

Returns pending with empty checks when the port has no label assigned.

Remove subdomain — session.ports.removeCustomHost

Releases a port's <label>.dg.run name early (sidecar pull + DNS delete + registry cleanup).

bash
curl -s -X POST "https://dg.run/api/rpc/session/ports/removeCustomHost" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>","port":3000}}' # {"json":{"ok":true}}

No-op ({ok:true}) when the port has no label. Idempotent — safe to call on stop flows.

Own domain — session.ports.setCustomDomain / removeCustomDomain

Serve a port on a domain you control (app.mydomain.com, mydomain.com). Your zone stays yours: you add the A record(s), we only verify resolution via DNS-over-HTTPS before serving.

bash
# Assign — returns pending + targetIp until your A records resolve here curl -s -X POST "https://dg.run/api/rpc/session/ports/setCustomDomain" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"sessionId":"<sessionId>","port":3000,"domain":"mydomain.com"}}'
json
{ "json": { "ok": true, "status": "pending", "customUrl": null, "targetIp": "3.4.5.6", "checks": [ { "fqdn": "mydomain.com", "resolves": false }, { "fqdn": "www.mydomain.com", "resolves": false } ] } }

Field notes:

  • domain accepts an optional scheme/path/trailing dot (normalized to lowercase FQDN). .dg.run names are rejected here — use setCustomHost for those. IPs and xn-- prefixes are rejected.
  • Apex input (mydomain.com) bundles www.mydomain.com automatically — point both A records at targetIp. Deeper subdomains (app.mydomain.com) are single-host. Optional canonical picks which of the pair serves (default: non-www); the other 301-forwards.
  • Nothing is persisted until all names resolve here (no registry squatting). Poll setCustomDomain again until active — it returns per-name checks each time.
  • Taken by another live session returns 409.
  • Remove with session.ports.removeCustomDomain (sessionId, port) — drops our side immediately, returns {ok:true}. Delete your `A` records yourself — a stale record would point at a recycled cloud IP after the session ends.

Live events — session.watch

Server-sent event stream of ports / status / stats snapshots plus ping keep-alives — the same feed the workspace page uses instead of polling. It is SSE-shaped (a long-lived subscription that recycles every few minutes and replays full snapshots on reconnect), so plain curl prints an event stream rather than one JSON body. Prefer session.get / session.ports.list polling from scripts; use watch only from an SSE-capable client.

Wallet balance — wallet.balance

Your current credit in dollars (fractional — per-minute billing slices are sub-cent).

bash
curl -s -X POST "https://dg.run/api/rpc/wallet/balance" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{}}' # {"json":{"balance":1.23}}

Top-ups stay browser-based (Stripe checkout on the Wallet page) — keys are read-only for billing, except that wallet.create / wallet.confirm technically accept key auth but still require completing the returned Stripe checkoutUrl in a browser. See Wallet & billing for rates and rewards.

Transaction history — wallet.list

Paginated newest-first transactions plus the current balance. Each row carries a derived status (pending, approved, rejected — same meanings as on the Wallet page):

bash
curl -s -X POST "https://dg.run/api/rpc/wallet/list" \ -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"json":{"page":1,"pageSize":20}}'
json
{ "json": { "transactions": [ { "_id": "tx_abc", "amount": 5.0, "details": "...", "at": 1727000100000, "createdAt": 1727000000000, "status": "approved" } ], "total": 1, "page": 1, "pageSize": 20, "balance": 6.23 } }

page defaults to 1, pageSize to 20 (max 100).

Errors

HTTP status tells you what went wrong:

| Status | Meaning | | --- | --- | | 401 | Missing, unknown, or revoked key — or signed out (browser). Check the Authorization header; revoked keys never come back, mint a new one. | | 403 | Key used on a cookie-only namespace (auth.*, apikey.*), or the account is blocked. | | 404 | Unknown sessionId (or one that isn't yours — ids can't be probed). | | 400 | Invalid input — e.g. mode:'run' without repoUrl/startCmd, dev-mode extras (repoUrl/startCmd/autoDeploy without mode:'run'), or a bad port/label/domain shape. The message names the problem. | | 402 | Insufficient credit — session.create needs at least one hour of runway at the session's rate. Top up and retry. | | 429 | Rate-limited (OTP request cooldown on user.request). Retry after the returned retryAfterSec. |

Limits

  • At most 20 keys per user. Revoked keys are deleted and don't count — revoke an old one to make room.
  • The API keys page shows each key's last used timestamp, so you can spot (and revoke) keys that are stale or were used unexpectedly.

Safety

  • Treat a key like a password: keep it out of logs, screenshots, client-side code, and shell history (prefer read -s KEY or a secrets manager over pasting it inline).
  • Name keys after where they're used (ci, backup-script) so you know which one to revoke.
  • Revoke immediately if a key leaks — revocation deletes the key at once and it stops working instantly.