Skip to content

sqi-server REST API

This document describes how to use the sqi-server REST API, with worked examples for the most common operations. The machine-readable contract is the OpenAPI 3.1 specification served by a running server at:

GET /api/v1/openapi.yaml

You can browse it with any OpenAPI viewer (e.g. Swagger UI, Redoc, or scalar).


Base URL

All REST endpoints share the prefix /api/v1. A locally running server listens on http://localhost:8080 by default.

BASE=http://localhost:8080/api/v1

Conventions

Content-Type

Submit requests as application/json or application/x-yaml (for OpenJD templates). Responses are always application/json.

Error responses

All errors use the RFC 7807 problem-details format with Content-Type: application/problem+json:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "job not found",
  "instance": "a1b2c3d4e5f60708"
}

The instance field contains the request ID, which also appears in the X-Request-ID response header — useful when correlating with server logs.

Rate limiting

/api/v1 requests are rate-limited per client IP (default token bucket: 20 requests per second sustained, burst of 40). Exceeding the limit returns a 429 Too Many Requests problem-details body and a delta-seconds Retry-After header indicating when the next request will be accepted:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 1

Clients should back off for at least the advertised duration before retrying.

Pagination

Jobs, tasks, queues, and workers are paginated: those list endpoints accept limit (default 50, max 1000) and offset (zero-based) query parameters and return a wrapper object:

{
  "items": [ ... ],
  "total": 1234,
  "limit": 50,
  "offset": 0
}

Other list endpoints are not paginated — including farms, storage locations, compute locations, usage pools, products, presets, users, and API keys. Those ignore limit/offset and return a bare JSON array.

Versioning

The URL prefix is the API contract version, and every response under /api/v1 carries a matching X-API-Version: 1 header. This contract major is deliberately decoupled from the product release version (e.g. sqi 0.1, 0.2, … all serve contract 1). Additive changes — new endpoints, new optional fields, new enum values — do not change it; clients must tolerate unknown fields and values.

A breaking change ships as a new URL prefix (/api/v2/…) with X-API-Version: 2. During the migration window the old prefix keeps working and its responses carry the RFC 8594 deprecation headers:

Header Value
Deprecation HTTP-date the deprecation was declared, or true
Sunset HTTP-date the endpoint will be removed (optional)
Link <url>; rel="deprecation" — migration documentation (optional)

Clients should warn when they see a Deprecation header or an X-API-Version major newer than the one they were written against.


Authentication

Authentication is off by default (auth.enabled=false), in which case every request is an anonymous superuser and no credential is needed — every example below works as written. See docs/auth.md for the full model.

When auth.enabled=true, send an API key as a bearer token:

curl -s -H "Authorization: Bearer $SQI_API_KEY" "$BASE/jobs" | jq .

Browser clients use the session cookie minted by POST /api/v1/auth/login instead. Three endpoints are always public because gating them would be circular: GET /api/v1/openapi.yaml, POST /api/v1/auth/login, and GET /api/v1/auth/providers (plus GET /api/v1/auth/oidc/login and GET /api/v1/auth/oidc/callback when SSO is configured).

Every other /api/v1 REST endpoint requires a permission (/healthz, /readyz and /metrics sit outside the API prefix and are never gated; the /ws upgrade authenticates the same way but gates per-subject — see WebSocket subscriptions):

Endpoint group Permission
GET /jobs, GET /jobs/{id}, GET /jobs/{id}/tasks, GET /tasks/{id}, GET /tasks/{id}/logs, GET /tasks/{id}/attempts jobs.read
POST /jobs, POST /products/{name}/jobs, PATCH/DELETE /jobs/{id}, POST /jobs/{id}/cancel, POST /jobs/{id}/retry, POST /tasks/{id}/retry, POST /tasks/{id}/cancel jobs.write
GET /workers, GET /workers/{id} workers.read
POST /workers/{id}/disable, POST /workers/{id}/enable, DELETE /workers/{id} workers.manage
GET on farms, queues, storage-locations, compute-locations, usage-pools infra.read
POST/PUT/DELETE on farms, queues, storage-locations, compute-locations, usage-pools infra.manage
GET /products, GET /products/{name}, GET /products/{name}/parameters, GET /presets, GET /presets/{name} products.read
POST /products, PUT /products/{name}, DELETE /products/{name}, POST /presets/{name}/install products.manage
GET /diagnostics/logs, WebSocket subject diagnostics diagnostics.read
GET /users, GET /users/{id} users.read
POST /users, PATCH /users/{id}, PUT /users/{id}/password, DELETE /users/{id} users.manage
POST/GET /api-keys, DELETE /api-keys/{id} apikeys.self
GET /users/{id}/api-keys, DELETE /users/{id}/api-keys/{keyId} apikeys.admin
POST /auth/logout, GET/PATCH /auth/me, PUT /auth/password, GET /version any authenticated principal

Object routes (/jobs/{id}…, /tasks/{id}…) are additionally owner-scoped: a principal without jobs.read.all sees and acts on only its own jobs — the one permission governs both read and write scoping. A missing or rejected credential returns 401; a valid credential without the permission returns 403, as does an owner-scoped principal reaching another user's job.


Worked examples

The examples below use curl. Replace $BASE with http://localhost:8080/api/v1 or export it:

export BASE=http://localhost:8080/api/v1

Submit a job

POST /api/v1/jobs

The body is a raw OpenJD template in YAML or JSON. Required query parameters:

Parameter Description
farm_id UUID of the target farm
queue_id UUID of the target queue

Optional query parameters: owner, submitter, priority (default 50, higher = sooner), project. Also the per-job retry-policy overrides max_attempts (≥ 1), retry_delay_seconds (≥ 0), and failure_limit (≥ 0; the job-level failure ceiling that auto-parks the job, 0 disables an inherited limit) — each omitted means inherit the queue → farm → server default, and an out-of-range value is rejected with 400. Finally, depends_on (repeatable) — IDs of upstream jobs, in the same farm, this job must wait for; if any is not yet completed the job is created blocked instead of pending and its tasks are held until every dependency completes (see docs/architecture.md). The same depends_on field is accepted in the JSON body when submitting from a product (below).

Job parameter values. Values for the template's own job parameters (its parameterDefinitions) are supplied as param.<Name>=<value> query parameters — one per parameter. Each param.<Name> key must match a declared parameter; a parameter with no default must be supplied this way or submission is rejected.

curl -s -X POST "$BASE/jobs?farm_id=FARM_ID&queue_id=QUEUE_ID&owner=alice&param.FrameStart=1&param.FrameEnd=100" \
  -H "Content-Type: application/x-yaml" \
  --data-binary @job.yaml | jq .

Successful response — 201 Created:

{
  "id": "018f1a2b-3c4d-7e5f-a6b7-c8d9e0f12345",
  "farm_id": "...",
  "queue_id": "...",
  "name": "My Render Job",
  "owner": "alice",
  "submitter": "",
  "priority": 50,
  "status": "pending",
  "template_format": "yaml",
  "created_at": "2026-01-15T10:00:00Z",
  "updated_at": "2026-01-15T10:00:00Z",
  "failed_attempts": 0
}

Validation failure — 422 Unprocessable Entity:

{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "step 'Render' references undefined parameter 'Frames'",
  "instance": "a1b2c3d4e5f60708"
}

Server gave up evaluating the template's expressions — 503 Service Unavailable:

{
  "type": "about:blank",
  "title": "Service Unavailable",
  "status": 503,
  "detail": "template validation exceeded its time budget on this server; retry, or ask the operator about openjd.expr_submission_deadline",
  "instance": "a1b2c3d4e5f60708"
}

This is not a verdict on the template: the deterministic expression budgets report an invalid template as 422, while this outcome depends on how busy the server was, so the same body may well be accepted on a retry.


Submit a job from a product

# 1. Inspect the product's parameters
curl -s http://localhost:8080/api/v1/products/python/parameters | jq

# 2. Submit, naming the job and supplying parameter values
curl -s -X POST http://localhost:8080/api/v1/products/python/jobs \
  -H 'Content-Type: application/json' \
  -d '{"name":"My Python Job","farm_id":"<farm>","queue_id":"<queue>","parameters":{"Script":"print(1)"}}'

The name field overrides the template's job name; when omitted the template's own name is used. farm_id and queue_id are required. parameters is a flat string→string map matching the template's parameterDefinitions. depends_on is an optional array of upstream job IDs, same semantics as the raw-submit query parameter above. The /parameters endpoint returns each parameter's type, default, allowed values, and user_interface hints.


List jobs

GET /api/v1/jobs

Filter parameters (all optional):

Parameter Values Description
status pending, blocked, paused, running, completed, failed, canceled Filter by job status
queue_id UUID Filter by queue
farm_id UUID Filter by farm
owner string Filter by owner
project string Filter by project
search string Case-insensitive substring over name, id, owner, project. Whitespace-separated words are matched as independent terms, ANDed and order-independent (e.g. night alice matches a job named "Nightly" owned by "alice")
sort_by created_at, priority, status, updated_at, name Sort field (default: created_at)
sort_dir asc, desc Sort direction (default: asc)
limit 1–1000 Page size (default: 50)
offset ≥0 Page offset (default: 0)
# List running jobs, newest first
curl -s "$BASE/jobs?status=running&sort_by=created_at&sort_dir=desc" | jq .

# Page through all failed jobs
curl -s "$BASE/jobs?status=failed&limit=10&offset=0" | jq .items[].id

Get job detail

GET /api/v1/jobs/{id}

Returns the job with its steps and per-status task counts. The response also carries effective_retry — the resolved retry policy for this job (max_attempts, retry_delay_seconds, failure_limit) after the server → farm → queue → job cascade — plus failed_attempts and, when the job has been auto-parked, a park_reason.

task_counts.unschedulable counts ready tasks that currently carry a non-empty unschedulable reason — a subset of ready, not an additional status, so it is not included in total a second time.

JOB_ID=018f1a2b-3c4d-7e5f-a6b7-c8d9e0f12345

curl -s "$BASE/jobs/$JOB_ID" | jq '{name, status, task_counts}'
{
  "name": "My Render Job",
  "status": "running",
  "task_counts": {
    "total": 100,
    "pending": 0,
    "ready": 10,
    "assigned": 5,
    "running": 5,
    "succeeded": 80,
    "failed": 0,
    "canceled": 0,
    "unschedulable": 0
  }
}

List tasks for a job

GET /api/v1/jobs/{id}/tasks

Accepts status (pending, ready, assigned, running, succeeded, failed, canceled), limit, offset, sort_by (created_at, status, updated_at, name), and sort_dir filters.

# Show failed tasks for a job
curl -s "$BASE/jobs/$JOB_ID/tasks?status=failed" | jq .items[]

Get a task

GET /api/v1/tasks/{id}

Returns a single task. Returns 404 if no task with that ID exists.

TASK_ID=<task-uuid>

curl -s "$BASE/tasks/$TASK_ID" | jq .
{
  "id": "...",
  "job_id": "...",
  "step_id": "...",
  "name": "Render-1",
  "parameters": { "Frame": "1" },
  "status": "failed",
  "assigned_worker_id": "worker-abc",
  "assigned_at": "2026-01-15T10:05:40.000Z",
  "created_at": "2026-01-15T10:00:00Z",
  "updated_at": "2026-01-15T10:05:45.000Z",
  "failure_reason": "openjd_fail: step action returned non-zero",
  "failed_attempts": 1
}

The worker that took the task is assigned_worker_id. The bare name worker_id means the same thing elsewhere — on the WebSocket jobs/{job-id}/tasks push payload and on each entry of GET /tasks/{id}/attempts — but never on this response. unschedulable_reason is non-empty only while a ready task cannot be satisfied by any online worker. failure_reason is the task-level reason for a terminal non-success and is cleared on retry — the per-attempt message from GET /tasks/{id}/attempts is the durable record. retry_after, when set and in the future, holds a ready task as a retry backoff. parameters, assigned_worker_id, assigned_at, unschedulable_reason, failure_reason, and retry_after are omitted when empty.


Cancel a job

POST /api/v1/jobs/{id}/cancel

Cancels the job and propagates cancel signals to all assigned workers. Idempotent: an already-canceled job returns 204. Returns 404 if the job does not exist, and 409 if the job has already reached completed or failed.

curl -s -X POST "$BASE/jobs/$JOB_ID/cancel"
# Returns 204 No Content on success

Retry a job's failed and canceled tasks

POST /api/v1/jobs/{id}/retry

Revives every failed or canceled task in the job — resetting those tasks, their steps, and the job back to pending so the scheduler re-runs them in dependency order. Idempotent: a job with no eligible tasks returns retried: 0.

curl -s -X POST "$BASE/jobs/$JOB_ID/retry" | jq .

Returns 200 with { "job_id": "...", "retried": N }. Returns 404 if the job does not exist.


Delete a job

DELETE /api/v1/jobs/{id}

Permanently deletes the job and all of its data (steps, tasks, attempts, logs). Active tasks are canceled first. Returns 204 on success, 404 if the job does not exist.

curl -s -X DELETE "$BASE/jobs/$JOB_ID"
# Returns 204 No Content on success

Pause and resume a job

PATCH /api/v1/jobs/{id}

# Pause
curl -s -X PATCH "$BASE/jobs/$JOB_ID" \
  -H "Content-Type: application/json" \
  -d '{"action": "pause"}'

# Resume
curl -s -X PATCH "$BASE/jobs/$JOB_ID" \
  -H "Content-Type: application/json" \
  -d '{"action": "resume"}'

# Raise priority
curl -s -X PATCH "$BASE/jobs/$JOB_ID" \
  -H "Content-Type: application/json" \
  -d '{"priority": 90}'

# Update per-job retry-policy overrides
curl -s -X PATCH "$BASE/jobs/$JOB_ID" \
  -H "Content-Type: application/json" \
  -d '{"max_attempts": 5, "retry_delay_seconds": 60, "failure_limit": 10}'

# Move the job to a different queue
curl -s -X PATCH "$BASE/jobs/$JOB_ID" \
  -H "Content-Type: application/json" \
  -d '{"queue_id": "<other-queue-uuid>"}'

The same endpoint moves a job between queues (queue_id; a queue that does not exist is rejected with 400) and sets the per-job retry-policy overrides (max_attempts, retry_delay_seconds, failure_limit); omitting a field (or sending null) leaves it unchanged — a job-level override cannot be cleared back to "inherit" this way. Resuming an auto-parked job (action: resume) also clears park_reason and resets failed_attempts to zero, re-arming its failure limit.


Tail task logs (polling)

GET /api/v1/tasks/{id}/logs

Logs are returned as a paginated list of timestamped chunks. Advance the cursor using after_nats_seq from each response.

Parameter Default Description
limit 100 Chunks per page (1–1000)
after_nats_seq 0 Return only chunks with NATS sequence > this value
tail false When true, stream chunks live as newline-delimited JSON (Content-Type: application/x-ndjson) instead of returning a page. Streaming stops when the task reaches a terminal state and all buffered chunks have been delivered, or when the client disconnects.
TASK_ID=<task-uuid>
CURSOR=0

# Poll loop
while true; do
  RESP=$(curl -s "$BASE/tasks/$TASK_ID/logs?limit=100&after_nats_seq=$CURSOR")
  echo "$RESP" | jq -r '.items[].data'
  CURSOR=$(echo "$RESP" | jq -r '.after_nats_seq')
  sleep 1
done

Each item in .items[] has:

{
  "id": "...",
  "task_id": "...",
  "attempt_id": "...",
  "seq_num": 42,
  "nats_seq": 1001,
  "stream": "stdout",
  "data": "Frame 0042 rendered in 3.2s\n",
  "at": "2026-01-15T10:05:42.123Z",
  "received_at": "2026-01-15T10:05:42.130Z"
}

Tail task logs (WebSocket)

For live streaming, use the WebSocket endpoint instead of polling:

GET /api/v1/ws   (Upgrade: websocket)

See the WebSocket subscription protocol section below for message format details.

Quick example using websocat:

TASK_ID=<task-uuid>

websocat ws://localhost:8080/api/v1/ws <<EOF
{"type":"subscribe","subject":"tasks/$TASK_ID/logs","payload":{"since_seq":0},"seq":1}
EOF

Retry a failed task

POST /api/v1/tasks/{id}/retry

Revives a failed or canceled task, resetting it and its step and the job (when terminal) back to pending, then re-gates pendingready in dependency order. Only tasks in failed or canceled state may be retried.

curl -s -X POST "$BASE/tasks/$TASK_ID/retry" | jq .

Returns 202 with { "task_id": "...", "status": "ready" } when the task's step dependencies are satisfied, or { ..., "status": "pending" } when they are not yet met. Returns 404 if the task does not exist, or 409 if the task is not in failed or canceled state.


Cancel a task

POST /api/v1/tasks/{id}/cancel

Cancels a single non-terminal task: closes its running attempt, signals the assigned worker to stop, and releases any held usage-pool slots. Only non-terminal tasks (pending, ready, assigned, running) may be canceled.

curl -s -X POST "$BASE/tasks/$TASK_ID/cancel" | jq .

Returns 202 with { "task_id": "...", "status": "canceled" }. Returns 404 if no task with that ID exists, or 409 if the task is already terminal (succeeded, failed, canceled).


Get task attempt history

GET /api/v1/tasks/{id}/attempts

Returns the task's execution attempts, oldest first. Each retry adds a new attempt, so this is the record of every time the task ran.

curl -s "$BASE/tasks/$TASK_ID/attempts" | jq .

Response shape:

{
  "items": [
    {
      "attempt_number": 1,
      "status": "failed",
      "worker_id": "worker-abc",
      "exit_code": 1,
      "message": "openjd_fail: step action returned non-zero",
      "started_at": "2026-01-15T10:05:40.000Z",
      "ended_at": "2026-01-15T10:05:45.000Z"
    }
  ]
}

status is the attempt's own state (running, succeeded, failed, or canceled), independent of the task's current status. worker_id, exit_code, message, and ended_at are omitted when they do not apply (an in-flight attempt has no exit_code/ended_at). Returns 404 if no task with that ID exists; a task that has not yet run returns 200 with an empty list ({ "items": [] }), not 404.

Use this to see why each attempt failed via its per-attempt message — including for a mid-retry task, whose task-level failure_reason is cleared on retry even though the earlier attempt's message is preserved here.


Worker endpoints

# List workers
curl -s "$BASE/workers" | jq '.items[] | {id, hostname, status}'

# Disable a worker (drains current task, stops new assignments)
curl -s -X POST "$BASE/workers/$WORKER_ID/disable"

# Re-enable
curl -s -X POST "$BASE/workers/$WORKER_ID/enable"

Farm, queue, and resource CRUD

Each resource supports GET / (list), POST / (create), GET /{id}, PUT /{id} (full replace), and DELETE /{id}.

/api/v1/farms
/api/v1/queues
/api/v1/storage-locations
/api/v1/compute-locations
/api/v1/usage-pools

Storage locations: type is derived, not supplied

The type field (filesystem / s3 / mixed) is computed from the location's roots and appears only in responses. Supplying type in a POST or PUT body returns 400 Bad Request. Set the roots; sqi infers the type.

Each s3:// root is validated as a well-formed s3://bucket[/prefix] URI. See docs/storage-s3.md for the full S3 setup guide.

Compute locations name the physical/logical sites workers run in (on-prem, aws-us-east-1, …) and are the keys a storage location's roots map is keyed on. See docs/compute-locations.md.

Example — create a farm, then a queue inside it:

FARM=$(curl -s -X POST "$BASE/farms" \
  -H "Content-Type: application/json" \
  -d '{"name":"studio-a","description":"Studio A render farm"}')
FARM_ID=$(echo $FARM | jq -r .id)

curl -s -X POST "$BASE/queues" \
  -H "Content-Type: application/json" \
  -d "{\"farm_id\":\"$FARM_ID\",\"name\":\"renders\",\"priority\":50,\"max_concurrent_tasks\":200}"

WebSocket subscriptions

Upgrade

GET /api/v1/ws
Upgrade: websocket

Message envelope

All frames — in both directions — are JSON objects with this shape:

{
  "type": "subscribe | unsubscribe | ping | push | ack | error | pong",
  "subject": "<subject-string>",
  "payload": { ... },
  "seq": 42
}

Message types by direction:

Type Direction Purpose
subscribe client → server Register for a subject's pushes (payload = {since_seq})
unsubscribe client → server Cancel a subscription
ping client → server Application-level keep-alive (server replies pong)
ack server → client Confirms a subscribe/unsubscribe (payload = {client_seq, error})
push server → client A subscribed event (payload shape varies by subject)
error server → client Protocol-level error (payload = {code, message})
pong server → client Reply to a client ping

seq on a server push is a per-subject, hub-assigned counter (starts at 1, increases globally across connections); store the last value you received and pass it as since_seq when reconnecting. On client messages seq is a client-chosen value the server echoes back in the ack's client_seq so you can correlate replies.

Subscribe

Send a subscribe frame with the resume cursor inside payload as since_seq. The server replies with an ack, then immediately replays buffered events with seq greater than since_seq, then streams new events live:

{"type": "subscribe", "subject": "tasks/{task-id}/logs", "payload": {"since_seq": 0}, "seq": 1}

The acknowledgement (sent before any replayed pushes):

{"type": "ack", "payload": {"client_seq": 1, "error": ""}}

A non-empty ack.error (e.g. an unknown subject) means the subscription was rejected; treat it like an error frame.

Available subjects

Subject Description push payload fields
jobs Aggregate job summary changes job_id, task_id, name, owner, queue_id, status, updated_at
jobs/{job-id}/tasks Task-level state transitions for the given job job_id, task_id, name, status, worker_id, unschedulable_reason, updated_at
tasks/{task-id}/logs Live log chunks for the given task attempt task_id, attempt_id, seq_num, stream, data, at
workers Worker registration and heartbeat events worker_id, name, hostname, farm_id, status
diagnostics Diagnostic (operational) log records from the server and every worker component, level, msg, attrs, at

A tasks/{task-id}/logs push carries a seq_num and the chunk content but — unlike the REST GET /tasks/{id}/logs chunks — omits id, nats_seq, and received_at.

Subscribing to diagnostics requires the diagnostics.read permission; a principal without it gets a rejected ack carrying forbidden: diagnostics requires diagnostics.read. component is server or worker:<worker-id>, and attrs is omitted when the record carries no structured attributes.

On both the jobs and workers subjects a status of removed is a synthetic value (not a persisted job/worker status) meaning the row was hard-deleted — drop it from your view rather than updating it in place.

Example push frame:

{
  "type": "push",
  "subject": "tasks/{task-id}/logs",
  "payload": {
    "task_id": "...",
    "attempt_id": "...",
    "seq_num": 42,
    "stream": "stdout",
    "data": "Frame 0042 rendered in 3.2s\n",
    "at": "2026-01-15T10:05:42.123Z"
  },
  "seq": 42
}

Unsubscribe

{"type": "unsubscribe", "subject": "tasks/{task-id}/logs"}

Keep-alive

The server sends a WebSocket-level ping every 30 seconds (handled transparently by any compliant WebSocket client). Connections idle for more than 5 minutes without any frame are closed with status 1001 (Going Away). Reconnect, re-send your subscribe frames, and pass the last received seq as since_seq to resume without missing events. (A client may also send an application-level ping frame at any time; the server replies with pong.)


Health and observability endpoints

Endpoint Description
GET /healthz Liveness — returns 200 OK if the process is alive
GET /readyz Readiness — returns 200 OK only when SQLite and NATS are reachable
GET /metrics Prometheus metrics (text format)
GET /api/v1/openapi.yaml OpenAPI 3.1 specification
curl -sf http://localhost:8080/healthz && echo "alive"
curl -sf http://localhost:8080/readyz  && echo "ready"