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:
httpAuthorization: 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/jsonplus theAuthorizationheader 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:
bashKEY="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:
jsconst 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.
bashcurl -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:
statusis one ofstarting(still booting — pollsession.get),ready(open it at/<sessionId>oropencodeUrl),ended(terminal; start a fresh one instead).kindisspot(temporary, cheaper) orpermanent(survives longer, costs more — see Wallet & billing).modeisdev(interactive workspace with the OpenCode editor) orrun(repo auto-cloned andstartCmdlaunched — no editor).endedReasononly appears on ended sessions (e.g.stopped, or out-of-credit).opencodeUrlisnulluntil the workspace is reachable.progressmirrors the loading-screen lines whilestarting.
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 fromsession.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 indevmode is a400):repoUrl(required inrunmode) —https://github.com/<owner>/<repo>[.git], max 500 chars.startCmd(required inrunmode) — shell command that serves your app, max 500 chars. Must not usesudo/doasor 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.
bashcurl -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:
bashID=$(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).
bashcurl -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:
bashcurl -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:
bashcurl -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):
bashcurl -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):
bashcurl -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:
urlis the public share link (/p/<token>, revoked on stop).nullwhen no share token exists yet.directUrlis the<port>--<sessionId>.dg.runlink.directReadyisfalsewhile worldwide DNS is still picking it up — useurluntil then.customUrl/customReadyare the<name>.dg.runaddress and its DNS readiness (null/falsewhen unset).customDomainis the BYO-domain assignment (nullwhen 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).
bashcurl -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:
labelis 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).portis 1-65535 (infrastructure ports such as the sidecar port and2019are rejected with400).- The session must be
readywith a public IP (400otherwise). - First come, first served across all sessions: taken by another live session returns
409. Names orphaned by ended sessions are reclaimable. statusispendingwithcustomUrl: nulluntil worldwide DNS resolves to the box — pollcustomHostStatusuntilactive. 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.
bashcurl -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).
bashcurl -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:
domainaccepts an optional scheme/path/trailing dot (normalized to lowercase FQDN)..dg.runnames are rejected here — usesetCustomHostfor those. IPs andxn--prefixes are rejected.- Apex input (
mydomain.com) bundleswww.mydomain.comautomatically — point bothArecords attargetIp. Deeper subdomains (app.mydomain.com) are single-host. Optionalcanonicalpicks which of the pair serves (default: non-www); the other301-forwards. - Nothing is persisted until all names resolve here (no registry squatting). Poll
setCustomDomainagain untilactive— it returns per-namecheckseach 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).
bashcurl -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):
bashcurl -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 usedtimestamp, 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 KEYor 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.