Designed for Coding Agents

Run/Flow

Hello, AI agents. This documentation provides everything you need to execute Docker tasks autonomously via our REST API. No human intervention required.

REST API at https://runflow.rodmena.app — Use your configured API key as the Bearer token.

Capabilities & The Magic #capabilities

What RunFlow Does

The Most Differentiating Thing

Freeze a live computation mid-flight — with its full in-memory state preserved — resume it exactly where it left off, and profile its CPU/memory the whole time, all over HTTP.

Serverless can't pause a running invocation; CI/cron can't freeze-and-resume a live job; plain docker needs shell access and gives you no governed observability API. RunFlow makes a running container behave like a process you can attach a debugger to — remotely, safely, and with RBAC + quotas around it.

Live Demo: Pause & Resume

Here is a trivial Python "training loop" that keeps its state only in process memory (so continuation proves the state survived the freeze). You can run this live:

# Set up your environment (Use the demo API key to try this live!)
export KEY="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
export BASE="https://runflow.rodmena.app"

# 1) Submit a simulated training job that streams progress
TID=$(curl -s -X POST $BASE/api/v1/runs -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
  "image":"python:3.12-slim",
  "command":["python","-u","-c","import time\nloss=1.0\nfor epoch in range(1,21):\n    time.sleep(1)\n    loss*=0.95\n    print(f\"epoch {epoch}/20 loss={loss:.4f}\",flush=True)"],
  "timeout_seconds":60,"cpu_limit":0.5,"memory_limit_mb":128,"name":"pause-resume-demo"}' | jq -r .track_id)

# 2) Watch logs stream + profile CPU live
curl -s "$BASE/api/v1/runs/$TID/logs?stream=stdout" -H "Authorization: Bearer $KEY" | jq -r '.lines[-3:][]'
curl -s "$BASE/api/v1/runs/$TID/utilization"        -H "Authorization: Bearer $KEY" | jq '.samples[-1]'

# 3) FREEZE it mid-training → logs stop advancing, state=paused, CPU→0
curl -s -X POST "$BASE/api/v1/runs/$TID/pause"  -H "Authorization: Bearer $KEY"

# 4) RESUME → it continues from the exact epoch it froze at (memory intact)
curl -s -X POST "$BASE/api/v1/runs/$TID/resume" -H "Authorization: Bearer $KEY"

# 5) Collect the result when it finishes
curl -s "$BASE/api/v1/runs/$TID/result" -H "Authorization: Bearer $KEY" | jq

What you'll see (actual run output):

StepObserved
mid-runepoch 8/20 loss=0.6634, cpu_percent > 0%, X-Log-Source: redis
after pausestate paused, frozen at epoch 8
6s later, still pausedstill epoch 8 — the process is frozen, zero CPU
after resumejumps to epoch 9/20 loss=0.6302 — continued exactly from where it froze. The loss kept falling, proving in-memory state was perfectly preserved, not restarted.
The "Aha" Moment: You just suspended a running computation and resumed it with its memory intact — no checkpointing code, no SSH — and watched its CPU the whole time, all through a governed REST API.

Authentication #auth

As an AI agent, you will use a Bearer Token to authenticate. The backend uses a two-tier model:

Your API key should be provided by the human user or accessible in your environment. Do not generate a random UUID. If you do not have one, a free-tier key can be obtained by emailing runflow-freepass@mail.rodmena.co.uk from the address it should be bound to (and runflow-freepass+rotate@mail.rodmena.co.uk to replace a compromised one). Ask your human to send it — the key is replied to that mailbox, so an agent cannot self-serve. Free-tier keys are restricted; call GET /api/v1/usage to see exactly what yours allows. The API key serves as the tenant isolation boundary: all runs, logs, and metadata are scoped to your identity.

Authorization: Bearer <your-api-key>

RBAC permissions are granted per API key in auth.rodmena.co.uk:

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
Finding your API key: As a coding agent, you should check your provided environment variables or ask the human user to supply the UUIDv4 API key.
curl https://runflow.rodmena.app/api/v1/tenant/quotas \
  -H "Authorization: Bearer $API_KEY"

Quick Start #quickstart

Follow these exact steps to execute a job programmatically:

1

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}}
2

Submit a hello-world Docker run

API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx   # your API key (email sales@rodmena.co.uk to obtain one)
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}
3

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

Comparison & Alternatives #comparison

How does RunFlow compare to other compute platforms? Here is a breakdown of pros, cons, and when to use what.

Serverless Functions (AWS Lambda, Google Cloud Run)

CI/CD Pipelines (GitHub Actions, GitLab CI)

Raw Kubernetes / Docker API

Native DAG Workflows

RunFlow runs directed-acyclic-graph workflows natively. Submit a graph of container nodes and RunFlow schedules each node as its own governed run the moment its dependencies are satisfied — no sidecar orchestrator required.

POST   /api/v1/workflows                   # submit a DAG (nodes + edges)
GET    /api/v1/workflows                   # list workflows
GET    /api/v1/workflows/{id}              # status + per-node state
GET    /api/v1/workflows/{id}/nodes/{ref}  # one node's detail
GET    /api/v1/workflows/{id}/result       # per-node outcomes when terminal
GET    /api/v1/workflows/{id}/events       # event log (replay/subscribe by ?since=)
POST   /api/v1/workflows/{id}/cancel       # cancel the whole graph
POST   /api/v1/workflows/{id}/pause|resume # halt / continue the graph
POST   .../nodes/{ref}/retry|approve|reject # retry a stage; decide an approval gate

Full reference — states, engine semantics, request models and worked examples — in Workflows. Copy-paste examples: 4g–4j.

Available over both the REST API and MCP. Connect any MCP client to the hosted endpoint at https://runflow.rodmena.app/mcp (streamable-HTTP, Authorization: Bearer <api-key>) — 25 tools at full REST parity: submit/monitor/control/delete runs; submit, monitor, cancel, pause/resume, retry, approve/reject, and event-stream workflows; read/update quotas.

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. Agent Task: Fetch HackerNews Top Stories

A more complex script showing how an AI agent can execute data-fetching logic entirely on the server. We run a Python one-liner to fetch the top 5 stories from HackerNews and print their titles.

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 urllib.request, json\ntop5=json.loads(urllib.request.urlopen(\"https://hacker-news.firebaseio.com/v0/topstories.json\").read())[:5]\nfor i,sid in enumerate(top5):\n    title=json.loads(urllib.request.urlopen(f\"https://hacker-news.firebaseio.com/v0/item/{sid}.json\").read()).get(\"title\")\n    print(f\"{i+1}. {title}\")"],
    "timeout_seconds": 30,
    "name": "hn-top-5",
    "tags": ["agent", "data-fetch"]
  }'

4e. Scheduling (One-time & Recurring)

A) One-Time Run: scheduled_start is an ISO-8601 UTC instant. A past time is clamped to now. RunFlow holds the run in state scheduled and dispatches it exactly at the target time. (You cannot omit the 'Z').

# Schedule a run 60 seconds from now
WHEN=$(date -u -d '+60 seconds' +%Y-%m-%dT%H:%M:%SZ)

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 datetime;print(\"ran at\", datetime.datetime.now(datetime.timezone.utc).isoformat())"],
    "scheduled_start": "'"${WHEN}"'",
    "timeout_seconds": 30,
    "name": "scheduled-one-time"
  }'

B) Recurring Runs (Cron): RunFlow does schedule. Three options, pick by what you actually need:

One thing worth knowing if you reach for scheduled_start on a run to fake a timer: a deferred run occupies your max_concurrent_runs quota from submission, not from execution — so a run scheduled a week out holds a slot for a week. Use a timer instead.

# Example crontab (run 'crontab -e', then add):
# 5 9 * * * (runs at 09:05 every day)
5 9 * * * curl -s -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(\"daily job\")"],"name":"daily-job"}' \
  >> $HOME/runflow-daily.log 2>&1

4f. 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"

4g. Workflow: a real ETL DAG (fan-out, fan-in, quality gate)

One POST submits the whole graph. Each node is its own governed run with its own track_id; RunFlow launches each one the moment its dependencies are satisfied. This graph extracts, shards the work across two parallel transforms, merges them, gates on data quality, then either publishes or alerts — the exact pipeline shape most people actually need.

curl -X POST https://runflow.rodmena.app/api/v1/workflows \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "nightly-etl",
    "tags": ["etl"],
    "failure_policy": "continue",
    "nodes": [
      {
        "ref": "extract",
        "image": "python:3.12-slim",
        "command": ["python","-c","import json;print(json.dumps({\"rows\":1200,\"nulls\":5}))"],
        "cpu_limit": 0.5, "memory_limit_mb": 128, "timeout_seconds": 120
      },
      {
        "ref": "transform-eu",
        "image": "python:3.12-slim",
        "inputs_from": ["extract"],
        "command": ["python","-c","import json,os;d=json.loads(os.environ[\"RUNFLOW_INPUT_EXTRACT\"]);print(json.dumps({\"region\":\"eu\",\"rows\":d[\"rows\"]//2,\"nulls\":d[\"nulls\"]//2}))"]
      },
      {
        "ref": "transform-us",
        "image": "python:3.12-slim",
        "inputs_from": ["extract"],
        "command": ["python","-c","import json,os;d=json.loads(os.environ[\"RUNFLOW_INPUT_EXTRACT\"]);print(json.dumps({\"region\":\"us\",\"rows\":d[\"rows\"]//2,\"nulls\":d[\"nulls\"]-d[\"nulls\"]//2}))"]
      },
      {
        "ref": "load",
        "image": "python:3.12-slim",
        "inputs_from": ["transform-eu","transform-us"],
        "command": ["python","-c","import json,os;a=json.loads(os.environ[\"RUNFLOW_INPUT_TRANSFORM_EU\"]);b=json.loads(os.environ[\"RUNFLOW_INPUT_TRANSFORM_US\"]);print(json.dumps({\"loaded\":a[\"rows\"]+b[\"rows\"],\"nulls\":a[\"nulls\"]+b[\"nulls\"]}))"]
      },
      {
        "ref": "quality-gate",
        "image": "python:3.12-slim",
        "inputs_from": ["load"],
        "retries": 1,
        "retry_backoff_seconds": 5,
        "command": ["python","-c","import json,os,sys\nd=json.loads(os.environ[\"RUNFLOW_INPUT_LOAD\"])\nrate=100.0*d[\"nulls\"]/d[\"loaded\"]\nif rate>1.0: sys.exit(1)\nprint(json.dumps({\"passed\":True,\"null_rate\":round(rate,2),\"rows\":d[\"loaded\"]}))"]
      },
      {
        "ref": "publish",
        "image": "python:3.12-slim",
        "inputs_from": ["quality-gate"],
        "command": ["python","-c","import json,os;q=json.loads(os.environ[\"RUNFLOW_INPUT_QUALITY_GATE\"]);print(json.dumps({\"published\":True,\"rows\":q[\"rows\"]}))"]
      },
      {
        "ref": "alert",
        "image": "python:3.12-slim",
        "command": ["python","-c","print(\"paging data-oncall\")"]
      }
    ],
    "edges": [
      {"from": "extract",      "to": "transform-eu", "condition": "on_success"},
      {"from": "extract",      "to": "transform-us", "condition": "on_success"},
      {"from": "transform-eu", "to": "load",         "condition": "on_success"},
      {"from": "transform-us", "to": "load",         "condition": "on_success"},
      {"from": "load",         "to": "quality-gate", "condition": "on_success"},
      {"from": "quality-gate", "to": "publish",      "condition": "on_success"},
      {"from": "quality-gate", "to": "alert",        "condition": "on_failure"}
    ]
  }'

Returns 202 {"workflow_id": "01K...", "status": "pending"}. Note the edge key is "from" (not from_ref) on the way in. transform-eu and transform-us run concurrently; load waits for both. When the gate passes, publish runs and alert is marked skipped; when it fails, the reverse. Each node's last stdout line is JSON, so it lands in the next node as RUNFLOW_INPUT_<REF> — see Message passing.

4h. Workflow: monitor it

Three views: the live graph, the event log, and the terminal result.

WF=01K...  # workflow_id from the submit response

# live graph — every node's state, attempt count and current track_id
curl -s https://runflow.rodmena.app/api/v1/workflows/$WF \
  -H "Authorization: Bearer $API_KEY" | jq '.nodes[] | {ref, state, attempt}'

# event log — replay from the start
curl -s "https://runflow.rodmena.app/api/v1/workflows/$WF/events?since=0" \
  -H "Authorization: Bearer $API_KEY" | jq -r '.[] | "\(.id) \(.node_ref // "workflow") -> \(.to_state)"'

# ...then subscribe: re-poll with the highest id you have seen
curl -s "https://runflow.rodmena.app/api/v1/workflows/$WF/events?since=93" \
  -H "Authorization: Bearer $API_KEY"

# per-node detail — every attempt's track_id, so you can pull that run's logs
curl -s https://runflow.rodmena.app/api/v1/workflows/$WF/nodes/quality-gate \
  -H "Authorization: Bearer $API_KEY" | jq '{state, attempt, attempts, output}'

# terminal result — per-node outcomes (409 not_terminal while still running)
curl -s https://runflow.rodmena.app/api/v1/workflows/$WF/result \
  -H "Authorization: Bearer $API_KEY" | jq

A node's logs are just its run's logs: take a track_id from attempts and call GET /api/v1/runs/{track_id}/logs. There is no separate workflow log endpoint — one node = one run.

4i. Workflow: control — pause, resume, retry a failed stage, cancel

# pause the graph: no NEW nodes are launched; in-flight nodes run to completion
curl -X POST https://runflow.rodmena.app/api/v1/workflows/$WF/pause \
  -H "Authorization: Bearer $API_KEY"

# resume
curl -X POST https://runflow.rodmena.app/api/v1/workflows/$WF/resume \
  -H "Authorization: Bearer $API_KEY"

# fix the cause, then re-run a failed stage AND everything downstream of it.
# Works on an already-terminal workflow: it goes back to running.
curl -X POST https://runflow.rodmena.app/api/v1/workflows/$WF/nodes/quality-gate/retry \
  -H "Authorization: Bearer $API_KEY"
# -> {"status":"ok","message":"Retrying quality-gate (+2 downstream)"}

# cancel everything: in-flight node runs are killed, the rest marked cancelled
curl -X POST https://runflow.rodmena.app/api/v1/workflows/$WF/cancel \
  -H "Authorization: Bearer $API_KEY"

4j. Workflow: human-in-the-loop approval gate

A node with "type":"approval" runs no container. The branch parks at waiting_approval — indefinitely, and the workflow stays active — until you decide. Approve routes down on_success, reject routes down on_failure. Approval nodes need no image.

curl -X POST https://runflow.rodmena.app/api/v1/workflows \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "deploy-with-signoff",
    "nodes": [
      {"ref": "build",  "image": "python:3.12-slim",
       "command": ["python","-c","print(\"built\")"]},
      {"ref": "signoff", "type": "approval"},
      {"ref": "deploy", "image": "python:3.12-slim",
       "command": ["python","-c","print(\"deploying\")"]},
      {"ref": "discard", "image": "python:3.12-slim",
       "command": ["python","-c","print(\"discarded\")"]}
    ],
    "edges": [
      {"from": "build",   "to": "signoff", "condition": "on_success"},
      {"from": "signoff", "to": "deploy",  "condition": "on_success"},
      {"from": "signoff", "to": "discard", "condition": "on_failure"}
    ]
  }'

# ship it (deploy runs, discard is skipped)
curl -X POST https://runflow.rodmena.app/api/v1/workflows/$WF/nodes/signoff/approve \
  -H "Authorization: Bearer $API_KEY"

# or don't (discard runs, deploy is skipped)
curl -X POST https://runflow.rodmena.app/api/v1/workflows/$WF/nodes/signoff/reject \
  -H "Authorization: Bearer $API_KEY"

A rejected gate is a failed node, so the workflow settles failed even though rejecting was the intended outcome. If you want a clean succeeded on the reject path, use failure_policy: "continue" and treat failed_nodes as the signal.

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.

Contract change (v2): Concurrent quota no longer rejects submissions with 429. Instead, runs are accepted and queued. Only 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))

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"
  }'

Expiry — expires_at (absolute deadline)

Pass an optional expires_at (ISO-8601 UTC instant) on submit to set an absolute deadline: if the run has not started by that time, RunFlow fails it as expired instead of dispatching it. It is independent of scheduled_start (when a run is allowed to start) and of max_queue_seconds (a window relative to eligible_start) — expires_at is a hard wall-clock cutoff. Use it for time-sensitive work you would rather drop than run late (e.g. "place this call, but never in the middle of the night").

# Run it, but only if it can start before the deadline — else drop it
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(\"on time\")"],
    "expires_at": "2026-07-24T22:00:00Z"
  }'
# If still `scheduled` at 22:00:00Z: state=failed, reason=expired (never dispatched)

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"
  }
}

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.

scheduled -> queued -> pulling -> running -> exited | failed | timeout | killed | v paused -> (resume) -> running
StateCategoryMeaning
scheduledactiveSubmitted, waiting for eligible_start or a free worker slot.
queuedactiveSlot acquired, waiting in the worker queue.
pullingactiveDocker is pulling the image.
runningactiveContainer is executing.
pausedactiveContainer paused via POST /pause. Resume with /resume.
exitedterminalContainer exited 0 or was stopped gracefully.
failedterminalContainer exited non-zero, or the worker failed to start it.
timeoutterminalRun exceeded timeout_seconds and was killed.
killedterminalRun 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.

Workflows (DAG) #workflows

A workflow is a directed acyclic graph of nodes joined by conditional edges. Submit the whole graph in one POST /api/v1/workflows; the engine ticks every second, launching each node the moment its incoming edges are satisfied. One node = one run = one track_id — nodes are ordinary runs, so they inherit every quota, working-hours rule and container hardening described elsewhere on this page, and their logs, results and utilization come from the same /api/v1/runs/{track_id}/* endpoints.

Worked examples: 4g submit, 4h monitor, 4i control, 4j approval gate.

Workflow states

pending -> running -> succeeded | failed | cancelled | v paused -> (resume) -> running
StateCategoryMeaning
pendingactiveAccepted, not yet picked up by an engine tick.
runningactiveAt least one node is running, retrying or awaiting approval.
pausedactivePaused via POST /pause. No new nodes launch; in-flight nodes finish.
succeededterminalNothing left to run and no node ended failed.
failedterminalNothing left to run and at least one node ended failed. See failed_nodes.
cancelledterminalCancelled via POST /cancel.

A workflow is terminal only when no node is running, retrying or waiting_approval. An approval gate keeps it active indefinitely.

Node states

StateCategoryMeaning
pendingactiveWaiting on upstream nodes.
runningactiveIts run is dispatched (the run has its own lifecycle above).
retryingactiveAttempt failed, waiting out retry_backoff_seconds before the next one.
waiting_approvalactiveApproval gate parked until /approve or /reject.
succeededterminalRun exited 0 (or gate approved).
failedterminalRun exited non-zero with retries exhausted (or gate rejected).
skippedterminalIts branch was not taken, or its dependencies can never be satisfied.
cancelledterminalThe workflow was cancelled before this node finished.

Edge conditions

An edge fires based on the upstream node's terminal state. A node launches when every incoming edge is satisfied (nodes with no incoming edges are roots and start immediately).

ConditionFires when upstream is
on_success (default)succeeded
on_failurefailed
alwayssucceeded or failed

always does not mean unconditional. An upstream that ends skipped or cancelled satisfies no condition — including always — so skips propagate down the graph instead of leaking through. A node with any unsatisfiable incoming edge is marked skipped itself.

failure_policy

ValueBehaviour
fail_fast (default)After any node fails, no further forward node is launched. Nodes on a failure path (any incoming on_failure or always edge) still run, so cleanup and alerting still fire. Remaining pending nodes are drained to skipped.
continueUnaffected branches keep running to completion. Use this when independent branches shouldn't punish each other.

Either way the workflow still settles failed if any node failed — continue changes how much of the graph runs, not the verdict. Check failed_nodes in the result.

Message passing between nodes

Nodes exchange small JSON payloads — there are no shared volumes.

inputs_from does not create a dependency edge. It only says where to read output from. A node that reads inputs_from: ["extract"] without a matching edge may launch before extract finishes and see nothing. Always declare the edge too — every ref in inputs_from must exist, or submit fails with 400 invalid_graph.

Retries

Each node carries its own retries (0–10, default 0) and retry_backoff_seconds (default 10). A failed attempt puts the node in retrying, waits the backoff, then dispatches a fresh run with a new track_id — every attempt is listed in attempts on the node detail endpoint. Only when retries are exhausted does the node become failed and its on_failure edges fire. The backoff is fixed, not exponential.

POST /nodes/{ref}/retry is the manual counterpart: it resets that node and its entire downstream closure to pending, and revives an already-terminal workflow back to running. Use it after fixing the underlying cause. The node must be terminal (else 409 node_not_terminal).

Limits

Two tenant quotas apply on top of the per-run ones: max_nodes_per_workflow (default 50, exceeded → 400 too_many_nodes) and max_concurrent_workflows (default 10, exceeded → 429 workflow_quota_exceeded). Every node also consumes a normal run slot and counts toward max_daily_runs, so a 6-node workflow spends 6 of your daily runs.

WorkflowSubmitRequest — POST /api/v1/workflows body

{
  "name": "nightly-etl",               // optional, max 128 chars
  "tags": ["etl"],                     // default [], free-form labels
  "failure_policy": "fail_fast",       // fail_fast (default) | continue
  "nodes": [ /* NodeSpec, at least 1 */ ],
  "edges": [ /* EdgeSpec, default [] */ ]
}

NodeSpec — one node

{
  "ref": "transform-eu",               // required, unique within the workflow, max 128 chars
  "type": "container",                 // container (default) | approval
  "image": "python:3.12-slim",         // required for container nodes; omit for approval
  "command": ["python", "-c", "..."],   // default []
  "env": {"KEY": "value"},              // default {}, string values only
  "inputs_from": ["extract"],           // default [], upstream refs to inject as RUNFLOW_INPUT_*
  "retries": 0,                        // default 0, range 0-10
  "retry_backoff_seconds": 10,          // default 10, fixed (not exponential)
  "cpu_limit": null,                    // per-node; clamped to tenant quota
  "memory_limit_mb": null,              // per-node; clamped to tenant quota
  "timeout_seconds": null               // per-node; clamped to tenant quota
}

Unknown keys are silently ignored, not rejected. A typo such as max_retries instead of retries returns 202 and then never retries. Check the field names above against your body if a node doesn't behave as configured.

EdgeSpec — one dependency

{
  "from": "extract",                    // required, upstream ref. NOTE: "from", not "from_ref"
  "to": "transform-eu",                // required, downstream ref
  "condition": "on_success"           // on_success (default) | on_failure | always
}

Requests use "from"; responses echo edges back as "from_ref"/"to_ref".

WorkflowStatus — GET /api/v1/workflows/{workflow_id}

{
  "workflow_id": "01KY2NQ63B95DZ7SYD741W3S45",
  "tenant_id": "...",
  "name": "nightly-etl",
  "state": "running",                  // pending|running|paused|succeeded|failed|cancelled
  "failure_policy": "continue",
  "submitted_at": "...",
  "started_at": "...",                 // null until the first node launches
  "finished_at": "...",                // null until terminal
  "tags": [...],
  "nodes": [{
    "ref": "quality-gate",
    "type": "container",
    "state": "running",                // see Node states above
    "attempt": 1,
    "current_track_id": "01KY...",    // pull logs via /api/v1/runs/{track_id}/logs
    "exit_code": null
  }],
  "edges": [{"from_ref":"load","to_ref":"quality-gate","condition":"on_success"}]
}

WorkflowNodeDetail — GET /api/v1/workflows/{workflow_id}/nodes/{ref}

{
  "ref": "quality-gate",
  "state": "failed",
  "attempt": 2,
  "attempts": ["01KY2H7ZJN...", "01KY2H85EV..."],  // track_id per attempt, oldest first
  "current_track_id": "01KY2H85EV...",
  "output": {...},                            // parsed from the last stdout line
  "started_at": "...",
  "finished_at": "..."
}

WorkflowResult — GET /api/v1/workflows/{workflow_id}/result (terminal only)

{
  "workflow_id": "...",
  "state": "succeeded",                 // succeeded|failed|cancelled
  "duration_seconds": 5.08,
  "nodes": [{
    "ref": "quality-gate",
    "final_state": "succeeded",
    "exit_code": 0,
    "attempts": 1,                     // count, not the id list
    "output": {"passed": true}
  }],
  "failed_nodes": []                        // refs that ended failed
}

Returns 409 not_terminal while the workflow is still active.

WorkflowEvent — GET /api/v1/workflows/{workflow_id}/events

id is a monotonic cursor. Replay from ?since=0; subscribe by re-polling with the highest id you have seen. Query: since (default 0), limit (default 200).

[{
  "id": 89,                              // cursor — pass back as ?since=89
  "workflow_id": "...",
  "node_ref": "quality-gate",            // null for workflow-level events
  "kind": "node",                       // node | workflow
  "to_state": "running",
  "detail": {"attempt": 1},              // e.g. {"attempt":N} or {"has_output":true}
  "created_at": "2026-07-21T15:46:44Z"
}]

Workflow Templates #templates

Register a DAG once, then run it by id — or by a name you choose — with just parameters. A template turns a pipeline into a stable, typed, tenant-private endpoint instead of a fat request body you keep re-sending. It is not a second engine: a template run substitutes your parameters and then goes down exactly the same path as POST /api/v1/workflows, so graph validation, the image allowlist, resource clamping, metering and container hardening are unchanged.

Register it

Declare parameters, then reference them anywhere you want substitution as ${{ name }}. The declaration is the template’s contract: a bad call is a 422 before any container starts.

curl -X POST https://runflow.rodmena.app/api/v1/templates \
  -H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "name": "summarise-repo",                 // optional; ^[a-z][a-z0-9-]{1,62}$, unique to YOUR tenant
    "params": [
      {"name": "repo",  "type": "string", "required": true,
       "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"},
      {"name": "model", "type": "enum", "enum": ["glm-5.2", "gpt-4o-mini"], "default": "glm-5.2"},
      {"name": "LLM_KEY", "type": "string", "required": true, "secret": true}
    ],
    "definition": {                                // the same body as POST /workflows
      "name": "summarise-${{ repo }}",
      "nodes": [
        {"ref": "fetch", "image": "python:3.12-slim", "timeout_seconds": 60,
         "command": ["python", "-c", "..."],
         "env": {"SOURCE_URL": "https://api.github.com/repos/${{ repo }}"}},
        {"ref": "summarise", "image": "python:3.12-slim", "inputs_from": ["fetch"],
         "command": ["python", "-c", "..."],
         "env": {"LLM_MODEL": "${{ model }}", "LLM_API_KEY": "${{ LLM_KEY }}"}}
      ],
      "edges": [{"from": "fetch", "to": "summarise", "condition": "on_success"}]
    }
  }'
# 201 -> {"template_id":"01KYD...","name":"summarise-repo","version":1, ...}

Run it

curl -X POST https://runflow.rodmena.app/api/v1/templates/summarise-repo/run \
  -H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
  -d '{"params": {"repo": "rodmena-limited/RunFlow", "LLM_KEY": "sk-..."}}'
# 202 -> {"workflow_id":"01KYD...","template_id":"01KYD...","version":1,"status":"pending"}

# from here it is an ordinary workflow:
curl .../api/v1/workflows/$WF          # state + per-node progress
curl .../api/v1/workflows/$WF/result   # each node's JSON output
curl ".../api/v1/workflows?template_id=$TID"   # every run of this template

Optional on a run: version (pin a version), name (override the workflow name), tags (added to the definition’s), scheduled_start (defer the whole graph — no node starts before it), webhook.

Parameters

Names, versions, limits

Over MCP: runflow_create_template, runflow_list_templates, runflow_get_template, runflow_update_template, runflow_delete_template, runflow_run_template — same arguments, same RBAC, same tenant scoping.

Recurring schedules are live. Two primitives, both delivering a signed webhook and running no container, so neither spends run quota: POST /api/v1/timers for a one-shot wake at an absolute instant, and POST /api/v1/schedules for a recurring cron in an IANA timezone (exactly-once per occurrence, DST-safe, fires on recovery after downtime with an honest fire_delay_seconds). A template trigger (POST /api/v1/templates/{ref}/triggers) is the third form: a cron that launches a stored workflow rather than delivering a webhook.

Outbound Webhooks #webhooks

Stop polling. Register a delivery target once and RunFlow POSTs a signed JSON envelope the moment a run, workflow or node changes state — over the same lifecycle catalog the /events cursor already exposes. Delivery is at-least-once (idempotent on event_id), retried with exponential backoff, observable, replayable, and SSRF-hardened.

Two ways to register

Inline (one shot) — declare a webhook object on a run or workflow submit body; scoped strictly to that one resource.

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(\"done\")"],
    "webhook": {"url": "https://me.example.com/hooks/run1", "events": ["run.terminal"]}
  }'

Tenant endpoint (durable) — register once, filter by event. A 32-byte secret is generated for you and returned exactly once; every delivery is signed with it.

curl -X POST https://runflow.rodmena.app/api/v1/webhooks \
  -H "Authorization: Bearer $API_KEY" -H 'Content-Type: application/json' \
  -d '{"url": "https://me.example.com/hooks/runflow", "events": ["run.failed", "node.waiting_approval"]}'
# -> 201 {"endpoint_id":"01K...","secret":"a1b2...(64 hex chars)","url":"https://me.example.com/hooks/runflow","events":["run.failed","node.waiting_approval"],...}

If an endpoint with an inline webhook both match the same event, RunFlow delivers to all of them as independent deliveries with independent attempt budgets.

Event catalog

ResourceEventsAliasWildcard
runrun.scheduled, run.queued, run.pulling, run.running, run.paused, run.resumed, run.stopping, run.exited, run.failed, run.timeout, run.killedrun.terminal (= exited | failed | timeout | killed)run.*
workflowworkflow.running, workflow.paused, workflow.resumed, workflow.succeeded, workflow.failed, workflow.cancelledworkflow.terminalworkflow.*
nodenode.running, node.retrying, node.waiting_approval, node.succeeded, node.failed, node.skipped, node.cancellednode.*
any* matches every lifecycle event. webhook.test is deliverable only via POST /api/v1/webhooks/{id}/test and is never emitted by lifecycle.

A subscription's events list is a union: a delivery is enqueued when the concrete event matches any literal, alias, or wildcard. Exactly one delivery is enqueued per (matching subscription, event) pair, even if several patterns match.

Envelope

{
  "event_id": "01K...",                  // ULID; stable across attempts + redelivery (dedupe on this)
  "event_type": "run.exited",
  "occurred_at": "2026-07-21T10:00:00Z",
  "tenant_id": "...",
  "resource": {"type": "run", "id": "01K..."},
  "data": { ... },                       // see data shape below
  "delivery": {"delivery_id": 42, "attempt": 1, "endpoint_id": "01K...|null"},
  "truncated": false                     // true if result_summary or data.nodes were trimmed to fit max_webhook_payload_bytes
}

For a run event, data carries {track_id, state, name, tags, image, submitted_at, started_at, finished_at, workflow_id, node_ref, env} with env redacted. For a run.terminal event, a result block is added: {exit_code, duration_seconds, stdout_bytes, stderr_bytes, result_summary} (last 1 KB of stdout). For a workflow event, data carries {workflow_id, state, name, failure_policy, tags, nodes, failed_nodes}. For a node event, {workflow_id, ref, state, attempt, current_track_id, exit_code, output}.

Headers

HeaderValue
Content-Typeapplication/json
X-RunFlow-Eventthe concrete event type (e.g. run.exited)
X-RunFlow-Event-Idthe ULID event_id from the envelope
X-RunFlow-Deliverythe numeric delivery_id
X-RunFlow-Attemptthe attempt number (1-based)
X-RunFlow-TimestampUnix seconds, bound into the signature
X-RunFlow-Signaturesha256=<hex> = HMAC-SHA256(key, "<timestamp>.<raw body>") where key = bytes.fromhex(secret) — see the note below; none for an inline webhook with no secret
User-AgentRunFlow/<version>

Hex-decode the secret before you HMAC with it. A secret we generate is 32 random bytes, handed to you once as 64 hex characters. The HMAC key is those 32 raw bytes (bytes.fromhex(secret)), not the 64-character string. Using the string directly produces a well-formed signature that will never match ours, and the symptom is that every delivery fails your verification while your code looks correct. A secret you supply is used verbatim as the key, with no decoding. GET /api/v1/webhooks/{id} reports which kind you hold as secret_origin: generated, supplied, or unknown for endpoints registered before we recorded it — we do not guess, because a wrong provenance points you at the wrong derivation. Rotation always generates, so a supplied-secret endpoint flips to generated on rotation and the governing rule flips with it.

Reference vector — a verifier that reproduces this will also verify real deliveries. The dummy secret below is the same shape we mint (64 hex), which is what makes it a valid check:

secret     = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
key        = bytes.fromhex(secret)                  # 32 bytes
timestamp  = 1700000000
raw body   = {"event":"webhook.test","data":{"hello":"world"}}
signed     = "1700000000." + raw body               # note the "." separator

X-RunFlow-Signature: sha256=17737c4354daa76a95234accecd925dbb898813f8ae49c30be40f563c141a079

# Keying with the 64-character STRING instead of its decoded bytes gives:
#   sha256=ffa67b3ea6d46819da436a844b11096bc763ea2c20e278e0cb38b69e149013c1
#
# For a GENERATED secret (this vector) that is the mistake. For a SUPPLIED
# secret it is the CORRECT and only derivation — the same operation, right or
# wrong depending entirely on where the secret came from.

There is no origin-independent “wrong” derivation. Do not turn the line above into a receiver rule such as “reject the verbatim-keyed digest”: that rejects every legitimately supplied secret. Decide by origin at configure time — you either minted the secret or we did — and key each secret one way only. A receiver that tries both derivations authenticates no more callers, but it erases the distinction and makes this documented error indistinguishable from a valid signature.

Compare the raw request body bytes — do not re-serialise the parsed JSON before hashing, as any key reordering or whitespace change alters the digest.

Zero-gap secret rotation. rotate-secret MINTS, so you cannot pre-install the new value and every delivery between the swap and your redeploy fails verification. To rotate with no window, supply instead: generate a value yourself → install it in your receiver → PATCH /api/v1/webhooks/{id} with {"secret": "…"} → retire the old one at your leisure. You install before we switch, so no valid signature is ever refused.

The caveat that makes or breaks it: a supplied secret is keyed verbatim, whatever it looks like. If you generate 64 hex characters and supply them, the value looks generated, parses as hex, and must still be keyed verbatim — so a receiver that hex-decodes it derives a different key and fails silently rather than loudly. Tag the entry in your own config with the derivation at the moment you install it; the config change and the PATCH have to land together.

Verifying the signature (Python)

import hmac, hashlib

def verify(raw_body: bytes, signature_header: str, shared_secret: bytes) -> bool:
    if signature_header == "none":
        return True  # inline webhook opted out of signing
    if not signature_header.startswith("sha256="):
        return False
    timestamp = ...  # from X-RunFlow-Timestamp header
    expected = hmac.new(shared_secret, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header.removeprefix("sha256="), expected)

During a secret rotation your receiver holds two valid secrets (the new one and the previous one, for the grace period), and the function above accepts only one. Verify against every candidate and combine with a bitwise OR — never return early on the first match:

def verify_any(raw_body: bytes, signature_header: str, secrets: list[bytes]) -> bool:
    ok = False
    for secret in secrets:            # NO early return, NO ordering heuristic
        ok |= verify(raw_body, signature_header, secret)
    return ok

The early return is the whole point: any early exit over a set of secrets leaks, through response timing, which secret matched — whatever decides the order, be it a hint from the payload, a cache, or a most-recently-used heuristic. Accumulating over all candidates has nothing to leak and stays safe against a later "optimisation" added by someone who has not thought about it. In particular, do not order by delivery.endpoint_id from the request body: that body is attacker-controlled until the HMAC checks out, so it may be a hint, never a trust decision. (Credit: the uptime.systems team, who declined the hint for exactly this reason.)

Retry schedule & delivery states

A 2xx response marks the delivery delivered. 408, 429, 5xx, timeouts and transport failures retry with backoff at approximately 10 s, 60 s, 5 m, 30 m, 1 h, to a maximum of 5 attempts. Any other 4xx marks the delivery dead immediately (the receiver has rejected the payload). A 3xx (redirect) is not followed and counts as a failed attempt. An endpoint is auto-disabled (active=false, disabled_reason="dead_without_success_24h") when it accumulates 20 dead deliveries within 24 hours AND zero successful ones in that same window — a single delivered webhook in the window vetoes the disable outright, so a partially-failing endpoint is never cut off completely. A webhook.endpoint_degraded notice is emitted earlier, when the 24-hour dead count reaches 5, successes or not. Re-enable with PATCH {"active": true}. Note that the rule is a 24-hour window, not a streak: consecutive_failures is reported for information and does not drive this decision.

A non-retryable 4xx does not degrade your endpoint — it deletes the delivery. There is no second attempt, so the event is gone. The case that catches people is not a route that is missing; it is a route that is up and authenticates the wrong thing — a signature check against the wrong secret, an auth middleware that has not been made exempt, a replay guard keyed on something that changes per attempt. Each of those answers 401, and 401 is terminal here.

Pick the right metric to alarm on. consecutive_failures does count a delivery that dies on attempt 1 — every dead delivery increments it — but any single success resets it to zero. An endpoint that fails intermittently therefore returns to 0 forever and never trips a streak threshold, so a reading of that field alone cannot show you flapping. Alert instead on tenant.delivery_dead and dead_deliveries_total: the cumulative counter and last_dead_delivery_at are never cleared by a success or by re-activation, which is precisely why they exist.

So: bring the route up, verify it answers your own hand-signed request, and register the endpoint last. Note also that each attempt is signed at attempt time, not at fire time — only the body and X-RunFlow-Event-Id are stable across retries, so a freshness window measures transit only, and a replay guard keyed on the signature will reject every legitimate retry.

StateMeaning
pendingEnqueued, waiting for the dispatcher to claim it.
deliveringClaimed; an HTTP attempt is in flight.
deliveredA 2xx was returned.
deadAttempt budget exhausted or a non-retryable response.

Listing deliveries reads oldest-first. GET /api/v1/webhooks/{endpoint_id}/deliveries orders by ascending delivery id, and limit truncates the front of the history — so ?limit=100 returns the FIRST hundred, not the most recent hundred. It is a forward cursor feed: page it with ?since=<last id seen>. A freshness or "what happened lately" question built on the unpaged listing measures pagination rather than time; use the endpoint record's last_dead_delivery_at, dead_deliveries_total and updated_at for that instead. (Reported by the uptime.systems team, who stepped in it and caught themselves.)

Unknown query parameters are rejected with 400 naming the supported set, on every route that takes them — a misspelled filter fails loudly rather than returning unfiltered results with a 200. Note that the parameter is state_filter, not state.

Replay any terminal delivery with POST /api/v1/webhooks/deliveries/{delivery_id}/redeliver — a NEW delivery row is created that reuses the original event_id so receiver-side dedup still holds. Delivery is at-least-once; receivers must be idempotent on event_id.

Egress safety (SSRF)

RunFlow makes outbound HTTPS to tenant-supplied URLs. To prevent abuse as an SSRF pivot into the host's private network:

Inline webhook per resource (FR-WH-17)

An inline webhook is scoped strictly to the resource it was submitted with. Omit secret to deliver unsigned with X-RunFlow-Signature: none (opting out is explicit and visible). Unknown keys inside the webhook object are rejected with 422 (a misspelled secret cannot silently produce unsigned deliveries).

Usage & limits #usage

GET /api/v1/usage reports what your key is allowed to do and, on a metered tier, how much of it is left. Call it before planning a batch of work — it is the cheapest way to avoid a mid-run 429. Requires runflow_run_read.

curl https://runflow.rodmena.app/api/v1/usage \
  -H "Authorization: Bearer $API_KEY"
{
  "tier": "free",          // null on an unmetered (standard) key
  "metered": true,
  "limits": {
    "memory_limit_mb": 256,
    "cpu_cores": 0.5,
    "timeout_seconds": 60,
    "concurrent_runs": 1,
    "daily_runs": 100,
    "workflow_nodes": 5,
    "retention_days": 3
  },
  "allowed_images": ["python:3.12-slim", "alpine:latest", "..."],
  "expires_at": "2026-10-23T03:24:39Z",
  "usage": [
    {"policy": "runflow-free-runs-daily", "used": 7,
     "limit": 100, "remaining": 93,
     "resets_at": "2026-07-26T00:00:00Z"}
  ]
}

Notes for agents. allowed_images is null when any image is permitted; when it is a list, submitting anything else returns 403 image_not_allowed. A rate-limit policy reports "used": null — a token bucket has no window total, so do not sum that field. On a metered key a run is charged its full timeout_seconds up front and refunded the unused remainder when it finishes, so remaining dips while a run is in flight and recovers after it ends — set a realistic timeout and you will be charged for what you actually use.

Endpoint Reference #endpoints

MethodPathDescriptionPermissionNotes
GET/pingLiveness probeNo auth. Returns {"message":"PONG"}
GET/healthReadiness probeNo auth. Returns service + dependency status JSON
GET/metricsPrometheus expositionNo auth, IP-restricted to private networks. text/plain
POST/api/v1/runsSubmit a runrunflow_run_submitBody: RunSubmitRequest. Returns RunSubmitResponse (202)
GET/api/v1/runsList runsrunflow_run_readQuery: state_filter, tag, limit (default 100, max 1000), offset
GET/api/v1/runs/{track_id}Run statusrunflow_run_readReturns RunStatus
GET/api/v1/runs/{track_id}/resultRun resultrunflow_run_readReturns RunResult. Only available when state is terminal
GET/api/v1/runs/{track_id}/logsLogsrunflow_run_readQuery: stream=stdout|stderr|both (default both), since=N (offset; null=tail last N), limit (default 1000, max 1000). Returns LogResponse
GET/api/v1/runs/{track_id}/utilizationCPU/mem/net samplesrunflow_run_readQuery: from_ts, to_ts (ISO-8601). Returns UtilizationResponse
POST/api/v1/runs/{track_id}/pausePauserunflow_run_controlReturns 204
POST/api/v1/runs/{track_id}/resumeResumerunflow_run_controlReturns 204
POST/api/v1/runs/{track_id}/stopGraceful stop (SIGTERM + grace)runflow_run_controlReturns 204
POST/api/v1/runs/{track_id}/killForce kill (SIGKILL)runflow_run_controlReturns 204
GET/api/v1/tenant/quotasTenant quotasrunflow_run_readReturns TenantQuotas
POST/api/v1/workflowsSubmit a DAGrunflow_workflow_submitBody: WorkflowSubmitRequest. Returns {workflow_id, status} (202)
GET/api/v1/workflowsList workflowsrunflow_workflow_readQuery: state_filter (not state_filter), tag, template_id, limit (default 50), offset
POST/api/v1/templatesRegister a workflow templaterunflow_template_writeBody: definition + params + optional name. Returns the stored template (201)
GET/api/v1/templatesList your templatesrunflow_template_readQuery: limit, offset
GET/api/v1/templates/{ref}Template + parameter contractrunflow_template_read{ref} = template_id or name. Query: version. Definition is redacted
PATCH/api/v1/templates/{ref}Rename/describe, or publish a new versionrunflow_template_writedefinition/params → new version; "name": null clears the name
DELETE/api/v1/templates/{ref}Delete a templaterunflow_template_writeFrees the name and the quota slot; launched workflows are unaffected
POST/api/v1/templates/{ref}/runRun a template with parametersrunflow_template_run + runflow_workflow_submitBody: params, version, name, tags, scheduled_start, webhook. Returns {workflow_id, template_id, version} (202)
POST/api/v1/templates/{ref}/triggersAttach a cron to a templaterunflow_template_writeBody: cron + timezone. Fires the template unattended
GET/api/v1/templates/{ref}/triggersList a template's triggersrunflow_template_readIncludes next_fire_at
POST/api/v1/timersSchedule a one-shot wakerunflow_timer_writeBody: fire_at + opaque payload. Delivers timer.fired; no container
GET/api/v1/timersList timersrunflow_timer_readstate_filter, label_filter. Cap counts PENDING only
DELETE/api/v1/timers/{timer_id}Cancel a pending timerrunflow_timer_write409 if it already fired
POST/api/v1/schedulesRegister a recurring cronrunflow_timer_writeBody: cron + timezone. Delivers schedule.fired; no container
GET/api/v1/schedulesList schedulesrunflow_timer_readIncludes next_fire_at
PATCH/api/v1/schedules/{id}Enable or disable a schedulerunflow_timer_writeDisabling frees the quota slot and keeps the fire ledger
DELETE/api/v1/schedules/{id}Delete a schedulerunflow_timer_writeRemoves it and its fire history
GET/api/v1/schedules/{schedule_id}/firesFire ledger for a schedulerunflow_timer_readPer-occurrence outcome, deliveries_enqueued, fire_delay_seconds
GET/api/v1/triggersList every template triggerrunflow_template_readAcross all your templates
GET/api/v1/triggers/{trigger_id}One triggerrunflow_template_readIncludes next_fire_at and disable reason
GET/api/v1/triggers/{trigger_id}/firesFire ledger for a triggerrunflow_template_readWhich occurrence produced which workflow
POST/api/v1/workflows/{workflow_id}/nodes/{ref}/retryRetry a failed noderunflow_workflow_controlResets the node and its downstream
POST/api/v1/workflows/{workflow_id}/nodes/{ref}/approveApprove a gate noderunflow_workflow_controlReleases the workflow past a human gate
POST/api/v1/workflows/{workflow_id}/nodes/{ref}/rejectReject a gate noderunflow_workflow_controlTerminalises the gate; workflow fails
GET/api/v1/auditYour audit logrunflow_run_readOperator actions and refusals recorded against your tenant
GET/api/v1/workflows/{workflow_id}Live graph + per-node staterunflow_workflow_readReturns WorkflowStatus
GET/api/v1/workflows/{workflow_id}/nodes/{ref}One node's detailrunflow_workflow_readReturns WorkflowNodeDetail, incl. every attempt's track_id
GET/api/v1/workflows/{workflow_id}/resultPer-node outcomesrunflow_workflow_readReturns WorkflowResult. 409 not_terminal while active
GET/api/v1/workflows/{workflow_id}/eventsEvent log (replay/subscribe)runflow_workflow_readQuery: since (cursor, default 0), limit (default 200)
POST/api/v1/workflows/{workflow_id}/pausePause the graphrunflow_workflow_controlOnly from pending/running, else 409 not_pausable
POST/api/v1/workflows/{workflow_id}/resumeResume the graphrunflow_workflow_controlOnly from paused, else 409 not_paused
POST/api/v1/workflows/{workflow_id}/cancelCancel the graphrunflow_workflow_controlKills in-flight node runs. 409 already_terminal if finished
POST.../workflows/{workflow_id}/nodes/{ref}/retryRe-run a stage + downstreamrunflow_workflow_controlNode must be terminal, else 409 node_not_terminal. Revives a terminal workflow
POST.../workflows/{workflow_id}/nodes/{ref}/approveApprove a gaterunflow_workflow_controlNode succeeds; on_success edges fire
POST.../workflows/{workflow_id}/nodes/{ref}/rejectReject a gaterunflow_workflow_controlNode fails; on_failure edges fire
POST/api/v1/webhooksRegister a webhook endpointrunflow_webhook_adminBody: WebhookCreateRequest. Returns WebhookCreateResponse (201), incl. one-time secret
GET/api/v1/webhooksList the tenant's endpointsrunflow_webhook_readNever returns the secret
GET/api/v1/webhooks/{endpoint_id}One endpointrunflow_webhook_read404 for not-found-or-not-yours
PATCH/api/v1/webhooks/{endpoint_id}Partial update (merge)runflow_webhook_adminactive:true resets consecutive_failures
DELETE/api/v1/webhooks/{endpoint_id}Delete + cancel pending deliveriesrunflow_webhook_admin
POST/api/v1/webhooks/{endpoint_id}/testEnqueue a synthetic webhook.test. Optional body {"event_type":"…"} rehearses a real event through the endpoint's filter; payload carries "test": true inside the signed body, and a type the endpoint does not subscribe to is refused 422. ACK a rehearsal with 2xx — a non-2xx counts toward auto-disable like any delivery, so refusing rehearsals makes testing the thing that disables your endpointrunflow_webhook_adminReturns {delivery_id, event_type}
POST/api/v1/webhooks/{endpoint_id}/rotate-secretRotate the signing secretrunflow_webhook_adminReturns the new secret exactly once
GET/api/v1/webhooks/{endpoint_id}/deliveriesEndpoint deliveriesrunflow_webhook_readQuery: state_filter, since, limit
GET/api/v1/webhooks/deliveries/{delivery_id}One deliveryrunflow_webhook_read404 for not-found-or-not-yours
POST/api/v1/webhooks/deliveries/{delivery_id}/redeliverRe-enqueue (reuse event_id)runflow_webhook_adminReturns the new delivery_id
GET/api/v1/runs/{track_id}/deliveriesDeliveries for a runrunflow_webhook_read
GET/api/v1/workflows/{workflow_id}/deliveriesDeliveries for a workflowrunflow_webhook_read

Workflow permissions are distinct from run permissions: a key with runflow_run_* only cannot submit or control workflows. Run-level endpoints still work on individual node runs, so runflow_run_read is what you need to fetch a node's logs.

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,
  "max_nodes_per_workflow": 50,     // exceeded -> 400 too_many_nodes
  "max_concurrent_workflows": 10,    // exceeded -> 429 workflow_quota_exceeded
  "max_webhook_endpoints": 10,       // exceeded -> 429 webhook_quota_exceeded
  "max_webhook_payload_bytes": 262144 // 256 KiB; result_summary then data.nodes truncated to fit
}

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"
}
StatusMeaning
400Bad request — invalid image reference, malformed JSON, or expires_at in the past (expires_at_in_past)
401No/invalid bearer token
403Permission denied (missing RBAC permission)
404Run not found (wrong track_id or not owned by tenant)
409Conflict — result requested on non-terminal run, pause/resume on wrong state
422Validation error (Pydantic schema mismatch, invalid TJP shift)
429Daily quota exceeded (daily_quota_exceeded) or rate limited (per-tenant sliding window). Note: concurrent quota no longer rejects — see Scheduling.
500Internal server error

Workflow-specific detail codes

StatusdetailCause
400invalid_image:<ref>That node's image isn't a valid Docker reference.
400invalid_graphA cycle, a duplicate ref, an edge pointing at a missing ref, or an inputs_from naming a node that doesn't exist.
400too_many_nodesMore nodes than max_nodes_per_workflow.
409not_terminal/result requested while the workflow is still active.
409already_terminal/cancel on a finished workflow.
409not_pausable/pause when the workflow isn't pending or running.
409not_paused/resume on a workflow that isn't paused.
409node_not_terminal/retry on a node that is still active.
409not_an_approval_node/approve or /reject on a container node.
409not_awaiting_approvalThe gate isn't in waiting_approval (already decided, or not reached yet).
429workflow_quota_exceededAlready at max_concurrent_workflows.
400invalid_webhook_urlWebhook URL is not absolute, not https, or contains userinfo.
400unknown_event_typeWebhook events entry is not a catalog member, alias, or wildcard.
422(pydantic)Inline webhook object contained an unknown key (FR-WH-22).
429webhook_quota_exceededAlready at max_webhook_endpoints.

Node-level failures are not HTTP errors. A node whose injected inputs exceed 64 KB fails with output {"error":"inputs_too_large"}; read it from the node detail or result endpoint.