REST API Reference¶
Base URL: http://localhost:7540
All request and response bodies are JSON. All endpoints return Content-Type: application/json.
Authentication¶
Every endpoint except the health probes requires a bearer token:
Tokens are configured under auth.tokens, or supplied as CRYSTAL_AUTH_TOKEN.
Authentication is on by default, and the server refuses to start with it
enabled and no tokens configured. To run without it — only sensible on a trusted
loopback interface — set auth.enabled: false explicitly.
Tokens carry one of two scopes:
| Scope | Grants |
|---|---|
read |
GET endpoints, plus POST /api/v1/query |
write |
everything read grants, plus all mutations |
POST /api/v1/query is read-scoped despite being a POST: it reads, and only
uses a body to carry the query.
A request with no credential, or one whose scope is insufficient, gets:
The response deliberately does not distinguish "no token", "unknown token" and "insufficient scope" — a client cannot use the difference, and an attacker can.
CORS¶
Cross-origin requests are refused unless the origin is listed in
cors.allowed_origins (or CRYSTAL_CORS_ORIGINS). The list is empty by
default. A wildcard is rejected at startup while auth is enabled.
Request limits¶
Request bodies are capped at server.max_body_bytes (4 MiB by default).
Oversized requests are rejected before the body is buffered.
Health¶
Three endpoints, with different jobs. Liveness and readiness are public; diagnostics is not.
GET /livez¶
Liveness: is the process running and serving HTTP. Does not touch storage.
A liveness failure means "restart me", and restarting does not fix a full disk or a missing data directory — it turns one problem into a crash loop. That is why this probe deliberately ignores storage health.
Response 200:
GET /readyz¶
Readiness: should this instance receive traffic. Probes the store.
Also served at GET /health and GET /api/v1/health for compatibility — a
container healthcheck wants readiness, not liveness.
Response 200:
Response 503:
The reason is written to the server log, not the response: this endpoint is unauthenticated and the failure detail names filesystem paths.
GET /api/v1/diagnostics¶
Runtime detail for operators. Requires a token (read scope) — Go version, goroutine count and memory profile are reconnaissance, not health.
Response 200:
{
"status": "ready",
"uptime": "1h2m3s",
"go_version": "go1.26.5",
"goroutines": 5,
"num_cpu": 8,
"storage": {
"vertex_count": 1200,
"edge_count": 8400,
"storage_type": "disk",
"size_bytes": 918273
},
"memory": {
"alloc_mb": 2,
"total_alloc_mb": 3,
"sys_mb": 12,
"num_gc": 4
}
}
Batch¶
POST /api/v1/batch¶
Applies several mutations atomically: all of them take effect, or none do.
Without this, every mutation commits independently, so a logical unit — "add a vertex and the three edges that give it meaning" — can half-apply on a crash or a mid-sequence error and leave edges pointing at a vertex that is not there.
Operations are put_vertex, put_edge, delete_vertex, delete_edge, applied
in order. Later operations see earlier ones, so a batch can create a vertex
and immediately attach an edge to it — set id on the create to reference it.
Request:
{
"ops": [
{ "op": "put_vertex", "id": "alice", "type": "Person", "properties": { "name": "Alice" } },
{ "op": "put_vertex", "id": "web1", "type": "Server" },
{ "op": "put_edge", "source_id": "alice", "target_id": "web1", "type": "accessed" }
]
}
Response 200:
{
"applied": 3,
"vertices": [ { "id": "alice", "...": "..." } ],
"edges": [ { "id": "0f3c...", "...": "..." } ]
}
Vertices and edges created without an explicit id come back with the generated
one.
Response 400 — the batch was rejected as a whole and nothing was applied: an unknown operation, a missing id, or an edge whose endpoint does not exist.
Atomicity holds across a crash as well as within the process: the batch is written to the WAL with a single fsync and only the last record carries a commit marker, so recovery discards a transaction whose marker never landed.
Graphs¶
One process can serve several named graphs. Every graph-scoped endpoint below is available in two forms:
| Form | Addresses |
|---|---|
/api/v1/vertices |
the default graph |
/api/v1/graphs/{name}/vertices |
the named graph |
The unnamespaced form is exactly what it always was, so an existing client keeps working untouched and a deployment that never creates a second graph sees no difference.
Graphs are fully isolated: a traversal, query or consistency check never crosses from one into another.
Names must match ^[a-z0-9][a-z0-9_-]{0,62}$. The restriction is deliberate — a
name becomes a directory name, so anything that could escape the data directory
is refused rather than sanitised.
GET /api/v1/graphs¶
{
"graphs": [
{ "name": "default", "default": true, "stats": { "vertex_count": 12, "edge_count": 30, "storage_type": "disk", "size_bytes": 4096 } },
{ "name": "case-2024-07", "default": false, "stats": { "vertex_count": 900, "edge_count": 4100, "storage_type": "disk", "size_bytes": 811008 } }
],
"default": "default"
}
POST /api/v1/graphs¶
201 on success. 400 for an invalid name, 409 if it exists, 507 if
storage.max_graphs is reached — that last one is not a bad request, it is the
server being out of room, and every graph is resident in full.
DELETE /api/v1/graphs/{name}¶
204 on success. Closes the graph and removes its data directory. The default graph cannot be deleted (400), because the unnamespaced routes depend on it.
Capacity is the sum of the graphs
Every graph a process serves is held in RAM in full, so N graphs cost the
sum of their sizes. storage.max_graphs (default 32) is what stops a create
API walking into the memory limit one namespace at a time. See
ADR 0001.
Replication¶
Read replicas follow a primary's transaction stream. The endpoints below are the
primary side; a follower is configured with --replica-of and consumes them.
The consistency model is asynchronous. A follower lags the primary by however long it takes to ship and apply the last transaction. Read-your-writes holds on the primary only. There is no consensus and no automatic failover — promoting a follower is an operator decision, because automatic promotion without consensus is how split-brain happens.
Replication requires the disk backend; the memory backend has no durable log and returns 501.
GET /api/v1/replication/status¶
lsn is the most recent committed transaction. snapshot_lsn is what the last
checkpoint covers — transactions at or below it are no longer in the log.
GET /api/v1/replication/snapshot¶
A full copy of the graph plus the LSN it describes, for a follower to bootstrap from. Taken inside a consistent view, so the copy and its LSN describe the same instant.
GET /api/v1/replication/stream?from={lsn}¶
Newline-delimited JSON. Sends every committed transaction after from, then
holds the connection open and sends more as they commit.
{"lsn":4211,"ops":[{"op":"put_vertex","vertex":{"id":"...","type":"Person"}}]}
{"lsn":4212,"ops":[{"op":"delete_edge","id":"..."}]}
If from is older than snapshot_lsn the requested transactions have been
discarded by a checkpoint and cannot be served. The stream reports this in-band
and the follower re-bootstraps:
Serving a partial range instead would be worse than refusing: the follower would apply a gap and believe it was caught up.
Schema¶
GET /api/v1/schema¶
Returns the registered property constraints. Read scope.
{
"vertices": {
"Person": {
"type_name": "Person",
"constraints": [
{ "name": "name", "type": 0, "required": true }
]
}
},
"edges": {}
}
Constraints are declared in the config file under schema.vertices and
schema.edges. A type with no schema is unconstrained. A write that violates
one is rejected with 400, and in a batch it rejects the whole batch.
Consistency¶
GET /api/v1/graph/consistency¶
Checks referential integrity over the loaded graph. Read scope.
The write path enforces these invariants, but a snapshot or WAL written by an older build is loaded verbatim — recovery reproduces what it is given rather than re-validating it. This is how you find damage that predates the enforcement.
{
"ok": false,
"summary": "1 problems across 2 vertices and 1 edges: 1 dangling edges, 0 orphaned adjacency entries, 0 missing adjacency entries",
"problems": 1,
"vertices_checked": 2,
"edges_checked": 1,
"dangling_edges": [ { "EdgeID": "e1", "Missing": "ghost", "Endpoint": "target" } ]
}
Three defect classes are reported:
| Class | Meaning |
|---|---|
dangling_edges |
An edge whose source or target vertex is absent |
orphaned_adjacency |
An index entry naming an edge or vertex that no longer exists |
missing_adjacency |
An edge that exists but is not indexed — invisible to traversal |
POST /api/v1/graph/repair¶
Drops dangling edges and rebuilds the adjacency index from the edge list, then checkpoints. Write scope. Returns the same shape, describing what was fixed.
Dangling edges are deleted rather than resurrected: the vertex they point at is gone and its properties are not recoverable, so an edge to it carries no meaning.
The same repair runs at startup with --repair. Without that flag a damaged
graph is reported and left alone — dropping edges is not something to do to
someone's data unasked.
Vertices¶
GET /api/v1/vertices¶
List all vertices. Optionally filter by type.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| type | string | Filter by vertex type |
Response 200:
[
{
"id": "a1b2c3...",
"type": "Person",
"labels": ["suspect"],
"properties": {"name": "Alice", "age": 30},
"created_at": "2026-03-05T12:00:00Z",
"updated_at": "2026-03-05T12:00:00Z"
}
]
POST /api/v1/vertices¶
Create a new vertex.
Request Body:
Response 201: Returns the created vertex with a generated ID.
GET /api/v1/vertices/:id¶
Get a vertex by ID.
Response 200: Returns the vertex object.
Response 404: {"error": "vertex not found"}
PUT /api/v1/vertices/:id¶
Update a vertex. Merges properties with existing values.
Request Body:
All fields are optional. Only provided fields are updated. Properties are merged, not replaced.
Response 200: Returns the updated vertex.
DELETE /api/v1/vertices/:id¶
Delete a vertex and all its connected edges.
Response 204: No content.
GET /api/v1/vertices/:id/edges¶
Get all edges connected to a vertex.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| direction | string | out (default), in, both |
Response 200: Returns an array of edge objects.
GET /api/v1/vertices/:id/neighbors¶
Get all neighboring vertices.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| direction | string | out (default), in, both |
Response 200: Returns an array of vertex objects.
Edges¶
GET /api/v1/edges¶
List all edges. Optionally filter by type.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| type | string | Filter by edge type |
POST /api/v1/edges¶
Create a new edge.
Request Body:
{
"source_id": "a1b2c3...",
"target_id": "d4e5f6...",
"type": "knows",
"properties": {"since": "2020"},
"directed": true,
"weight": 1.0
}
directed defaults to true. weight defaults to 1.0.
Both source_id and target_id must refer to existing vertices.
Response 201: Returns the created edge.
Response 400: {"error": "vertex not found"} if source or target does not exist.
GET /api/v1/edges/:id¶
Get an edge by ID.
PUT /api/v1/edges/:id¶
Update edge properties and/or weight.
Request Body:
DELETE /api/v1/edges/:id¶
Delete an edge.
Response 204: No content.
Graph Operations¶
GET /api/v1/graph/stats¶
Get graph statistics.
Response 200:
POST /api/v1/graph/flush¶
Force a flush of in-memory data to disk.
Response 200: {"status": "flushed"}
Query¶
POST /api/v1/query¶
Execute a query against the graph.
Request Body:
{
"type": "vertices",
"vertex_type": "Person",
"labels": ["suspect"],
"properties": {"city": "Berlin"},
"limit": 10,
"offset": 0
}
| Field | Type | Description |
|---|---|---|
| type | string | vertices, edges, or neighbors |
| vertex_type | string | Filter vertices by type |
| edge_type | string | Filter edges by type |
| labels | []string | Filter vertices that have all specified labels |
| properties | object | Filter by exact property matches |
| from_vertex | string | Starting vertex ID (required for neighbors type) |
| direction | string | out, in, both (for neighbors) |
| depth | int | Traversal depth (for neighbors, default 1) |
| limit | int | Maximum results |
| offset | int | Skip N results |
Response 200:
Traversal¶
GET /api/v1/traversal/bfs/:id¶
Breadth-first traversal from a starting vertex.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| max_depth | int | Maximum depth (default 10) |
| direction | string | out (default), in, both |
| edge_type | string | Filter by edge type |
Response 200: Array of vertices in BFS order.
GET /api/v1/traversal/dfs/:id¶
Depth-first traversal from a starting vertex. Same parameters as BFS.
GET /api/v1/traversal/shortest-path/:from/:to¶
Find the shortest path between two vertices.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| direction | string | out (default), in, both |
| edge_type | string | Filter by edge type |
Response 200:
Response 404: If no path exists.
GET /api/v1/traversal/all-paths/:from/:to¶
Find all paths between two vertices.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
| max_depth | int | Maximum path length (default 5) |
| direction | string | out (default), in, both |
| edge_type | string | Filter by edge type |
Response 200: Array of path objects, each containing vertices and edges.