OriginChain docs
03 · http api · 32 endpoints

HTTP API reference.

One base URL per tenant instance. TLS 1.3 only. Bearer auth on every /v1/... path; mutating routes additionally honour Idempotency-Key. Request bodies cap at 8 MiB (the NDJSON batch route lifts that cap and applies its own per-line + total-buffer accounting).

auth & headers
Header Required Notes
Authorization: Bearer <token>Every /v1/* routeTenant-scoped. /health, /ready, /metrics are public.
Idempotency-Key: <ulid|uuid>Optional, mutating routesSame key + same body = cached response. Different body with same key = 409.
Content-TypePOST / PUTapplication/json by default; text/plain for schemas; application/x-ndjson on the streaming batch route.
Accept: text/event-stream/v1/tenants/:t/watchSSE stream. Server pushes one event per change burst.
X-OC-Query-IdResponse onlyULID for the query. Pass it to POST /v1/queries/:id/cancel.
X-OC-Replication: degradedResponse onlyWrite succeeded but follower didn't ack within sync-timeout. Surface as a warning.
Retry-After: <seconds>Response only (429)Honour it. Clients should back off, not hammer.
errors

Every non-2xx response is a JSON document of the form { "error": "code", "message": "...", "request_id": "..." }. Quote request_id in support tickets.

Status Code Meaning
400validation_failedBody or query parameters malformed.
401unauthorizedBearer missing, invalid, or not scoped to this tenant.
402quota_exceededAuthed and under RPS, but credit is exhausted.
403forbiddenToken cannot reach this resource.
404not_foundSchema / row / migration not registered.
409conflictIdempotency replay-mismatch, lease busy, or migration wrong-state.
413body_too_largeBody over 8 MiB (or NDJSON line over 1 MiB).
429rate_limitedPer-bearer token bucket drained. Honour Retry-After.
499cancelledQuery cancelled via POST /v1/queries/:id/cancel.
500/503server_error / fenced5xx; retry with backoff if idempotent. 503 means the writer was fenced — retry against the new leader.

Schemas

TOML manifests describe a table — its primary key, columns, indexes, and relations. The substrate hashes everything off the manifest, so registering one is the prerequisite to any row write.

POST /v1/tenants/:tenant/schemas — Register or update a TOML manifest. Body is the raw TOML; Content-Type: text/plain.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/schemas" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: text/plain" \
  --data-binary @orders.toml
response
200 OK
{ "id": "trading.orders", "version": 1 }
GET /v1/tenants/:tenant/schemas — List every schema id registered for the tenant.
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/schemas" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
["trading.orders", "trading.trades", "trading.users"]
GET /v1/tenants/:tenant/schemas/:id — Fetch the raw TOML for a schema id. Response is text/plain, not JSON.
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/schemas/trading.orders" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK · 404 if unknown
# text/plain
id = "trading.orders"
[primary_key]
columns = ["order_id"]
...

Rows (typed CRUD)

Insert, batch, and read against a registered schema. Single-row writes are atomic; the batch endpoint accepts a JSON array (atomic in one WAL frame) or NDJSON via `application/x-ndjson` (streamed in flushable chunks).

POST /v1/tenants/:tenant/rows/:schema — Upsert a single row. `?expect=insert` skips the prior-state read for pure-insert bulk loads. Send `Idempotency-Key` to make retries safe.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/rows/trading.orders" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "order_id": "o-0001",
    "symbol":   "AAPL",
    "qty":      100
  }'
response
200 OK · 400 validation · 409 idempotency replay-mismatch
{ "ok": true, "lsn": { "segment": 4, "offset": 8421007 } }
POST /v1/tenants/:tenant/rows/:schema/_batch — Atomic batch (one WAL frame, one fsync). JSON body: a row array. NDJSON body (Content-Type: application/x-ndjson): streamed; flushes every `?chunk=N` rows (default 1000, max 10000). 8 MiB body cap is disabled on this route.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/rows/trading.orders/_batch" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: bulk-2026-05-01-batch-1" \
  -d '[
    { "order_id": "o-1", "symbol": "AAPL", "qty": 100 },
    { "order_id": "o-2", "symbol": "MSFT", "qty": 250 }
  ]'
response
200 OK
{ "inserted": 2, "lsn": { "segment": 4, "offset": 8425112 } }
GET /v1/tenants/:tenant/rows/:schema/:pk — Read a row by its single-column primary key. Composite PKs must use POST /query with a ColumnScan plan.
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/rows/trading.orders/o-0001" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK · 404 not found
{
  "order_id": "o-0001",
  "symbol":   "AAPL",
  "qty":      100,
  "_oc_row_version": 1
}

Query — Plan tree (JSON)

The substrate's native execution surface. POST a JSON Plan tree and get rows back. `?explain=true` returns the executed plan annotated with stats (EXPLAIN ANALYZE). Cancel an in-flight plan with the ULID handed back in `X-OC-Query-Id`.

POST /v1/tenants/:tenant/query — Execute a Plan tree. Bare response is `Vec<row>`; with `?explain=true` it's `{rows, explain}`.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/query" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "Limit": {
      "n": 10,
      "child": {
        "Filter": {
          "predicate": { "Eq": ["status", "pending"] },
          "child":     { "Scan": { "schema": "trading.orders" } }
        }
      }
    }
  }'
response
200 OK · 400 plan parse · 499 cancelled
[
  { "order_id": "o-0001", "symbol": "AAPL", "status": "pending", ... },
  ...
]
POST /v1/tenants/:tenant/query?explain=true — EXPLAIN ANALYZE: executes the plan and returns it annotated with per-node row counts and µs timings.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/query?explain=true" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "Scan": { "schema": "trading.orders" } }'
response
200 OK
{
  "rows": [ ... ],
  "explain": {
    "op":       "Scan",
    "schema":   "trading.orders",
    "rows_out": 412,
    "elapsed_us": 1340,
    "children": []
  }
}
POST /v1/queries/:id/cancel — Flip the cancellation token for an in-flight plan. The id is the ULID returned in `X-OC-Query-Id` on the original request.
request
curl -X POST "$OC_BASE_URL/v1/queries/01HW7G5...JZ/cancel" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK (always; cancelled=false if already finished)
{ "cancelled": true }
GET /v1/tenants/:tenant/watch — Server-Sent Events stream. The connection holds open; the server pushes one event per change burst against the subscribed schemas. Ctrl-C / client close ends the subscription.
request
curl -N "$OC_BASE_URL/v1/tenants/$OC_TENANT/watch?schemas=trading.orders" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Accept: text/event-stream"
response
200 OK (text/event-stream)
event: snapshot
data: { "rows": [ ... ] }

event: snapshot
data: { "rows": [ ... ] }

SQL

A SQL surface over the same substrate. SELECT executes; INSERT and DELETE return the translated payload so callers can replay them against the typed /rows path (which has the idempotency-key plumbing). Aggregates, OUTER JOINs, and chained 3+ table joins are supported.

POST /v1/tenants/:tenant/sql — POST `{"sql": "..."}`. Response shape varies by `kind`: `select` -> `{kind, rows}`; `insert` -> `{kind, schema, rows}`; `delete` -> `{kind, schema, pk}`.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/sql" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "sql": "SELECT order_id, symbol, qty FROM trading.orders WHERE status = '"'"'pending'"'"' LIMIT 10" }'
response
200 OK · 400 parse / unsupported
// SELECT
{ "kind": "select", "rows": [{"order_id":"o-1","symbol":"AAPL","qty":100}, ...] }

// INSERT (re-issue against /rows/:schema with the returned rows)
{ "kind": "insert", "schema": "trading.orders", "rows": [...] }

// DELETE (re-issue against /rows/:schema/:pk)
{ "kind": "delete", "schema": "trading.orders", "pk": "o-1" }

Vector search

HNSW ANN with cosine / dot / L2 metrics and tunable speed/recall. Default high_recall mode hits recall@10 = 0.96 at 100k vectors with p99 109 ms; fast mode runs p99 37 ms at recall 0.69. Optional metadata is stored alongside each vector and queryable as an equality filter on topk. Brute-force fallback for small N.

POST /v1/tenants/:tenant/vector/:table/put — Upsert one vector. Optional `metadata` object is indexed for filtered topk.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/vector/embeddings/put" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id":        "doc-001",
    "embedding": [0.012, -0.443, ...],
    "dim":       384,
    "metric":    "cosine",
    "metadata":  { "lang": "en", "tier": "premium" }
  }'
response
201 Created
// 201 Created (no body)
POST /v1/tenants/:tenant/vector/:table/topk — k-nearest neighbour search. `mode=hnsw` (default) or `mode=bruteforce`. Non-empty `filter` triggers HNSW + post-filter on metadata equality.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/vector/embeddings/topk" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query":  [0.011, -0.439, ...],
    "k":      10,
    "dim":    384,
    "metric": "cosine",
    "mode":   "hnsw",
    "filter": { "lang": "en" }
  }'
response
200 OK
[
  { "id": "doc-001", "score": 0.973 },
  { "id": "doc-127", "score": 0.952 },
  ...
]

Full-text search

Per-field, per-tenant inverted index. Tokenizer is UAX #29 (Latin / Cyrillic / CJK / Arabic / Hindi). Modes: `boolean` (AND), `bm25` (ranked, default Lucene k1=1.2 b=0.75), and `phrase` (exact contiguous tokens).

POST /v1/tenants/:tenant/fts/:table/:field — Index a document under (table, field). Re-indexing the same `doc_id` cleans stale postings.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/fts/articles/body" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "doc_id": "art-001", "text": "OriginChain ships managed substrate-grade key-value storage." }'
response
201 Created
// 201 Created (no body)
GET /v1/tenants/:tenant/fts/:table/:field?q=&mode=&k= — Search. `mode=boolean|bm25|phrase` (default boolean). `k` caps results in `mode=bm25` (default 10).
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/fts/articles/body?q=substrate%20managed&mode=boolean" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
// boolean / phrase
["art-001", "art-007"]

// bm25
[ { "doc_id": "art-001", "score": 7.42 }, { "doc_id": "art-007", "score": 5.18 } ]

Graph traversal

Reads relations declared on a manifest. PKs are passed as path-encoded strings (single-column, string-typed).

GET /v1/tenants/:tenant/graph/:schema/neighbors?rel=&pk= — Forward one-hop. Returns the destination PKs along `rel` from `pk`.
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/graph/social.users/neighbors?rel=follows&pk=alice" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
["bob", "carol", "dave"]
GET /v1/tenants/:tenant/graph/:schema/reverse?rel=&pk= — Inbound one-hop: who points AT `pk` along `rel`. Works only when `from_table != to_table` (see oc-graph STATUS for the self-relation caveat).
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/graph/social.users/reverse?rel=follows&pk=bob" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
["alice", "frank"]
GET /v1/tenants/:tenant/graph/:schema/bfs?rel=&pk=&max_depth= — Breadth-first search up to `max_depth` (default 3). Returns reachable nodes with depth.
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/graph/social.users/bfs?rel=follows&pk=alice&max_depth=2" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
[
  { "pk": "bob",   "depth": 1 },
  { "pk": "carol", "depth": 1 },
  { "pk": "ed",    "depth": 2 }
]
GET /v1/tenants/:tenant/graph/:schema/path?rel=&src=&dst=&max_depth= — Reachability check: is there an `rel`-path from `src` to `dst` within `max_depth` hops?
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/graph/social.users/path?rel=follows&src=alice&dst=ed&max_depth=3" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
{ "reachable": true }
GET /v1/tenants/:tenant/graph/:schema/dijkstra?rel=&src=&dst=&weights_json= — Weighted shortest-path. `weights_json` is a JSON object mapping `"<from>|<to>"` -> f64. A manifest weight-column variant is available — contact support.
request
curl --get "$OC_BASE_URL/v1/tenants/$OC_TENANT/graph/road.cities/dijkstra" \
  -H "Authorization: Bearer $OC_TOKEN" \
  --data-urlencode "rel=connects" \
  --data-urlencode "src=NYC" \
  --data-urlencode "dst=SFO" \
  --data-urlencode 'weights_json={"NYC|CHI":2.0,"CHI|DEN":1.5,"DEN|SFO":1.2}'
response
200 OK
{ "cost": 4.7 }

Online schema migrations

Submit a diff, watch backfill progress, then cut over atomically. Aborts are allowed pre-cutover only. Spec 06 §4.4 — every state transition is durably journaled.

POST /v1/tenants/:tenant/migrations — Submit a migration. Body: `{schema, diff}` where `diff` is a `Vec<oc_migrate::DiffOp>`.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/migrations" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "schema": "trading.orders",
    "diff":   [ { "AddColumn": { "name": "venue", "type": "str", "default": "NYSE" } } ]
  }'
response
200 OK
{
  "id":      "0192ab...",
  "tenant":  "01HW...ZZ",
  "schema":  "trading.orders",
  "state":   "Backfilling",
  "diff":    [ ... ],
  "progress": { "rows_seen": 0, "rows_total": null }
}
GET /v1/tenants/:tenant/migrations — List every migration the tenant has submitted (active + terminal).
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/migrations" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
[ { "id": "...", "schema": "trading.orders", "state": "Backfilling", ... }, ... ]
GET /v1/tenants/:tenant/migrations/:id — Read one migration. Poll for backfill progress.
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/migrations/0192ab..." \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK · 404 unknown
{ "id": "...", "state": "ReadyToCutover", "progress": { "rows_seen": 1240000, ... } }
POST /v1/tenants/:tenant/migrations/:id/cutover — Atomic cutover. Only legal in `ReadyToCutover` state.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/migrations/0192ab.../cutover" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK · 409 wrong state
{ "id": "...", "state": "Completed", ... }
POST /v1/tenants/:tenant/migrations/:id/abort — Abort. Only legal pre-cutover. Once the migration is `Completed`, abort returns 409.
request
curl -X POST "$OC_BASE_URL/v1/tenants/$OC_TENANT/migrations/0192ab.../abort" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK · 409 already cut over
{ "id": "...", "state": "Aborted", ... }
GET /v1/tenants/:tenant/migrations/_audit — Append-only audit log of every state transition (submit / cutover / abort / auto-cutover) with actor + UNIX timestamp.
request
curl "$OC_BASE_URL/v1/tenants/$OC_TENANT/migrations/_audit" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
[
  { "id": "...", "schema": "trading.orders", "state": "Backfilling", "at_secs": 1714512000, "actor": "...", "event": "submit" },
  { "id": "...", "schema": "trading.orders", "state": "Completed",   "at_secs": 1714512042, "actor": "...", "event": "cutover" }
]

Replication (admin)

Lease-driven active-passive coordination. The lease holder is the sole writer; followers tail frames from the leader. These endpoints are operational, not application-facing — most tenants never call them.

GET /v1/replication/lease — Read the current lease (or null if vacant).
request
curl "$OC_BASE_URL/v1/replication/lease" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
{ "epoch": 4, "holder": "writer-a", "expires_at_secs": 1714512030, "etag": "..." }
POST /v1/replication/lease — Try-acquire the lease. `ttl_secs` defaults to 30. Returns 409 if held by another writer.
request
curl -X POST "$OC_BASE_URL/v1/replication/lease" \
  -H "Authorization: Bearer $OC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "holder": "writer-a", "ttl_secs": 30 }'
response
200 OK · 409 busy
{ "epoch": 5, "holder": "writer-a", "expires_at_secs": 1714512060, "etag": "..." }
GET /v1/replication/frames?since_segment=&epoch= — Export every WAL frame from `since_segment` onwards as hex-encoded `Frame::Append`. Management-plane convenience; production followers stream raw bytes via the FrameReader transport.
request
curl "$OC_BASE_URL/v1/replication/frames?since_segment=4&epoch=5" \
  -H "Authorization: Bearer $OC_TOKEN"
response
200 OK
["7b226c736e223a..."]

Health & observability

All three are public (no auth) so AWS load balancers and Prometheus scrapers can probe without a credential.

GET /health — Liveness — process is up and the WAL is mounted.
request
curl "$OC_BASE_URL/health"
response
200 OK
{ "status": "ok" }
GET /ready — Readiness — ready to accept traffic (lease healthy, replication caught up).
request
curl "$OC_BASE_URL/ready"
response
200 OK · 503 not ready
{ "ready": true, "wal": "sync", "lease_holder": "writer-a" }
GET /metrics — Prometheus exposition — request latencies, cache hits, WAL bytes, replication frame counts.
request
curl "$OC_BASE_URL/metrics"
response
200 OK (text/plain)
# HELP oc_query_latency_ms /v1/query end-to-end latency.
# TYPE oc_query_latency_ms histogram
oc_query_latency_ms_bucket{le="10"}  9218
oc_replication_frames_total          41702
oc_plan_cache_hits_total             41190
...
SQL — what's not supported

UPDATE, BEGIN/COMMIT/multi-statement transactions, DDL (CREATE TABLE / ALTER), CROSS JOIN, and NATURAL JOIN are not in scope today. Use POST /rows/:schema for upserts, the migrations endpoints for schema change, and explicit JOIN ... ON for multi-table reads.