Skip to content

API Reference

Every endpoint, with the request it takes and the response it returns. The authoritative machine-readable form is docs/api/openapi.yaml, which CI regenerates and compares; this page is the human-readable view of the same surface.

All paths are relative to /api/v1/. All bodies are JSON. All timestamps are ISO 8601 UTC.

Common shapes

Error envelope

Every failure returns the same shape, whatever the cause:

{
  "error": {
    "code": "not_found",
    "message": "No run matches the given query.",
    "details": {}
  }
}

code is stable and safe to branch on; message is for a human and may change.

code Status Meaning
validation_error 400 / 422 The body is not a valid definition or trigger
not_authenticated 401 No credential, or it is invalid/expired
permission_denied 403 Authenticated, but not allowed
not_found 404 Does not exist in this tenant
definition_conflict 409 That name and version exists with different content
throttled 429 A rate limit was exceeded; see Retry-After
internal_error 500 The server failed

Pagination

List endpoints use DRF cursor pagination:

{"next": "http://host/api/v1/runs/?cursor=cD0y", "previous": null, "results": [...]}

next is null on the last page. Follow the URL as given; do not rebuild it.

Definitions

GET /workflows/

Published definitions, without their tasks. Paginated.

{"id": "…", "name": "etl", "version": 1, "description": "…",
 "created_at": "2026-10-23T10:00:00Z", "task_count": 3}

POST /workflows/

Publish a definition. Idempotent by (name, version).

{
  "name": "etl",
  "version": 1,
  "description": "Extract and load.",
  "tasks": [
    {"key": "extract", "task_type": "echo", "config": {"value": "raw"}},
    {"key": "load", "task_type": "noop", "depends_on": ["extract"]}
  ]
}
Response When
201 Created
200 Already published with identical content
409 Already published with different content — bump the version
400 / 422 Not a valid definition (duplicate key, dangling dependency, cycle, bad config)

task_type must name a registered handler (noop, echo, add, wait_for_file, fail). A dispatch never imports code.

GET /workflows/{name}/{version}/

One definition with its tasks.

{"id": "…", "name": "etl", "version": 1, "description": "…",
 "created_at": "…",
 "tasks": [{"key": "extract", "task_type": "echo", "depends_on": [], "config": {}}]}

config is redacted: a task may carry a credential to execute, and a reader does not receive it. The worker still gets the real config in its dispatch.

Runs

POST /runs/

Trigger a run.

{"name": "etl", "version": 1}

Optional header Idempotency-Key: <string>. The same key with the same body returns the original run with Idempotent-Replay: true; the same key with a different body is 422.

202 — accepted, not finished:

{
  "id": "…", "workflow_name": "etl", "workflow_version": 1,
  "state": "pending", "created_at": "…", "started_at": null, "finished_at": null,
  "task_runs": [
    {"id": "…", "task_key": "extract", "state": "ready", "attempt": 1,
     "fencing_token": 0, "lease_owner": null, "lease_expires_at": null,
     "created_at": "…", "started_at": null, "finished_at": null}
  ]
}

A task with no dependencies starts ready; one with dependencies starts pending. The run itself starts pending and becomes running when the scheduler first advances it.

Failure When
404 No such definition in this tenant
422 The idempotency key was reused with a different body
429 The trigger scope's rate limit

GET /runs/

Runs visible to the caller, without task runs. Paginated. Each row carries id, workflow_name, workflow_version, state, created_at, started_at, finished_at.

GET /runs/{id}/

One run with its task runs — the shape POST /runs/ returns.

GET /runs/{id}/events/

The append-only event log of the run's tasks. Not paginated: the row count is bounded by the task count times the transitions each task can make.

{"results": [
  {"id": "…", "task_key": "extract", "kind": "promote",
   "from_state": "pending", "to_state": "ready", "created_at": "…"}
]}

kind is one of promote, skip, retry, dead, transition.

Task runs

POST /task-runs/{id}/requeue/

Move a failed or dead task run back to ready so it runs again.

{"id": "…", "task_key": "extract", "state": "ready", "attempt": 2, "fencing_token": 1}
Failure When
409 The task is not in a requeueable state — requeueing a running task would execute it twice
404 No such task run in this tenant

Run states

WorkflowRun:  pending -> running -> succeeded | failed | cancelled
TaskRun:      pending -> ready -> running -> succeeded | failed | retried | dead
                              -> skipped
                              -> cancelled

A run is succeeded when every task is terminal and at least one succeeded. A dead task means every attempt was used up and a human decision is needed; it does not by itself fail the run.

Members

GET /members/

Members of the caller's organization. Requires the admin role or above, because the response exposes usernames and e-mail addresses.

{"id": "…", "username": "alice", "email": "alice@example.com", "role": "member"}

POST /members/

{"username": "bob", "role": "member"}

201 on success. 409 if the user is already a member.

PATCH /members/{id}/

{"role": "admin"}

DELETE /members/{id}/

204. The last owner cannot be removed or demoted — 409, because an organization with no owner cannot be administered. An admin cannot modify an owner, or grant the owner role.

API keys

GET /api-keys/

Key metadata only: id, name, prefix, scopes, created_at, last_used_at, expires_at, revoked_at. The secret is never returned.

POST /api-keys/

{"name": "ci", "scopes": ["read", "write"], "expires_at": "2027-01-01T00:00:00Z"}

201 returns the plaintext key once:

{"id": "…", "name": "ci", "key": "ff_1a2b3c4d_…", "prefix": "1a2b3c4d", …}

Store it now; it cannot be retrieved again.

POST /api-keys/{id}/rotate/

Returns a new plaintext key and invalidates the old one.

POST /api-keys/{id}/revoke/

204. The key stops authenticating immediately.

Observability

GET /metrics/

Prometheus text exposition format. Unauthenticated and aggregate only — no tenant identifier, username or task key — so a scrape needs no credential. Bind it to a private network.

GET /schema/ and GET /docs/

The OpenAPI document and a Swagger UI over it. Serving them is for humans reading the docs site; the committed file is the reviewed artifact.

Health probes

Outside /api/v1/, mounted at the site root:

Path Purpose
/health/live/ The process is up. No dependency is checked.
/health/ready/ Every dependency, named: {"status": "ready", "postgres": "ok", "redis": "ok", "rabbitmq": "ok"}. 503 when one is down, and the body says which.

Rate limits

Per caller, per scope:

Scope Default
auth 10/min
trigger 60/min
read 300/min

429 carries Retry-After. The rates are configurable through THROTTLE_AUTH_RATE, THROTTLE_TRIGGER_RATE and THROTTLE_READ_RATE.