Authentication #auth
RunFlow uses a two-tier auth model backed by auth.rodmena.app:
- Tier 1 — Master client key: RunFlow's own service key (
AUTH_CLIENT_KEY) authenticates RunFlow to auth.rodmena.app. This is configured server-side, never exposed to callers. - Tier 2 — API keys: Each caller presents a Bearer token (UUIDv4) that must be registered and whitelisted in auth.rodmena.app. Unknown keys are rejected with 401. Known keys without the required permission are rejected with 403.
You cannot use a random UUID — only keys approved by the service admin (via auth.rodmena.app) will work. The API key also serves as the tenant isolation boundary: all runs, logs, and metadata are scoped to the key's identity.
Authorization: Bearer <your-api-key>
- 401 — missing, malformed, or unknown API key (not registered in auth.rodmena.app).
- 403 — valid API key but missing the RBAC permission for the endpoint.
- 503 — auth.rodmena.app is unreachable (fail-closed; no requests pass).
RBAC permissions are granted per API key in auth.rodmena.app:
runflow_run_submit # POST /api/v1/runs
runflow_run_read # GET /api/v1/runs, /{track_id}, /result, /logs, /utilization, /tenant/quotas
runflow_run_control # POST /pause, /resume, /stop, /kill
runflow_run_delete # DELETE /api/v1/runs/{track_id}
runflow_tenant_admin # PUT /api/v1/tenant/quotas
curl https://runflow.rodmena.app/api/v1/tenant/quotas \
-H "Authorization: Bearer $API_KEY"
Quick Start #quickstart
Three steps: check health, submit a run, fetch the result + logs.
Check health
No auth required. Returns service + dependency status.
curl https://runflow.rodmena.app/health
# {"status":"healthy","postgres":"up","redis":"up","docker":"up","workers":{"total":4,"busy":0}}
Submit a hello-world Docker run
API_KEY=ead69607-06c3-4321-916a-c41636349815 # your API key (registered in auth.rodmena.app)
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "python:3.12-slim",
"command": ["python", "-c", "print(\"hello from runflow\")"],
"timeout_seconds": 60
}'
# {"track_id":"01KY0XX71BMX0B88C3A189S2K7","status":"scheduled","eligible_start":"2026-07-20T23:31:17Z","clamped":false}
Poll status, then fetch result + logs
TRACK=01KY0XX71BMX0B88C3A189S2K7
# status — poll until state is terminal
curl -s https://runflow.rodmena.app/api/v1/runs/$TRACK \
-H "Authorization: Bearer $API_KEY" | python3 -m json.tool
# result — available when state is "exited" / "failed" / "timeout" / "killed"
curl -s https://runflow.rodmena.app/api/v1/runs/$TRACK/result \
-H "Authorization: Bearer $API_KEY" | python3 -m json.tool
# logs — stdout only here; use stream=both for both
curl -s "https://runflow.rodmena.app/api/v1/runs/$TRACK/logs?stream=stdout" \
-H "Authorization: Bearer $API_KEY" | python3 -m json.tool
Examples #examples
4a. Hello-world Docker run
Minimal run: pull python:3.12-slim, run a one-liner, exit.
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "python:3.12-slim",
"command": ["python", "-c", "print(\"hello from runflow\")"],
"timeout_seconds": 60
}'
4b. Python script with env vars (useful)
A run that takes an env var and does something useful — fetches a URL and prints the HTTP status + content-type. The env dict is injected into the container as environment variables.
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "python:3.12-slim",
"command": ["python", "-c", "import os,urllib.request; r=urllib.request.urlopen(os.environ[\"TARGET_URL\"]); print(r.status, r.headers[\"content-type\"])"],
"env": {"TARGET_URL": "https://api.github.com/repos/python/cpython"},
"timeout_seconds": 30,
"tags": ["probe", "github"]
}'
The tags array is free-form labels for filtering when listing runs. The env values are strings only.
4c. Useful work — data processing with resource limits
A bigger run with CPU/memory limits and a name for easy identification. Note: stdin is not supported — pipe data via env vars or bake it into the command. The container's command reads from env/args, not stdin.
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "python:3.12-slim",
"command": ["python", "-c", "import json,os; data={\"a\":1,\"b\":2,\"c\":3}; print(json.dumps({\"count\":len(data),\"keys\":list(data.keys()),\"sum\":sum(data.values())}))"],
"env": {"PYTHONDONTWRITEBYTECODE": "1"},
"timeout_seconds": 120,
"cpu_limit": 1.0,
"memory_limit_mb": 512,
"name": "summarize-input",
"tags": ["etl", "summary"]
}'
Requested cpu_limit / memory_limit_mb / timeout_seconds are clamped to your tenant quotas. If clamped, the response has "clamped": true.
4d. Scheduled start (delay a run)
Omit scheduled_start for ASAP. Provide an ISO-8601 timestamp to run later.
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "alpine:3.20",
"command": ["echo", "scheduled run fired"],
"scheduled_start": "2026-07-22T09:00:00Z",
"timeout_seconds": 30
}'
4e. Control: pause, resume, stop, kill
# pause a running container
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/pause \
-H "Authorization: Bearer $API_KEY"
# resume
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/resume \
-H "Authorization: Bearer $API_KEY"
# graceful stop (SIGTERM + grace period)
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/stop \
-H "Authorization: Bearer $API_KEY"
# force kill (SIGKILL)
curl -X POST https://runflow.rodmena.app/api/v1/runs/$TRACK/kill \
-H "Authorization: Bearer $API_KEY"
Scheduling #scheduling
RunFlow uses a reactive scheduling model. When you submit a run, it gets an eligible_start (the earliest time it's allowed to start, based on scheduled_start and the tenant's working-hours calendar). The scheduler then dispatches eligible runs to free workers every 1 second. If no worker is free, the run waits and retries — it is never rejected for capacity reasons.
max_daily_runs still triggers an immediate 429. If you depended on 429-at-quota as backpressure, poll scheduling_info.queue_position instead.
How eligible_start is computed
eligible_start = working_hours_clamp(max(scheduled_start, now))
- No
scheduled_start→eligible_start = now - Past
scheduled_start→ clamped tonow - Future
scheduled_start→ used as-is, then clamped to next working window if outside hours - No
working_hours_tjp→ no calendar constraint, pure arithmetic
Capacity (worker pool, concurrent quota) is not considered at submit time. A run at the concurrent quota limit is accepted and waits in scheduled state until a worker frees up. If it waits longer than max_queue_seconds past eligible_start, it transitions to failed:queue_timeout.
Working hours calendar
Set working_hours_tjp on the tenant's quotas to restrict when runs may start. The value is a TJP (TaskJuggler) shift definition. Runs are only dispatched if their eligible_start falls within the shift; if you submit at 18:30 with a Mon-Fri 09:00-18:00 shift, eligible_start advances to Monday 09:00.
# Set working hours via PUT /api/v1/tenant/quotas
curl -X PUT https://runflow.rodmena.app/api/v1/tenant/quotas \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"working_hours_tjp": "shift shift_def \"Business Hours\" { workinghours mon - fri 09:00 - 18:00 }"
}'
# Now submit on Friday 17:55 (on-shift) -\u003e eligible_start = Friday 17:55
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "python:3.12-slim",
"command": ["python", "-c", "print(\"within hours\")"],
"scheduled_start": "2025-07-18T17:55:00Z"
}'
# Submit on Friday 18:30 (off-shift) -\u003e eligible_start = Monday 09:00
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "python:3.12-slim",
"command": ["python", "-c", "print(\"after hours\")"],
"scheduled_start": "2025-07-18T18:30:00Z"
}'
scheduling_info in run status
Every GET /api/v1/runs/{track_id} response includes a scheduling_info object:
{
"scheduling_info": {
"eligible_start": "2026-07-21T10:00:00Z",
"working_hours_applied": false,
"queue_position": 3,
"estimated_start": "2026-07-21T10:00:30Z"
}
}
queue_position— how many of this tenant'sscheduledruns are ahead in the queue (null if the run is notscheduled). This is the honest signal for "when will mine start."estimated_start— a soft best-effort estimate based on historical run durations and queue depth.nullwhen there are fewer than 5 prior runs of the same image. Not a commitment — may change.working_hours_applied— true if the tenant has aworking_hours_tjpshift set.
What happens when the pool is full
# 4 workers, 4 runs already running, submit a 5th
curl -X POST https://runflow.rodmena.app/api/v1/runs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"image": "python:3.12-slim", "command": ["python", "-c", "print(5)"]}'
# Response: 202 Accepted (NOT 429)
{"track_id": "01KY...", "status": "scheduled", "eligible_start": "2026-07-21T..."}
# Check scheduling_info to see queue position
curl -s https://runflow.rodmena.app/api/v1/runs/01KY... \
-H "Authorization: Bearer $API_KEY" | python3 -m json.tool
# "scheduling_info": {"queue_position": 1, "estimated_start": "..."}
# When a running run exits, the 5th run is dispatched within ~1s
Quotas and limits
Per-tenant quotas control resource usage. max_concurrent_runs limits how many runs can be in {pulling, running, paused} simultaneously — enforced at dispatch time, not submit time. max_daily_runs limits submissions per UTC day — enforced at submit time with 429. Resource limits (cpu_limit, memory_limit_mb, timeout_seconds) are clamped to the tenant max and the response includes "clamped": true if any were reduced.
# Check your quotas
curl https://runflow.rodmena.app/api/v1/tenant/quotas \
-H "Authorization: Bearer $API_KEY"
# Update quotas (requires runflow_tenant_admin permission)
curl -X PUT https://runflow.rodmena.app/api/v1/tenant/quotas \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"max_concurrent_runs": 5,
"max_daily_runs": 200,
"max_timeout_seconds": 3600,
"max_queue_seconds": 600
}'
Run Lifecycle #lifecycle
Every run moves through these states. Poll GET /api/v1/runs/{track_id} until state is terminal.
| State | Category | Meaning |
|---|---|---|
scheduled | active | Submitted, waiting for eligible_start or a free worker slot. |
queued | active | Slot acquired, waiting in the worker queue. |
pulling | active | Docker is pulling the image. |
running | active | Container is executing. |
paused | active | Container paused via POST /pause. Resume with /resume. |
exited | terminal | Container exited 0 or was stopped gracefully. |
failed | terminal | Container exited non-zero, or the worker failed to start it. |
timeout | terminal | Run exceeded timeout_seconds and was killed. |
killed | terminal | Run force-killed via POST /kill. |
Control endpoints: POST /pause, POST /resume, POST /stop (graceful SIGTERM + grace), POST /kill (SIGKILL). All return 204 No Content on success.
Endpoint Reference #endpoints
| Method | Path | Description | Permission | Notes |
|---|---|---|---|---|
/ping | Liveness probe | — | No auth. Returns {"message":"PONG"} | |
/health | Readiness probe | — | No auth. Returns service + dependency status JSON | |
/metrics | Prometheus exposition | — | No auth, IP-restricted to private networks. text/plain | |
/api/v1/runs | Submit a run | runflow_run_submit | Body: RunSubmitRequest. Returns RunSubmitResponse (202) | |
/api/v1/runs | List runs | runflow_run_read | Query: state, tag, limit (default 100, max 1000), offset | |
/api/v1/runs/{track_id} | Run status | runflow_run_read | Returns RunStatus | |
/api/v1/runs/{track_id}/result | Run result | runflow_run_read | Returns RunResult. Only available when state is terminal | |
/api/v1/runs/{track_id}/logs | Logs | runflow_run_read | Query: stream=stdout|stderr|both (default both), since=N (offset; null=tail last N), limit (default 1000, max 1000). Returns LogResponse | |
/api/v1/runs/{track_id}/utilization | CPU/mem/net samples | runflow_run_read | Query: from, to (ISO-8601). Returns UtilizationResponse | |
/api/v1/runs/{track_id}/pause | Pause | runflow_run_control | Returns 204 | |
/api/v1/runs/{track_id}/resume | Resume | runflow_run_control | Returns 204 | |
/api/v1/runs/{track_id}/stop | Graceful stop (SIGTERM + grace) | runflow_run_control | Returns 204 | |
/api/v1/runs/{track_id}/kill | Force kill (SIGKILL) | runflow_run_control | Returns 204 | |
/api/v1/tenant/quotas | Tenant quotas | runflow_run_read | Returns TenantQuotas |
Request / Response Models #models
RunSubmitRequest — POST /api/v1/runs body
{
"image": "python:3.12-slim", // required, Docker image ref (host/path[:tag|@digest])
"command": ["python", "-c", "..."], // default [], command args
"env": {"KEY": "value"}, // default {}, injected as container env vars (string values only)
"scheduled_start": null, // ISO-8601 or null (ASAP)
"timeout_seconds": null, // null=use default; clamped to quota max_timeout_seconds
"cpu_limit": null, // cores, clamped to quota max_cpu_cores
"memory_limit_mb": null, // MB, clamped to quota max_memory_mb
"name": "optional-label", // max 128 chars
"tags": ["tag1", "tag2"] // default [], free-form labels
}
RunSubmitResponse — 202
{
"track_id": "01KY0XX71BMX0B88C3A189S2K7", // ULID
"status": "scheduled",
"eligible_start": "2026-07-20T23:31:17Z",
"clamped": false // true if requested limits were reduced to quota
}
RunStatus — GET /api/v1/runs/{track_id}
{
"track_id": "...",
"tenant_id": "...",
"state": "running", // scheduled|queued|pulling|running|paused|exited|failed|timeout|killed
"image": "...",
"command": [...],
"env": {...},
"submitted_at": "...",
"eligible_start": "...",
"started_at": "...", // null until running
"finished_at": "...", // null until terminal
"exit_code": 0, // null until terminal
"container_id": "...",
"worker_id": "...",
"cpu_limit": 1.0,
"memory_limit_mb": 512,
"timeout_seconds": 60,
"name": "...",
"tags": [...],
"stdout_file": "/var/log/runflow/...stdout",
"stderr_file": "/var/log/runflow/...stderr",
"events": [{"ts":"...","from_state":"...","to_state":"...","reason":"...","payload":{}}],
"scheduling_info": { // see Scheduling section
"eligible_start": "2026-07-21T10:00:00Z",
"working_hours_applied": false,
"queue_position": 3, // null if not scheduled
"estimated_start": "2026-07-21T10:00:30Z" // soft estimate, null if no history
}
}
RunResult — GET /api/v1/runs/{track_id}/result (terminal only)
{
"track_id": "...",
"exit_code": 0,
"status": "exited", // exited|failed|timeout|killed
"started_at": "...",
"finished_at": "...",
"duration_seconds": 12.34,
"stdout_bytes": 1024,
"stderr_bytes": 0,
"stdout_file": "...",
"stderr_file": "...",
"result_summary": "..."
}
LogResponse — GET /api/v1/runs/{track_id}/logs
{
"lines": ["line1", "line2"],
"next_since": 2, // pass as ?since=2 for the next page
"eof": true // false = more lines may arrive (run still active)
}
UtilizationResponse — GET /api/v1/runs/{track_id}/utilization
{
"samples": [
{
"timestamp": "2026-07-20T23:31:18Z",
"cpu_percent": 12.5,
"memory_mb": 128.4,
"memory_limit_mb": 512,
"net_rx_bytes": 1024,
"net_tx_bytes": 2048
}
]
}
TenantQuotas — GET /api/v1/tenant/quotas, PUT /api/v1/tenant/quotas
{
"max_concurrent_runs": 10, // max runs in {pulling,running,paused} simultaneously
"max_daily_runs": 100, // max submissions per UTC day (still rejected at submit)
"max_timeout_seconds": 86400,
"max_cpu_cores": 4.0,
"max_memory_mb": 4096,
"max_queue_seconds": 3600, // max wait after eligible_start before queue_timeout
"working_hours_tjp": null, // TJP shift definition, see Scheduling section
"retention_days": 30
}
PUT requires runflow_tenant_admin permission. Only the quotas fields you include are updated.
curl -X PUT https://runflow.rodmena.app/api/v1/tenant/quotas \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"max_concurrent_runs": 5,
"max_queue_seconds": 1800,
"working_hours_tjp": "shift shift_def \"Office\" { workinghours mon - fri 09:00 - 18:00 }"
}'
Errors #errors
Standard error envelope (FastAPI default):
{
"detail": "human-readable message"
}
| Status | Meaning |
|---|---|
400 | Bad request — invalid image reference, malformed JSON |
401 | No/invalid bearer token |
403 | Permission denied (missing RBAC permission) |
404 | Run not found (wrong track_id or not owned by tenant) |
409 | Conflict — result requested on non-terminal run, pause/resume on wrong state |
422 | Validation error (Pydantic schema mismatch, invalid TJP shift) |
429 | Daily quota exceeded (daily_quota_exceeded) or rate limited (per-tenant sliding window). Note: concurrent quota no longer rejects — see Scheduling. |
500 | Internal server error |