Skip to content

SPARQL, GraphQL, WebSocket, and REST Endpoints

All endpoints described on this page are available on port 7001. Authentication is required on all endpoints in production deployments; see the API Overview for details.

Trial image

The trial image (ghcr.io/indentiaplatform/indentiadb-trial) runs without authentication by default — all endpoints are accessible without credentials. The -u root:changeme flags shown in examples are included for consistency with production usage and are ignored in trial mode.


SPARQL Query — GET / POST /sparql

Execute SPARQL 1.2 SELECT, ASK, CONSTRUCT, and DESCRIBE queries against the RDF triple store.

GET Request

URL-encode the query parameter and send it as a query string:

curl -u root:changeme \
  "http://localhost:7001/sparql?query=SELECT%20%3Fs%20%3Fp%20%3Fo%20WHERE%20%7B%20%3Fs%20%3Fp%20%3Fo%20%7D%20LIMIT%2010"

Optional dataset parameters:

Parameter Description
default-graph-uri Restrict the query to a specific default graph
named-graph-uri Include an additional named graph in the dataset

POST Request — application/sparql-query

Send the raw SPARQL query string as the request body:

curl -u root:changeme \
  -X POST http://localhost:7001/sparql \
  -H "Content-Type: application/sparql-query" \
  -H "Accept: application/sparql-results+json" \
  -d "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10"

POST Request — application/x-www-form-urlencoded

Send the query as a form-encoded query field:

curl -u root:changeme \
  -X POST http://localhost:7001/sparql \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "query=SELECT ?s WHERE { ?s a <http://example.org/Person> }"

Response Formats

The Accept header controls the serialization format:

Accept Header Format Use Case
application/sparql-results+json JSON SELECT, ASK, CONSTRUCT, DESCRIBE
application/sparql-results+xml XML SELECT, ASK
text/csv CSV SELECT (tabular export)
text/tab-separated-values TSV SELECT (tabular export)
application/x-ndjson NDJSON SELECT (streaming)
text/turtle Turtle CONSTRUCT, DESCRIBE
application/n-triples N-Triples CONSTRUCT, DESCRIBE
application/n-triples;profile="http://www.w3.org/ns/rdf-canon#c14n" N-Triples Canonical (C14N) CONSTRUCT, DESCRIBE — deterministic output

The format query parameter (json, xml, csv, tsv, turtle, ntriples, ntriples-c14n) is an alternative to the Accept header.

TSV and CSV

Both tabular formats list their columns in the query's SELECT projection order (SELECT ?s ?p ?o gives the columns s, p, o), on the buffered and the streaming path alike.

  • TSV follows the SPARQL 1.1 Query Results TSV format: the header row carries ?-prefixed variable names, and every cell is a complete RDF term in Turtle/N-Triples syntax — IRIs in <…>, quoted literals with their datatype or language tag, _: blank nodes and RDF 1.2 triple terms (<<( … )>>). Tabs, newlines and carriage returns inside a term are written as \t, \n and \r. TSV is the lossless tabular format: a literal can never be mistaken for an IRI.
  • CSV follows the SPARQL 1.1 CSV format: plain variable names in the header, bare term values without datatype or language tag, and RFC 4180 quoting. Use TSV or JSON when term types matter.
?s  ?p  ?o
<http://example.org/alice>  "42"^^<http://www.w3.org/2001/XMLSchema#integer>    "hallo"@nl
_:b0    <<( <http://example.org/alice> <http://example.org/name> "Alice" )>>    

Changed behavior

Earlier releases wrote TSV cells as bare values (like CSV), omitted the ? header prefix and sorted columns alphabetically. Update TSV consumers that relied on that shape.

Canonical N-Triples (RDF 1.2 C14N)

The canonical N-Triples variant implements the escaping rules from the RDF 1.2 Candidate Recommendation (7 April 2026) and the related RDF Dataset Canonicalization spec. It differs from plain N-Triples in two ways:

  1. Canonical character escaping — \b, \t, \n, \f, \r, \", \\ as named escapes; all other C0/C1 controls, DEL, and XML 1.1 invalid characters as \uXXXX.
  2. Language tag canonicalization — language tags are lowercased per BCP47 ("chat"@EN-GB → "chat"@en-gb), including the direction component of directional language-tagged strings ("مرحبا"@AR--RTL → "مرحبا"@ar--rtl).

Select it via either:

  • Accept header with profile parameter:
    curl -u root:changeme \
      -H 'Accept: application/n-triples;profile="http://www.w3.org/ns/rdf-canon#c14n"' \
      "http://localhost:7001/sparql?query=..."
    
  • Query parameter:
    curl -u root:changeme \
      "http://localhost:7001/sparql?format=ntriples-c14n&query=..."
    

Use canonical form when you need byte-for-byte reproducible output for hashing, signing, or diff-based change detection.

CONSTRUCT and DESCRIBE output format

CONSTRUCT and DESCRIBE queries return a JSON array of N-Triples strings when Accept: application/sparql-results+json is used:

[
  "<http://example.org/alice> <http://xmlns.com/foaf/0.1/name> \"Alice\"",
  "<http://example.org/bob> <http://xmlns.com/foaf/0.1/name> \"Bob\""
]
For native RDF serialization, use Accept: text/turtle or Accept: application/n-triples — the response will be one triple per line in N-Triples format (which is a valid Turtle subset).

SELECT Example

curl -u root:changeme \
  -X POST http://localhost:7001/sparql \
  -H "Content-Type: application/sparql-query" \
  -H "Accept: application/sparql-results+json" \
  -d '
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
SELECT ?person ?name
WHERE {
  ?person a foaf:Person ;
          foaf:name ?name .
}
ORDER BY ?name
'

Response:

{
  "head": { "vars": ["person", "name"] },
  "results": {
    "bindings": [
      {
        "person": { "type": "uri", "value": "http://example.org/alice" },
        "name": { "type": "literal", "value": "Alice" }
      }
    ]
  }
}

ASK Example

curl -u root:changeme \
  -X POST http://localhost:7001/sparql \
  -H "Content-Type: application/sparql-query" \
  -H "Accept: application/sparql-results+json" \
  -d '
PREFIX ex: <http://example.org/>
ASK { ex:alice a ex:Person }
'

Response:

{ "head": {}, "boolean": true }

CONSTRUCT Example

curl -u root:changeme \
  -X POST http://localhost:7001/sparql \
  -H "Content-Type: application/sparql-query" \
  -H "Accept: text/turtle" \
  -d '
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
PREFIX ex: <http://example.org/>
CONSTRUCT { ?s foaf:name ?name }
WHERE { ?s foaf:name ?name }
'

DESCRIBE Example

curl -u root:changeme \
  -X POST http://localhost:7001/sparql \
  -H "Content-Type: application/sparql-query" \
  -H "Accept: text/turtle" \
  -d 'DESCRIBE <http://example.org/alice>'

Named Dataset Parameters

Restrict a query to a specific named graph:

curl -u root:changeme \
  -X POST http://localhost:7001/sparql \
  -H "Content-Type: application/sparql-query" \
  -H "Accept: application/sparql-results+json" \
  -d 'SELECT * WHERE { ?s ?p ?o }' \
  "http://localhost:7001/sparql?default-graph-uri=http://example.org/employees"

Read Consistency — consistency=strong

In an HA cluster, default reads are answered locally (fast, eventually consistent). For linearizable reads, opt in per request with the consistency=strong query parameter or the X-Indentia-Consistency: strong header:

curl -X POST "http://localhost:7001/sparql" \
  -H "X-Indentia-Consistency: strong" \
  -H "Content-Type: application/sparql-query" \
  -d 'SELECT * WHERE { ?s ?p ?o } LIMIT 10'

A strong read executes a Raft read-index barrier before the query. A follower answers with a 307 redirect to the leader; a node that cannot confirm quorum answers 503 — never stale data. See High Availability for details.

Streaming Results

Large result sets are delivered with chunked transfer encoding once they exceed the configured streaming threshold (default 1,000 results). Streaming responses include X-Rows-Sent and X-Duration-Ms trailer headers. Send X-No-Streaming: true to force a fully buffered response.

With the server flag IG_SPARQL_LAZY_NDJSON=1, SELECT results additionally stream lazily — rows are serialized as they are produced, without materializing the full result set in memory:

Query form Lazy streaming format
SELECT NDJSON (Accept: application/x-ndjson), CSV, TSV, or SPARQL Results XML
CONSTRUCT / DESCRIBE N-Triples
SELECT (JSON), ASK, COUNT Always buffered (the SPARQL JSON format requires a full pre-scan)

Lazy streams are governed by the same execution limits and timeout as buffered queries — the timeout bounds the entire evaluation — and a client disconnect hard-cancels the running query, releasing its resources.


SPARQL Update — POST /update

Execute SPARQL 1.1 Update operations to modify the triple store.

Request

curl -u root:changeme \
  -X POST http://localhost:7001/update \
  -H "Content-Type: application/sparql-update" \
  -d '
PREFIX ex: <http://example.org/>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
INSERT DATA {
  ex:alice a ex:Person ;
           foaf:name "Alice" ;
           foaf:age 30 .
}
'

A successful update returns HTTP 200 with a JSON confirmation body:

{
  "success": true,
  "message": "Update applied successfully",
  "snapshot_index": 1,
  "affected_triples": 7
}

Delta Overlay Limit — HTTP 413

Live updates land in an in-memory delta overlay that is bounded by the [limits] configuration (max_delta_quads, default 5,000,000 quads). An update that would push the overlay past the cap is rejected fail-closed with HTTP 413 and error code delta_overlay_limit_exceeded instead of risking an out-of-memory crash:

{
  "error": "delta_overlay_limit_exceeded",
  "message": "Delta overlay limit exceeded (current=4980000, attempted=60000, max=5000000). Use bulk index build for large datasets."
}

Batch bulk writes at ≤ ~2,000 triples per UPDATE and use the offline bulk index build for large datasets — see Scale & Sharding.

Request Limits for UPDATE

The WHERE part of an update is evaluated under the same [limits] as a query (max_results, query_timeout_secs, query_memory_mb, plus per-actor and per-role overrides), counted across the whole request rather than per operation. For updates the limits are always hard: an update is never applied to a truncated set of bindings.

Limit exceeded Status error
More WHERE solutions than max_results 400 result_limit_exceeded
Request memory budget 413 memory_limit_exceeded
Query timeout 408 query_timeout
Template expansion above the per-request ceiling — min(max_delta_quads, max_results) quads, or max_results when max_delta_quads = 0 400 invalid_request

CLEAR or DROP over NAMED or ALL graphs is rejected with 403 before evaluation. Clear or drop individual graphs instead (DROP GRAPH <iri>).

Write Rate Limiting — [limits.write_qos]

An optional per-actor token bucket protects the write path (and the reads that share its locks) from a single chatty writer. It is disabled by default:

[limits.write_qos]
enabled         = true
updates_per_sec = 10     # sustained rate per actor; 0 = unlimited
burst           = 50     # bucket capacity per actor

[limits.write_qos.per_actor."ingest-service"]
updates_per_sec = 5      # unset fields inherit the defaults above
Key Default Meaning
enabled false Master switch
updates_per_sec 10 Sustained refill rate per actor (updates/second); 0 means unlimited
burst 50 Bucket capacity per actor; must be greater than 0 when a rate is set
per_actor."<actor id>" — Overrides updates_per_sec and/or burst for one actor id

A request over budget receives HTTP 429 (too_many_requests) with a Retry-After header (seconds until the next token). The gate applies to POST /update only; internal replication and projection work is never throttled. Rejections are counted in ig_write_qos_rejections_total.

Idempotent Retries — X-Indentia-Mutation-Id

Send a client-generated canonical UUIDv7 in X-Indentia-Mutation-Id to make a write safe to retry. The header works on every /update request — it no longer requires X-Indentia-Valid-From — and the server echoes it on the response.

curl -u root:changeme \
  -X POST http://localhost:7001/update \
  -H "Content-Type: application/sparql-update" \
  -H "X-Indentia-Mutation-Id: 01991a2b-3c4d-7e5f-8a6b-7c8d9e0f1a2b" \
  -d 'INSERT DATA { <http://example.org/alice> <http://example.org/status> "active" }'
Situation Result
Exact retry of an applied write 200 with the original snapshot_index; the data is not applied again (affected_triples may report 0)
Same ID reused for a different write 409
Not a canonical UUIDv7 400
ID older than the retry horizon (5 minutes after its UUIDv7 timestamp) 400 — issue a new UUIDv7

Generate a new UUIDv7 for every logical write and keep the request body and headers byte-identical when retrying it. For valid-time writes, see Bitemporal — Temporal Writes via HTTP Headers.

Conditional Publication

A cluster-mode /update can be guarded by a version precondition on a publication control subject (compare-and-set). The update commits only if the control subject still holds the expected version, and it must move the control to the next version in the same request. Both parts are required together — otherwise the request fails with 400:

  • Content-Type: application/vnd.indentiagraph.publication-update+sparql (the body is the unchanged SPARQL update);
  • exactly one X-Indentia-Publication-Precondition header containing a JSON object of at most 4,096 bytes.
curl -X POST http://localhost:7001/update \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/vnd.indentiagraph.publication-update+sparql" \
  -H "X-Indentia-Mutation-Id: 01991a2b-3c4d-7e5f-8a6b-7c8d9e0f1a2b" \
  -H 'X-Indentia-Publication-Precondition: {"graph":"https://id.indentia.ai/sources/tenant-a/docs","subject":"https://id.indentia.ai/sources/tenant-a/docs/doc-1/publication","predicate":"https://id.indentia.ai/publication/version","expected_version":"01991000-0000-7000-8000-000000000001","next_version":"01991000-0000-7000-8000-000000000002"}' \
  --data-binary @publish.ru
Field Required Meaning
graph yes Control graph — under https://id.indentia.ai/sources/<tenant>/ or exactly urn:indentia:rag:<tenant>
subject yes Control subject — under <graph>/ or https://id.indentia.ai/document/Document/<tenant>/
predicate yes Version predicate in the https://id.indentia.ai/publication/ namespace
expected_version yes Current version — a canonical UUIDv7 that must equal the single plain-literal value of predicate — or explicit null to bootstrap a control subject that does not exist yet
next_version yes Canonical UUIDv7 greater than expected_version; the update must leave exactly this value on the control subject
dependencies no Up to 8 read-only {subject, predicate, expected_version} gates in the same graph that must still hold their version

Rules and outcomes:

  • The caller needs a tenant claim, read and write permission on the graph, and a configured authorization service; otherwise 403.
  • The control state and each dependency are limited to 256 quads.
  • Standalone mode, temporal headers (X-Indentia-Valid-From) and tenant-routed writes do not support this request form (400).
  • 200 returns the ordinary update body. 409 means the precondition was lost (version mismatch, control already exists on bootstrap, or a concurrent publication won) — no data was written by that attempt.
  • 503 (with Retry-After) until every replica runs a release that supports cluster command format v9 and the cluster has activated it. See High Availability for the upgrade procedure.

Generate a new X-Indentia-Mutation-Id for each publication attempt and keep its exact body and header bytes; if the outcome is unknown, resend that attempt unchanged. Read the control state back with a consistency=strong query.

INSERT DATA

PREFIX ex: <http://example.org/>
INSERT DATA {
  ex:bob a ex:Person ;
         <http://xmlns.com/foaf/0.1/name> "Bob" .
}

DELETE DATA

PREFIX ex: <http://example.org/>
DELETE DATA {
  ex:alice <http://xmlns.com/foaf/0.1/age> 30 .
}

DELETE / INSERT WHERE

Conditional update based on a pattern match:

PREFIX ex: <http://example.org/>
DELETE { ex:alice ex:status "inactive" }
INSERT { ex:alice ex:status "active" }
WHERE  { ex:alice ex:status "inactive" }

Graph Management

Create a named graph:

CREATE GRAPH <http://example.org/employees>

Drop a named graph and all its triples:

DROP GRAPH <http://example.org/employees>

CLEAR/DROP of NAMED or ALL graphs is not accepted on /update (403); name the graphs individually.

Copy all triples from one graph to another:

COPY <http://example.org/staging> TO <http://example.org/production>

Move triples (copy then drop source):

MOVE <http://example.org/staging> TO <http://example.org/archive>

Graph Store Protocol — GET / PUT / POST / DELETE /data

The SPARQL 1.1 Graph Store HTTP Protocol allows reading and writing entire graphs via HTTP.

GET — Read a Graph

Retrieve a named graph in the desired RDF serialization:

# N-Triples (default)
curl -u root:changeme \
  -H "Accept: application/n-triples" \
  "http://localhost:7001/data?graph=http://example.org/employees"

# Turtle
curl -u root:changeme \
  -H "Accept: text/turtle" \
  "http://localhost:7001/data?graph=http://example.org/employees"

# TriG (includes GRAPH <iri> { ... } wrapper for named graphs)
curl -u root:changeme \
  -H "Accept: application/trig" \
  "http://localhost:7001/data?graph=http://example.org/employees"

Retrieve the default graph:

curl -u root:changeme \
  -H "Accept: application/n-triples" \
  http://localhost:7001/data

Supported Accept headers for GET:

Accept Header Response Content-Type
application/n-triples (default) application/n-triples; version=1.2
application/n-triples-star application/n-triples-star; version=1.2
text/turtle text/turtle; version=1.2
application/trig application/trig; version=1.2
application/rdf+xml application/rdf+xml; version=1.2
application/ld+json application/ld+json; version=1.2

The version=1.2 parameter signals RDF 1.2 conformance — clients that understand quoted triples and rdf:JSON can use this to opt in to RDF-star-aware parsing.

GET /data?all=true, or a GET /data without graph/default that sends Accept: application/n-quads, returns the whole dataset instead — see Dataset Export.

PUT — Replace a Graph

Replace the entire content of a named graph (or the default graph) with the uploaded RDF:

curl -u root:changeme \
  -X PUT \
  -H "Content-Type: text/turtle" \
  "http://localhost:7001/data?graph=http://example.org/employees" \
  --data-binary @employees.ttl

Replace the default graph:

curl -u root:changeme \
  -X PUT \
  -H "Content-Type: text/turtle" \
  http://localhost:7001/data \
  --data-binary @data.ttl

POST — Merge Into a Graph

Add triples to an existing named graph without removing existing triples:

curl -u root:changeme \
  -X POST \
  -H "Content-Type: text/turtle" \
  "http://localhost:7001/data?graph=http://example.org/employees" \
  -d '
@prefix ex: <http://example.org/> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .

ex:carol a ex:Person ; foaf:name "Carol" .
'

DELETE — Remove a Graph

Delete all triples from a named graph:

curl -u root:changeme \
  -X DELETE \
  "http://localhost:7001/data?graph=http://example.org/employees"

Delete the default graph:

curl -u root:changeme \
  -X DELETE \
  http://localhost:7001/data

Supported Content Types

Content-Type Format
text/turtle Turtle
application/n-triples N-Triples
application/n-quads N-Quads (includes graph name)
application/trig TriG (Turtle with named graphs)
application/rdf+xml RDF/XML
application/ld+json JSON-LD

Dataset Export

Two Admin-only endpoints stream the complete dataset — every named graph plus the default graph — as N-Quads. Both read the raw dataset without ACL filtering (an export that silently dropped hidden data would be useless as a backup), stream without a result cap, and abort the response body mid-stream on an internal error instead of ending a partial export with a clean 200. Neither export is a point-in-time snapshot: writes applied while an export runs may or may not be included.

GET /data?all=true — Full N-Quads Dump

curl -u root:changeme \
  "http://localhost:7001/data?all=true" \
  -o dataset.nq

The response is application/n-quads; version=1.2. Blank-node labels are consistent across the whole dump, so a blank node shared between graphs keeps a single label. A bare GET /data with Accept: application/n-quads (and no graph or default parameter) returns the same dump. Requires Admin permission (403 otherwise).

GET /admin/export/nquads — RDF 1.2 Export

A batched RDF 1.2 N-Quads export (base directions, triple terms <<( s p o )>>) intended as input for rebuilding or attaching an index. It takes the dataset read lock once per batch, never for the whole export.

curl -u root:changeme -D headers.txt \
  "http://localhost:7001/admin/export/nquads?min_snapshot_index=1200" \
  -o export.nq
Parameter Default Meaning
min_snapshot_index — Refuse with 409 (export_behind_requested_snapshot, retryable: true) unless this node has applied at least this log index
batch 4096 Quads per lock acquisition (clamped to 64–65,536)
skip_invalid false Leave out quads that N-Quads 1.2 cannot express (for example a literal in subject position) instead of aborting the export

The response header X-IG-Export-Snapshot-Index reports the snapshot index at which the export started. With skip_invalid=true, a complete export ends with a comment line that states the counts, including how many quads were left out:

# ig-export complete base_quads=1048576 overlay_quads=2311 skipped_invalid=2

With skip_invalid=true, a body that lacks this trailing comment is incomplete. Without skip_invalid, the first unrepresentable quad aborts the stream.


Vector Top-K — POST /vector/topk

A native top-k cosine-similarity scan over chunk embeddings stored in one named graph, bypassing the SPARQL layer. The scan is scoped to a caller-supplied set of documents: only chunks linked to one of doc_iris are scored.

curl -u root:changeme \
  -X POST http://localhost:7001/vector/topk \
  -H "Content-Type: application/json" \
  -d '{
    "graph": "urn:indentia:rag:tenant-a",
    "query_vector": [0.012, -0.034, 0.051],
    "doc_iris": ["urn:indentia:doc:tenant-a/report-2026.pdf"],
    "k": 20
  }'
Field Required Default / limits
graph yes Named graph holding the chunks (IRI)
query_vector yes Non-empty array of finite numbers
doc_iris yes 1–200 document IRIs scoping the scan
k yes 1–1000
field no Embedding predicate, default urn:indentia:rag:embedding
text_field no Chunk text predicate, default urn:indentia:rag:chunkText
headings_field no Heading trail predicate, default urn:indentia:rag:headings
belongs_to_field no Chunk → document predicate, default urn:indentia:rag:belongsTo

Embeddings are read from literals of comma- or space-separated numbers, optionally wrapped in […]. A chunk whose embedding does not parse or has a different dimension than the query vector is skipped.

{
  "hits": [
    {
      "chunk": "urn:indentia:chunk:tenant-a/17",
      "doc": "urn:indentia:doc:tenant-a/report-2026.pdf",
      "text": "…",
      "headings": "Results > Revenue",
      "score": 0.91
    }
  ]
}

Hits are sorted by score (descending), at most k. text and headings are null when absent.

Status Cause
400 Invalid field value or IRI
401 / 403 Missing authentication, or no Read permission
403 The requested graph is not visible to the caller
429 Query admission queue is full
503 The node is shedding load under memory pressure

The endpoint enforces Read permission and graph-level visibility. Document-level filtering inside a visible graph is the caller's responsibility: pass only document IRIs the user may see.


Stream Ingest — POST /stream/ingest, GET /stream/status

Offset-ordered ingestion for event producers with exactly-once semantics. Each batch carries a per-producer offset; the batch's triples and the new offset are written in one update, so data and offset commit atomically and survive restarts.

curl -u root:changeme \
  -X POST http://localhost:7001/stream/ingest \
  -H "Content-Type: application/json" \
  -d '{
    "producer": "sensor-gateway-1",
    "offset": 42,
    "events": [
      {
        "event_id": "evt-9001",
        "graph": "https://example.org/graphs/sensors",
        "subject": "https://example.org/sensor/17",
        "predicate": "https://example.org/status",
        "object": "online",
        "op": "upsert"
      }
    ]
  }'
{ "producer": "sensor-gateway-1", "offset": 42, "accepted": 1, "duplicate": false, "last_offset": 42 }
Field Meaning
producer Producer name; the key of the offset sequence (no spaces, <, >, " or \)
offset Offset of this batch, starting at 1
events Up to 1,000 events; an empty list is a valid offset-only heartbeat
events[].graph, subject, predicate Absolute IRIs
events[].object Value, written as a plain string literal
events[].op upsert (default) replaces every existing value of subject/predicate in that graph; delete removes them
events[].event_id Optional, for tracing only

Offset handling:

Offset sent Result
last_offset + 1 Applied — 200 with accepted = number of events
≤ last_offset Duplicate — 200 with duplicate: true and accepted: 0; nothing changes, so producers can safely redeliver
> last_offset + 1 Gap — 409; the producer must resend the missing offsets first

More than 1,000 events or an invalid IRI/producer name returns 400. At most IG_STREAM_MAX_INFLIGHT batches (default 8) run concurrently; further batches get 429 and should be retried with backoff. POST /stream/ingest requires Write permission.

GET /stream/status (Read permission) returns the offsets as known to the answering node:

{
  "producers": [ { "producer": "sensor-gateway-1", "last_offset": 42, "events": 1337 } ],
  "max_inflight": 8,
  "offset_graph": "https://id.indentia.ai/sources/_/stream-offsets"
}

Offsets are stored as ordinary triples in the reserved named graph shown in offset_graph.


Workspace Expansion — POST /workspaces/{id}/expand

When the workspaces API is enabled (IG_WORKSPACES_ENABLED=true or a IG_WORKSPACES_CONFIG file; otherwise the routes return 404), expand resolves a workspace into the document IRIs and tables the caller may see. A workspace the caller has no role on answers 404, exactly like a missing one.

A collection that references a tenant source graph (https://id.indentia.ai/sources/<tenant>/<source>) expands to the documents in that graph only — subjects typed https://id.indentia.ai/document/Document or minted under https://id.indentia.ai/document/Document/ — not to chunk or entity subjects.

Unpaged (empty body):

curl -X POST http://localhost:7001/workspaces/q3-review/expand \
  -H "Authorization: Bearer $TOKEN"
{ "workspace": "…", "iris": ["…"], "tables": [ { "catalog": "…", "namespace": "…", "table": "…" } ] }

Paged (send limit and/or cursor):

curl -X POST http://localhost:7001/workspaces/q3-review/expand \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"limit": 1000, "cursor": "<next_cursor from the previous page>"}'
{
  "workspace": "…",
  "iris": ["…"],
  "tables": [],
  "next_cursor": "…",
  "total": 48210,
  "stamp": "…"
}
  • limit is 1–5,000 (default 1,000 when only a cursor is sent). iris is one page of the ascending, de-duplicated set; next_cursor is null on the last page.
  • Paging is keyset-based: no duplicates or reordering while the scope changes, and every page is cut from the caller's own ACL view, so a cursor cannot widen access. stamp changes when the scope or the caller's access changes.
Setting Default Effect
IG_WORKSPACE_EXPAND_UNPAGED_MAX (TOML expand_unpaged_max) 1000 Maximum visible references one collection may contribute to an unpaged expand
IG_WORKSPACE_EXPAND_MAX_IRIS (TOML expand_max_iris) 100000 Absolute ceiling for any expansion, paged or not

Errors:

Status error Cause
400 collection_too_large An unpaged expand exceeds IG_WORKSPACE_EXPAND_UNPAGED_MAX for one collection, or any expansion exceeds IG_WORKSPACE_EXPAND_MAX_IRIS. The body adds collection and limit.
400 invalid_cursor Malformed cursor, or a cursor issued for another workspace
400 bad_request limit out of range or an unparseable body
{
  "error": "collection_too_large",
  "message": "collection exceeds 1000 document references; page the expansion or narrow the workspace collection",
  "collection": "https://id.indentia.ai/sources/tenant-a/sharepoint",
  "limit": 1000
}

Projection-Backed Endpoints — /sql, /lpg/query, /algo/{name}, /locy/query

POST /sql, POST /lpg/query, POST /algo/{name} and POST /locy/query read the embedded SurrealKV projection of the RDF data. All four require Admin permission. While that projection is being (re)built, or after its repair has been parked, they fail closed with 503 instead of reading a half-built mirror:

{
  "error": "projection_not_ready",
  "message": "projection mirror not ready: the SurrealKV projection is being (re)built from the authoritative dataset; retry later",
  "projection_repair_parked": false,
  "retryable": true
}

In cluster mode a follower redirects POST /sql to the leader (307) before checking its own projection, so a certified leader serves the request even while the follower is still rebuilding. (/sql wraps the JSON above in the message of a standard service_unavailable error.)

After a restart the projection resumes from a certified watermark and replays only the missed changes; a full rebuild is the fallback. If IG_PROJECTION_MAX_FULL_REBUILDS consecutive full rebuilds fail (default 3; 0 never parks), the repair is parked: the projection stays unavailable (projection_repair_parked: true, retryable: false) until an administrator fixes the cause and resumes it, or the node restarts.

POST /admin/projection/resume

curl -u root:changeme -X POST http://localhost:7001/admin/projection/resume
{ "resumed": true, "was_parked": true, "projection_ready": false }

Requires Admin permission. The next repair cycle runs a full pass with a fresh failure budget; projection_ready turns true once it is certified. Nodes without an embedded projection answer 503 embedded_surreal_not_configured.

Production profile

With IG_PRODUCTION=true, /sql is disabled (403) and /lpg/query, /algo/{name} and /locy/query are refused (409), because these local projections cannot yet prove they are up to date with the replicated log.


GraphQL — POST /graphql

Execute GraphQL queries and mutations against the IndentiaDB schema.

Query Example

curl -u root:changeme \
  -X POST http://localhost:7001/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "{ article { id title author } }"
  }'

Response:

{
  "data": {
    "article": [
      { "id": "article:1", "title": "Introduction to Graph Databases", "author": "Alice" }
    ]
  }
}

Mutation Example

curl -u root:changeme \
  -X POST http://localhost:7001/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation CreateArticle($title: String!, $author: String!) { createArticle(data: {title: $title, author: $author}) { id title } }",
    "variables": { "title": "GraphQL in IndentiaDB", "author": "Bob" }
  }'

Variable Substitution

Variables are passed in the variables field of the request body:

curl -u root:changeme \
  -X POST http://localhost:7001/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query GetArticle($id: ID!) { article(id: $id) { title content } }",
    "variables": { "id": "article:1" }
  }'

Schema Introspection

curl -u root:changeme \
  -X POST http://localhost:7001/graphql \
  -H "Content-Type: application/json" \
  -d '{ "query": "{ __schema { types { name } } }" }'

Error Format

GraphQL errors are returned alongside partial data using the standard GraphQL error structure:

{
  "data": null,
  "errors": [
    {
      "message": "Field 'nonexistent' does not exist on type 'article'",
      "locations": [{ "line": 1, "column": 3 }],
      "path": ["article", "nonexistent"]
    }
  ]
}

SHACL Validation — POST /shacl/validate

Validate RDF data against SHACL (Shapes Constraint Language) shapes.

Request

Send a JSON body with a shapes field containing the SHACL shapes as a Turtle string. The validation is run against the current contents of the triple store.

curl -u root:changeme \
  -X POST http://localhost:7001/shacl/validate \
  -H "Content-Type: application/json" \
  -d '{
    "shapes": "@prefix sh: <http://www.w3.org/ns/shacl#> .\n@prefix ex: <http://example.org/> .\n@prefix foaf: <http://xmlns.com/foaf/0.1/> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n\nex:PersonShape\n    a sh:NodeShape ;\n    sh:targetClass ex:Person ;\n    sh:property [ sh:path foaf:name ; sh:minCount 1 ; sh:datatype xsd:string ] ."
  }'

Response — Conforms

When the data satisfies all shapes:

{
  "conforms": true,
  "results": [],
  "stats": {
    "focus_nodes_validated": 2,
    "constraints_checked": 4,
    "violations": 0,
    "warnings": 0,
    "infos": 0,
    "duration_ms": 3
  }
}

Response — Violation

When constraints are violated, each violation is reported with its focus node, path, severity, and message:

{
  "conforms": false,
  "results": [
    {
      "focusNode": "http://example.org/alice",
      "resultPath": "http://xmlns.com/foaf/0.1/name",
      "severity": "http://www.w3.org/ns/shacl#Violation",
      "message": "Every Person must have exactly one foaf:name of type xsd:string",
      "sourceShape": "http://example.org/PersonShape",
      "sourceConstraintComponent": "http://www.w3.org/ns/shacl#MinCountConstraintComponent"
    }
  ]
}

Severity Levels

Severity IRI Meaning
sh:Violation Hard constraint failure
sh:Warning Advisory constraint
sh:Info Informational constraint

WebSocket — /ws

The WebSocket endpoint provides real-time streaming for LIVE SELECT queries. Connect over ws:// (or wss:// for TLS) and send JSON messages.

Connection

ws://localhost:7001/ws

Authentication credentials must be included as query parameters or in the initial handshake message:

ws://root:changeme@localhost:7001/ws

LIVE SELECT Subscription

Send a JSON message to subscribe to live changes on a table or query:

{
  "id": "sub-001",
  "method": "query",
  "params": ["LIVE SELECT * FROM article WHERE status = 'published'"]
}

The server responds with a subscription UUID:

{
  "id": "sub-001",
  "result": "550e8400-e29b-41d4-a716-446655440000"
}

CREATE Notification

When a new record is created that matches the live query:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "action": "CREATE",
  "result": {
    "id": "article:5",
    "title": "New Article",
    "status": "published"
  }
}

UPDATE Notification

When a matching record is updated:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "action": "UPDATE",
  "result": {
    "id": "article:5",
    "title": "Updated Title",
    "status": "published"
  }
}

DELETE Notification

When a matching record is deleted:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "action": "DELETE",
  "result": {
    "id": "article:5"
  }
}

KILL — Unsubscribe

Send a KILL message with the subscription UUID to stop receiving notifications:

{
  "id": "kill-001",
  "method": "kill",
  "params": ["550e8400-e29b-41d4-a716-446655440000"]
}

JavaScript Example

const ws = new WebSocket('ws://localhost:7001/ws');

ws.onopen = () => {
  // Subscribe to live changes
  ws.send(JSON.stringify({
    id: 'sub-1',
    method: 'query',
    params: ['LIVE SELECT * FROM sensor_reading WHERE value > 90']
  }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  if (msg.id === 'sub-1') {
    // Store the subscription UUID for later use
    const subscriptionId = msg.result;
    console.log('Subscribed:', subscriptionId);
    return;
  }

  // Handle live notifications
  console.log('Live event:', msg.action, msg.result);

  // Unsubscribe after receiving 10 events
  if (eventCount++ >= 10) {
    ws.send(JSON.stringify({
      id: 'kill-1',
      method: 'kill',
      params: [msg.id]
    }));
  }
};

ws.onerror = (err) => console.error('WebSocket error:', err);
ws.onclose = () => console.log('Disconnected');

Python Example

import asyncio
import json
import websockets

async def live_select():
    uri = "ws://localhost:7001/ws"

    async with websockets.connect(uri) as ws:
        # Subscribe to live changes
        await ws.send(json.dumps({
            "id": "sub-1",
            "method": "query",
            "params": ["LIVE SELECT * FROM orders WHERE status = 'pending'"]
        }))

        # Get subscription UUID
        response = json.loads(await ws.recv())
        subscription_id = response["result"]
        print(f"Subscribed with ID: {subscription_id}")

        # Process live events
        async for message in ws:
            event = json.loads(message)
            print(f"Action: {event['action']}, Data: {event['result']}")

            # Kill subscription after first event
            await ws.send(json.dumps({
                "id": "kill-1",
                "method": "kill",
                "params": [subscription_id]
            }))
            break

asyncio.run(live_select())

Health Endpoints

IndentiaDB exposes four health endpoints with a deliberate split: the public probes reveal only an aggregate status, while the full cluster evidence lives behind authentication.

Endpoint Auth Purpose
GET /live public Process liveness — always {"status": "alive"} (200) while the process accepts connections. Kubernetes livenessProbe / startupProbe.
GET /ready public Traffic readiness — {"status": "ready"} (200) or {"status": "not_ready"} (503). Kubernetes readinessProbe.
GET /health public Minimal status probe.
GET /health/details Admin permission Full node and cluster evidence.

GET /health — minimal public probe

curl http://localhost:7001/health
{ "status": "healthy" }

HTTP 200 when healthy, 503 otherwise. status is healthy, starting, or unhealthy. Node identities, addresses, Raft terms, replication progress, and shard topology are deliberately not exposed on the public probe.

Changed behavior

Older releases returned the full cluster document on /health. That detail — cluster members, replication lag, ha_ready, sharding layout — has moved to the authenticated /health/details endpoint. Unauthenticated callers of /health/details receive 401.

GET /health/details — authenticated HA evidence

curl -H "Authorization: Bearer $TOKEN" http://localhost:7001/health/details
{
  "status": "healthy",
  "role": "leader",
  "node_id": 1,
  "snapshot_index": 120,
  "current_term": 7,
  "cluster_members": [
    { "node_id": 1, "address": "node-1:7001", "role": "leader",   "reachable": true },
    { "node_id": 2, "address": "node-2:7001", "role": "follower", "reachable": true },
    { "node_id": 3, "address": "node-3:7001", "role": "follower", "reachable": true }
  ],
  "replication_lag": null,
  "index_loaded": true,
  "uptime_seconds": 86400,
  "cluster_enabled": true,
  "ha_ready": true,
  "meta_voters": 3,
  "meta_failure_margin": 1,
  "sharding": {
    "num_shards": 1,
    "routing_num_shards": 1,
    "routing_factor": 1,
    "replicas_per_shard": 2,
    "meta_replicas": 3
  }
}

Notable fields:

Field Meaning
role leader, follower, candidate, or standalone
cluster_enabled true when clustered; false standalone (no replication)
ha_ready Leader-local HA proof — true only when this node itself holds fresh quorum-acknowledged leadership evidence. Followers do not echo their leader's claim; standalone nodes always report false.
ha_reason When not HA-ready: the first failed invariant, e.g. insufficient_voters, no_current_leader, local_node_not_leader, stale_quorum_ack
meta_voters / meta_failure_margin Exact Raft voter count and how many voter failures still leave a strict majority
replication_lag Follower lag behind the leader in log entries (null on leader/standalone)
sharding Shard layout — omitted entirely in standalone mode

Metrics — GET /metrics

Returns Prometheus-compatible metrics in the text exposition format.

curl -u root:changeme http://localhost:7001/metrics

Sample Response

# HELP indentiagraph_indentiagraph_queries_total Total number of queries executed
# TYPE indentiagraph_indentiagraph_queries_total counter
indentiagraph_indentiagraph_queries_total 15234

# HELP indentiagraph_indentiagraph_query_duration_seconds Query execution time
# TYPE indentiagraph_indentiagraph_query_duration_seconds histogram
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.005"} 12000
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.01"} 14500
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.025"} 15100
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="0.05"} 15200
indentiagraph_indentiagraph_query_duration_seconds_bucket{le="+Inf"} 15234
indentiagraph_indentiagraph_query_duration_seconds_sum 45.2
indentiagraph_indentiagraph_query_duration_seconds_count 15234

# HELP indentiagraph_indentiagraph_cluster_size Number of cluster members
# TYPE indentiagraph_indentiagraph_cluster_size gauge
indentiagraph_indentiagraph_cluster_size 1

# HELP indentiagraph_indentiagraph_is_leader Whether this node is the Raft leader
# TYPE indentiagraph_indentiagraph_is_leader gauge
indentiagraph_indentiagraph_is_leader 1

Key Metrics

Metric Type Description
indentiagraph_indentiagraph_queries_total Counter Total queries executed
indentiagraph_indentiagraph_query_duration_seconds Histogram Query execution latency distribution
indentiagraph_indentiagraph_cluster_size Gauge Number of cluster members
indentiagraph_indentiagraph_is_leader Gauge 1 if this node is the Raft leader, 0 otherwise
indentiagraph_indentiagraph_leader_elections_total Counter Total Raft leader elections

Geospatial — POST /geospatial/evaluate

Evaluate GeoSPARQL functions for geographic data.

curl -u root:changeme \
  -X POST http://localhost:7001/geospatial/evaluate \
  -H "Content-Type: application/json" \
  -d '{
    "function": "geof:distance",
    "args": [
      "POINT(4.9041 52.3676)",
      "POINT(13.4050 52.5200)",
      "http://www.opengis.net/def/uom/OGC/1.0/kilometre"
    ]
  }'

Alerts — GET / POST / DELETE /alerts

Manage threshold-based alerts on query results.

List Alerts

curl -u root:changeme http://localhost:7001/alerts

Create Alert

curl -u root:changeme \
  -X POST http://localhost:7001/alerts \
  -H "Content-Type: application/json" \
  -d '{
    "name": "high-latency",
    "query": "SELECT count() FROM requests WHERE latency_ms > 1000 GROUP ALL",
    "condition": "result > 0",
    "interval_seconds": 60,
    "webhook": "https://hooks.example.com/alert"
  }'

Delete Alert

curl -u root:changeme \
  -X DELETE http://localhost:7001/alerts/high-latency

Holonic Four-Graph — /holonic/*

Native implementation of Cagle's holonic model on named graphs. The router is mounted only when the server is started with [holonic] enabled=true (see the Holonic feature page). /holonic/health is always mounted and is in the authentication bypass list so orchestrators and probes can poll it without credentials.

Readiness — GET /holonic/health

curl -s http://localhost:7001/holonic/health

Response shapes:

Status Body Meaning
200 {"available": true, "default_tenant": "acme"} Service attached, ready to accept CRUD
503 Holonic service not configured (enable via AppState::enable_holonic) Feature not enabled on this server

Holon CRUD — POST/GET /holonic/holons

# Declare a holon in the tenant registry graph
curl -u root:changeme \
  -X POST "http://localhost:7001/holonic/holons?tenant=acme" \
  -H 'Content-Type: application/json' \
  -d '{"iri":"https://id.indentia.ai/identity/person/acme/leon",
       "label":"Leon de Vries",
       "member_of":"https://id.indentia.ai/identity/org/acme/indentia"}'

# List every holon in the tenant
curl -u root:changeme \
  "http://localhost:7001/holonic/holons?tenant=acme"

# Get one holon's detail including every layer
curl -u root:changeme \
  "http://localhost:7001/holonic/holons/<url-encoded-iri>?tenant=acme"

Layer writes

Four layer endpoints share the same body shape: {"turtle": "<valid Turtle>", "graph_slug": "optional-slug"}. The server parses @prefix directives via oxttl and emits canonical N-Triples into the target named graph.

Layer Endpoint Typical use
Interior POST /holonic/holons/{iri}/interior A-Box facts from a specific source (CRM, LDAP, WhatsApp)
Boundary POST /holonic/holons/{iri}/boundary SHACL shape acting as membrane
Context POST /holonic/holons/{iri}/context Membership + PROV-O events
Projection POST /holonic/holons/{iri}/projection Derived views

Portals — POST/GET /holonic/portals

Portals encode cross-holon transforms as RDF rather than code. A cga:TransformPortal triple in the source holon's boundary graph declares target + a SPARQL CONSTRUCT that translates the source into the target's schema.

# Register a portal
curl -u root:changeme \
  -X POST "http://localhost:7001/holonic/portals?tenant=acme" \
  -H 'Content-Type: application/json' \
  -d '{
    "iri": "urn:portal:leon-to-crm",
    "source": "https://id.indentia.ai/identity/person/acme/leon",
    "target": "https://id.indentia.ai/identity/person/crm/leon",
    "construct_query": "CONSTRUCT { ?s <http://crm/worksAt> ?o } WHERE { ?s <http://indentia.ai/core#worksAt> ?o }"
  }'

# List outbound portals from a holon
curl -u root:changeme \
  "http://localhost:7001/holonic/portals?tenant=acme&source=<url-encoded-source-iri>"

Authentication + Authorization

When an AuthenticationService is attached to the server:

Method Required Status when missing
Anonymous on any /holonic/* Authentication 401 Unauthorized
GET, HEAD Permission::Read 403 Forbidden
POST, PUT, PATCH, DELETE Permission::Write 403 Forbidden
Write without AuthorizationService (but authn enabled) Configured authz 403 Forbidden

Cross-tenant ?tenant= values that do not match the actor's org_scope.org_path slug are logged under the holonic.authz tracing target but currently not blocked — blocking requires a canonical tenant claim on Actor which is a follow-up ADR.

Caveats

  • Cluster mode is refused at startup — holonic writes bypass the Raft-routed UpdateExecutor path, which would diverge from followers. Configure standalone mode for holonic ingest.
  • SPARQL INSERT DATA cannot carry @prefix — the store's custom parse_into decodes Turtle to N-Triples first.
  • The /holonic/health probe always responds, even when holonic is disabled (503). Rely on status codes, not the presence of the route, to distinguish "not mounted" from "not configured".

See Holonic Four-Graph Layer for the conceptual model and deployment pattern.