Every workflow you can do in the dashboard, you can do programmatically. REST API for scripts and CI, MCP server for Claude and Cursor, CLI for your terminal. One token, your apps from anywhere.
Three steps from zero to controlling your apps from a script:
http://distribute.app/api/v1/... with header Authorization: Bearer <your-token>
Every request to /api/v1/* carries a Bearer token in the Authorization
header. Tokens are issued and revoked from your dashboard · Distribute never
shows a token twice, so you'll be asked to copy it once at creation. Lose it,
and you create a new one.
curl -H "Authorization: Bearer $DISTRIBUTE_TOKEN" \
http://distribute.app/api/v1/me
# {"username": "you", "admin": false}
Tokens have an optional expires_in_days · use it for ephemeral CI tokens
and rotate aggressively. Long-lived tokens have no expiration but get a last_used_at
stamp so you can spot dormant credentials.
Scopes constrain what a token can do. Pick the minimum set your script needs ·
a deploy script doesn't need billing:read, an analytics script doesn't
need apps:write.
Wildcard tokens (*) exist only for the legacy CLI device-flow login ·
they cannot be created via the dashboard or API.
Plain HTTP, JSON in and out, predictable status codes. Discovery endpoint
/api/v1 returns the live route list so tooling stays in sync.
# Probe your token
curl -H "Authorization: Bearer $TOKEN" \
http://distribute.app/api/v1/me
# → {"username": "you", "admin": false}
# Discover live endpoints
curl http://distribute.app/api/v1
# → {"endpoints": [...]}
Detailed examples for each common task are in the Deploy, Manage, and Set env vars sections below.
A single Python script · no install, no SDK, no native binary. Runs anywhere with Python 3.8+.
curl -fsSL http://distribute.app/install.sh | sh
distribute login # opens browser · authenticates
distribute apps # lists your apps
distribute deploy ./myapp # one-shot deploy from current dir
distribute logs myapp --tail 50
distribute env myapp DEBUG=false
Model Context Protocol bridge so Claude Desktop, Cursor, and any other MCP-compatible client can drive your Distribute account directly from a conversation. No copy-paste of curl commands, no juggling tabs.
The server is a single zero-dependency Python file. Download it, point your
client at it, paste your token in the env. Twelve tools available out of
the box: list_apps, get_app, restart_app,
get_logs, get_env, set_env,
get_balance, list_domains, list_workspaces,
and more.
curl -fsSL http://distribute.app/mcp/distribute -o ~/distribute-mcp.py
chmod +x ~/distribute-mcp.py
Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"distribute": {
"command": "python3",
"args": ["/Users/you/distribute-mcp.py"],
"env": {
"DISTRIBUTE_API_TOKEN": "your-token-from-dashboard",
"DISTRIBUTE_BASE_URL": "http://distribute.app"
}
}
}
}
Restart Claude Desktop. You can now ask:
"List my Distribute apps. For any that crashed in the last hour, get their last 50 log lines and restart them."
Example · deploy"Deploy this Next.js project · I just pushed it to https://github.com/me/site/archive/main.zip · call it 'site' and use port 3000."
A deploy is a single POST to /api/v1/apps/deploy. Distribute
accepts your source as a zip · either uploaded directly (multipart) or
fetched from a URL you control (GitHub release, S3 presigned URL, etc.).
The response includes the live HTTPS URL once the build finishes.
The cleanest mode for CLI, CI, and MCP · no multipart bodies to assemble:
curl -X POST https://distribute.app/api/v1/apps/deploy \
-H "Authorization: Bearer $DISTRIBUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"app_name": "myapi",
"source_url": "https://github.com/me/myapi/archive/main.zip",
"port": 3000,
"env": {"NODE_ENV": "production", "DB_URL": "postgres://..."},
"instance_plan": "small",
"custom_domain": "api.example.com"
}'
For local file uploads from a script that has the zip on disk:
curl -X POST https://distribute.app/api/v1/apps/deploy \
-H "Authorization: Bearer $DISTRIBUTE_TOKEN" \
-F "app_name=myapi" \
-F "app_port=3000" \
-F "file=@./myapi.zip"
| Field | Type | Required | Notes |
|---|---|---|---|
app_name | string | yes | 1-50 chars, lowercase, [a-z0-9_-] |
source_url | string | yes (JSON mode) | Public http(s) URL to a zip · 500MB max |
file | multipart | yes (multipart mode) | .zip blob |
port | int | no | Auto-detected from EXPOSE / source if omitted |
custom_domain | string | no | DNS CNAME setup follows · Distribute issues SSL automatically |
env | object | no | {"KEY": "VALUE"} pairs · null deletes a key on redeploy |
instance_plan | string | no | nano (default), micro, small, medium, large |
description | string | no | Free text · shown in your dashboard |
{
"success": true,
"app": "myapi",
"url": "https://myapi.distribute.app",
"custom_url": "https://api.example.com", // present if custom_domain set
"app_type": "nodejs",
"is_redeploy": false,
"deployment": "Docker + Traefik",
"ssl_enabled": true,
"ssl_instructions": "Add CNAME record: api.example.com → myapi.distribute.app",
"cname_target": "myapi.distribute.app"
}
Redeploys are detected automatically · POSTing the same app_name
again rebuilds the image and rolls the container without losing your
custom domain or env vars (any keys you don't pass are preserved).
Distribute runs the same pipeline as the dashboard upload form:
balance check, name uniqueness check, project type detection,
docker build,
port verification against the image's ExposedPorts,
Traefik label assignment, Let's Encrypt SSL provisioning. The
response returns once routing is live · typically 30-60 seconds
from POST to first 200 response.
Every common operation has a dedicated endpoint:
# List
curl -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps
# Inspect
curl -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi
# Tail logs (last 200 lines by default)
curl -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi/logs?tail=500
# Restart
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi/restart
# Stop (compute pauses, data preserved)
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi/stop
# Start a stopped app
curl -X POST -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi/start
# Delete · destructive, cannot be undone
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi
Read or update environment variables on a running app. Updates trigger a container restart so the new values take effect immediately.
# Read current env
curl -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi/env
# Set / update keys · pass null to delete a key
curl -X POST https://distribute.app/api/v1/apps/myapi/env \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"env": {
"DEBUG": "false",
"DB_URL": "postgres://prod-host/db",
"OLD_FLAG": null
}
}'
Keys you don't include in the payload are left untouched. This makes it safe to update one variable in a script without having to fetch and re-send the whole environment.
For code editors and IDEs that need to apply changes faster than a full
deploy. Patch a single file (or a small batch) directly into the running
container · no rebuild, no version bump, no container swap. Hot-reload
frameworks (Vite, Nodemon, Flask debug, uvicorn --reload) pick
up the change immediately.
A complementary Server-Sent Events stream pushes file and deploy events back to the client so editors can react without polling.
Issue narrow tokens for editor integrations:
A token with only these two scopes can update code but cannot redeploy, stop, or delete the app · safe for editor extensions.
curl -X PUT https://distribute.app/api/v1/apps/myapi/files \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"path": "src/index.js",
"content": "console.log(\"hello live\")",
"encoding": "text"
}'
# {"success":true,"path":"src/index.js","size":24}
Path is relative to /app/ in the container. encoding
can be text (UTF-8 string, default) or base64
for binary files. Each file caps at 5 MB · larger payloads should go
through a regular deploy.
curl -X PUT https://distribute.app/api/v1/apps/myapi/files/batch \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"files": [
{"path": "src/a.js", "content": "..."},
{"path": "src/b.js", "content": "..."},
{"path": "logo.png", "content": "iVBORw0K...", "encoding": "base64"}
]
}'
# {"success":true,"written":["src/a.js","src/b.js","logo.png"],"failed":[]}
Up to 100 files / 20 MB total per batch. Per-file failures don't roll back the others · the response lists what succeeded and what didn't.
curl -H "Authorization: Bearer $TOKEN" \
"https://distribute.app/api/v1/apps/myapi/files?path=src/index.js"
# {"success":true,"path":"src/index.js","content":"...","encoding":"text","size":24}
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
"https://distribute.app/api/v1/apps/myapi/files?path=src/old.js"
# {"success":true,"path":"src/old.js"}
Open a long-lived HTTP connection and receive events as they happen.
Compatible with the browser's native EventSource when using
cookie auth, or with any streaming HTTP client when using Bearer tokens.
curl -N -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/v1/apps/myapi/events
data: {"type":"hello","app":"myapi","ts":"2026-05-02T12:00:00Z","user":"alice"}
data: {"type":"file_updated","ts":"...","app":"myapi","payload":{"path":"src/index.js","size":24,"by":"alice"}}
: heartbeat
data: {"type":"deploy_event","ts":"...","app":"myapi","payload":{"phase":"success","method":"pipeline","commit_sha":"a1b2c3d4"}}
hello · sent once when the stream opens · {user, app, ts}
heartbeat · every 15s · keeps proxies from idling out · zero payload (SSE comment line)
file_updated · single PUT · {path, size, by}
files_updated · batch PUT · {paths, count, by}
file_deleted · DELETE · {path, by}
file_error · failed mutation · {path, error}
deploy_event · full upload or pipeline rebuild finished · {phase, method, ...}
container_event · container restart / crash · {state}
// Open the events stream once at startup
const events = new EventSource(
`https://distribute.app/api/v1/apps/${appName}/events`,
// Note · browser EventSource doesn't support custom headers, so use
// a session cookie (login first) or a streaming fetch wrapper for tokens
);
events.addEventListener('message', (e) => {
const evt = JSON.parse(e.data);
if (evt.type === 'deploy_event' && evt.payload.phase === 'success') {
refreshPreviewFrame();
}
});
// On every editor save · push the file
async function saveFile(path, content) {
await fetch(
`https://distribute.app/api/v1/apps/${appName}/files`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ path, content, encoding: 'text' }),
}
);
}
// On bulk operations (refactor, find/replace) · use batch
async function saveFiles(files) {
await fetch(
`https://distribute.app/api/v1/apps/${appName}/files/batch`,
{
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ files }),
}
);
}
Frameworks that watch /app/ and pick up changes automatically:
--reload · FastAPI / Starletterunserver (StatReloader)
For everything else (production gunicorn, compiled binaries, static
builds), call POST /api/v1/apps/{name}/restart after the
batch of file changes is done.
The server rejects any path that is:
/ · the leading slash is stripped before validation, so the rest must be relative)..
The path is always interpreted relative to /app/ inside the
container. Combined with the container's filesystem isolation, an
attacker cannot reach outside their own app even with a valid token.
Every account gets one bucket automatically · the one the dashboard shows. These endpoints let you create more of them: one per environment, per client, per retention policy. All of them live on the same S3-compatible backend and are reachable with any S3 SDK.
Ownership is enforced by name prefix. Every bucket you create is
prefixed with your account name · acme-corp-backups,
not backups · so ownership is unambiguous and two tenants
can never collide on a name. The prefix is added for you when you
leave it out.
curl -H "Authorization: Bearer $TOKEN" \
https://distribute.app/api/storage/buckets
{
"success": true,
"prefix": "acme-corp",
"count": 2,
"endpoint": "https://s3.distribute.app",
"buckets": [
{ "name": "acme-corp", "is_crate": true, "objects": 42,
"size_bytes": 5242880, "size_human": "5.0 MB" },
{ "name": "acme-corp-cdn", "is_crate": false, "objects": 0,
"size_bytes": 0, "size_human": "0 B" }
]
}
is_crate marks your main bucket · the one wired into
uploads, sharing and public links. It cannot be deleted.
curl -X POST https://distribute.app/api/storage/buckets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "backups", "public": false }'
"backups" becomes acme-corp-backups.
Set "public": true to allow anonymous read on every
object in the bucket · useful for a CDN origin, wrong for anything
private. Names must be lowercase, 3 to 63 characters, letters, digits,
dots and hyphens only.
curl https://distribute.app/api/storage/buckets/acme-corp-cdn/policy \
-H "Authorization: Bearer $TOKEN"
curl -X POST https://distribute.app/api/storage/buckets/acme-corp-cdn/policy \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "public": true }'
# Refuses if the bucket still holds objects
curl -X DELETE https://distribute.app/api/storage/buckets/acme-corp-backups \
-H "Authorization: Bearer $TOKEN"
# Delete the objects along with it
curl -X DELETE "https://distribute.app/api/storage/buckets/acme-corp-backups?force=1" \
-H "Authorization: Bearer $TOKEN"
A non-empty bucket returns 409 with the object count so
you can decide. Your main bucket returns 400 · empty it
instead if that is what you meant.
The file endpoints default to your main bucket. Pass bucket
to address another one you own.
# List
curl -H "Authorization: Bearer $TOKEN" \
"https://distribute.app/api/storage/crate/files?prefix=&bucket=acme-corp-cdn"
# Upload
curl -X POST https://distribute.app/api/storage/crate/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@report.pdf" \
-F "bucket=acme-corp-cdn"
# Download
curl -H "Authorization: Bearer $TOKEN" \
"https://distribute.app/api/storage/crate/download?key=docs/report.pdf&bucket=acme-corp-cdn"
GET /api/storage/crate returns the endpoint and
credentials. Any S3 SDK works · boto3, aws-cli, minio-py, rclone.
import boto3
s3 = boto3.client(
"s3",
endpoint_url="https://s3.distribute.app",
aws_access_key_id=ACCESS_KEY,
aws_secret_access_key=SECRET_KEY,
)
s3.upload_file("report.pdf", "acme-corp-cdn", "docs/report.pdf")
distribute bucket ls
distribute bucket create --name backups --public
distribute bucket public --name backups --private
distribute bucket rm --name backups --force
Live list · this is rendered from the same source the API uses.
| Method | Path | Required scope |
|---|---|---|
| GET | /api/v1/me | any |
| GET | /api/v1/apps | apps:read |
| POST | /api/v1/apps/deploy | apps:deploy |
| GET | /api/v1/apps/{name} | apps:read |
| GET | /api/v1/apps/{name}/logs | apps:read |
| GET | /api/v1/apps/{name}/env | apps:env |
| POST | /api/v1/apps/{name}/env | apps:env |
| POST | /api/v1/apps/{name}/restart | apps:write |
| POST | /api/v1/apps/{name}/stop | apps:write |
| POST | /api/v1/apps/{name}/start | apps:write |
| DELETE | /api/v1/apps/{name} | apps:write |
| GET | /api/v1/apps/{name}/files?path=... | apps:files |
| PUT | /api/v1/apps/{name}/files | apps:files |
| PUT | /api/v1/apps/{name}/files/batch | apps:files |
| DELETE | /api/v1/apps/{name}/files?path=... | apps:files |
| GET | /api/v1/apps/{name}/events | apps:stream |
| GET | /api/v1/billing/balance | billing:read |
| GET | /api/v1/domains | domains:read |
| GET | /api/v1/workspaces | workspaces:read |
| GET | /api/storage/buckets | storage:read |
| POST | /api/storage/buckets | storage:write |
| DELETE | /api/storage/buckets/{name} | storage:write |
| GET | /api/storage/buckets/{name}/policy | storage:read |
| POST | /api/storage/buckets/{name}/policy | storage:write |
| GET | /api/storage/crate/files?prefix=...&bucket=... | storage:read |
| POST | /api/storage/crate/upload | storage:write |
| GET | /api/storage/crate/download?key=...&bucket=... | storage:read |
HTTP status codes mean what you'd expect:
400 · malformed request · check the field your client sent401 · token missing, invalid, or expired402 · billing block · wallet empty or unpaid plan · top up to continue403 · token valid but missing the required scope404 · app or resource doesn't exist (or isn't yours)409 · conflict · usually a name collision with another user's app413 · upload too large · zips capped at 500MB500 · build or deploy error · the response includes a build_log excerpt502 · couldn't fetch your source_url · check the URL is public and serves a zipError body is always JSON: {"error": "human-readable message"}
Build failures additionally include a build_log field with the last 2000 chars of compiler/Docker output · most "deploy worked locally but failed on Distribute" issues fall out of reading those lines.
What's shipped recently · most recent first.
/api/storage/buckets. Ownership is enforced by name
prefix, and the file endpoints accept a bucket
parameter to address any bucket you own.
PUT /api/v1/apps/{name}/files. The companion GET /api/v1/apps/{name}/events Server-Sent Events stream pushes file and deploy events back. New scopes · apps:files and apps:stream.
app_name already owned by another user, it now auto-appends a UTC timestamp instead of returning 409. Same-owner redeploys still update in place. The response carries auto_suffixed: true + original_name so callers can correlate.
POST /api/v1/apps/deploy with {source_url, app_name, ...}. Returns the live URL once the build finishes. New scope apps:deploy.
deploy_app, list_apps, get_logs, set_env, get_balance.
/api/v1/* namespace, scope-gated Bearer tokens, dashboard UI for token issuance and revocation.
Looking for the CLI quickstart and command reference? See /docs. Have a use case the API doesn't cover yet? Open a ticket from the dashboard · we add endpoints based on what users actually need.