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¶
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
}
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.
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. Also 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"
}
Validation failure — 422 Unprocessable Entity:
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "step 'Render' references undefined parameter 'Frames'",
"instance": "a1b2c3d4e5f60708"
}
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 |
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.
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
}
}
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[]
Cancel a job¶
POST /api/v1/jobs/{id}/cancel
Cancels the job and propagates cancel signals to all assigned workers.
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}'
The same endpoint 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 |
after_nats_seq |
0 | Return only chunks with NATS sequence > this value |
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","since_seq":0}
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/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.
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, 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, 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, hostname, farm_id, status |
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.
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"