API Reference

Every HTTP route served by interlace serve. Interactive OpenAPI docs are always available on a running daemon at /schema/scalar (spec at /schema/openapi.json).

Conventions

  • Auth: Authorization: Bearer ilk_.... While no API key exists the whole API is open (keyless mode); the first key locks it down. Each route requires one scope — read, write, or admin; a key carries any combination, and an admin key satisfies every requirement. Missing/invalid token → 401; insufficient scope → 403.
  • Status codes: GETs and DELETEs return 200; POSTs return 201, except POST /runs/{id}/cancel and POST /environments/{name}/rollback, which return 200; errors are 400 (bad request/blocked), 401 (missing/invalid token), 403 (wrong scope), 404 (unknown), 429 (backpressure). A failed engine statement adds statement (the SQL that failed) next to detail. The same SQL is on the model.failed event.
  • /health, /schema/*, and /ui/* never require auth.

Meta

RouteScopeDescription
GET /healthopen{status, version, environment}
GET /openRedirects to /ui/
GET /ui/...openThe web UI

Models & Lineage

RouteScopeDescription
GET /modelsreadAll models in topological order: name, materialise/output, strategy, fingerprint, depends_on, tags, schedule
GET /models/{name}readAdds upstream/downstream, column lineage, SQL or Python source, indexes, constraints, and schema
GET /models/{name}/impactreadColumn blast radius for ?column=COL: {source, impacted[{model, column, via}], opaque_consumers[]} — mirrors interlace impact
GET /models/{name}/previewreadRow sample and column profile (nulls, distinct, min, max) plus the last build. ?limit= defaults to 25, capped at 100. Ephemeral and file outputs set available false
GET /lineagereadThe whole graph in one payload: models, edges, column-level lineage, streams and their consumers — what the UI’s lineage canvas renders

Plan & Apply

RouteScopeDescription
GET /planreadenvironment, select, forward_only. Returns changes[], transfers[], physical[] (+ index / - constraint, no rebuild), and drift[]
POST /applywriteBody {selectors, environment, force, forward_only}. Breaking plan without force → 409. Blocking schema drift → 400 before any write (force does not bypass it). Returns {built, promoted, breaking, reused, transfers, rows, timings}

Runs

RouteScopeDescription
GET /runsreadDurable queue, newest first: state, attempts, error, partition window, idempotency key (its prefix names the trigger: cron:, interval:, watch:, webhook:, api:, stream:)
GET /runs/{id}readRun detail plus its merged event history
POST /runswriteBody {selectors: [], environment, start, end, restate: false} (empty selectors = all models; ISO timestamps). Returns {enqueued, models} — enqueued: 0 means deduplicated
POST /runs/{id}/cancelwrite200. Queued cancels now; running cancels at the worker’s next heartbeat. Unknown/finished → 404

Runs are executed by the scheduler loop with 60-second leases, up to 3 attempts, and cooperative cancellation.

Environments

RouteScopeDescription
GET /environmentsreadPer environment: promoted model count, drift vs the compiled project, promoted_at
DELETE /environments/{name}admin200. Drops views ({environment, dropped_views}); prod requires ?force=true; unknown → 404. Emits environment.dropped
GET /environments/{name}/historyreadPromotion generations, newest first — the rollback targets: [{generation, promoted_at, models}]
POST /environments/{name}/rollbackadmin200. Body {generation?} (default: the one before latest). Repoints views at that generation — nothing rebuilds. Emits environment.rolled_back

Checks

RouteScopeDescription
GET /checksreadRecorded check results (filter with ?model=)
GET /models/{name}/checks/{check}/rowsreadThe rows that check rejected, from the snapshot it ran against — including when promotion was blocked. Table-level and Python checks set available false
POST /checks/runwriteBody {environment, selectors} (optional). Runs checks against promoted tables, no rebuild. Returns {outcomes, skipped, passed, blocking_failures}

Streams

RouteScopeDescription
GET /streamsreadPer stream: schema, table, head (accepted), watermark (materialised), pending (head − watermark), on_schema_drift, retention
GET /streams/{name}readAdds idempotency_key and recent (the last 20 payloads)
POST /streams/{name}writeBody: one JSON object or an array. Durable before it returns. {accepted, deduplicated, last_offset, quarantined}. Schema violations → 400 (reject/evolve) or quarantined (quarantine mode); warehouse too far behind → 429
GET /streams/{name}/eventsreadSSE tail of the durable log for an external consumer. Data frames are {offset, ts, payload, idempotency_key, headers} with id = offset. No cursor starts at the head (live only); after=0 replays; Last-Event-ID resumes. group takes that consumer group’s lease (409 if held) and resumes from its committed offset; the first frame is event: lease with {group, token, committed_offset}. A frame is not an ack. ?token= is accepted here. <name>__quarantine tails diverted rows
POST /streams/{name}/commitwriteBody {group, offset, token} from the lease frame. Advances that group’s committed offset. A stale token → 400

Query Console

RouteScopeDescription
POST /queryreadBody {sql, limit: 500} (capped at 10,000). Exactly one SELECT/UNION statement — DDL/DML and external readers (read_csv, query, glob, HTTP/file) are rejected at parse. Runs on a sandboxed cursor with external access disabled (warehouse only), 30s timeout, ~8 MB cell cap. Returns {columns, types, rows, row_count, truncated, elapsed_ms}

System

RouteScopeDescription
GET /enginesreadConfigured engines (DSN credentials redacted)
GET /connectionsreadNamed http and postgres connections. Secret header values and DSN credentials are replaced with …
GET /schedulesreadScheduled models: kind (cron/every/watch/webhook), expression, next_fire, last_fired
POST /hooks/{name}writeEnqueue the model whose schedule.webhook is name. Idempotency-Key dedupes. Unknown name is 404. {model, idempotency_key, enqueued}
POST /tests/runwriteEphemeral DuckDB fixture test. Body {selectors, update_golden}. {ok, passed, messages}. Does not run live checks or the promotion gate
POST /gcadminBody {grace: "7d", dry_run: false} (optional). Returns {removed_snapshots, dropped_tables, kept_snapshots, dry_run}
POST /resetadminBody {confirm: false, dry_run: false}. Requires confirm: true unless dry_run. Wipes Interlace-owned views, snapshots, runs, events, and streams; does not drop table/file destinations. Returns {dropped_views, dropped_schemas, cleared_snapshots, kept_terminals, environments, stream_log_cleared, dry_run}

API Keys

RouteScopeDescription
GET /apikeysadminNames, scopes, creation times — never the secrets
POST /apikeysadminBody {name, scopes: ["read"]}. Returns the ilk_ token once
DELETE /apikeys/{name}admin200. Revokes every key with that name

Bootstrap: while keyless, POST /apikeys works unauthenticated — create the first (admin) key, then everything requires tokens.

Events

RouteScopeDescription
GET /eventsreadDurable event log; ?after=<seq> pages forward, 200 per call
GET /events/streamreadServer-Sent Events. Each message: event = type, id = sequence, data = the full event. Reconnects resume from the Last-Event-ID header with no gaps

Event types: run.enqueued, run.started, run.succeeded, run.retrying, run.failed, run.cancel_requested, run.cancelled, apply.started, apply.finished, apply.blocked, model.start, model.done, model.failed, model.cancelled, stream.flushed, environment.dropped, environment.rolled_back, gc.finished, reset.finished.

Apply and run payloads include api_key (the key name, or cli / scheduler / mcp / anonymous). Set event_log_path and each committed event is also one NDJSON line. The SSE feed still reads SQLite, so a CLI apply in another process still appears.

Note: browser EventSource can’t send an Authorization header — once keys exist, browser clients should poll GET /events; non-browser SSE clients pass the bearer header as usual.