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¶m.FrameStart=1¶m.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 pending→ready 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"