Skip to content

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:

Authorization: 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:

401 Unauthorized
WWW-Authenticate: Bearer realm="crystal"

{"error": "unauthorized"}

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:

{ "status": "alive" }

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:

{ "status": "ready" }

Response 503:

{ "status": "not ready" }

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

{ "name": "case-2024-07" }

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": 4210, "snapshot_lsn": 4096 }

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:

{"error":"lsn_too_old","detail":"..."}

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:

{
  "type": "Person",
  "labels": ["suspect"],
  "properties": {"name": "Alice", "age": 30}
}

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:

{
  "type": "Person",
  "labels": ["suspect", "witness"],
  "properties": {"age": 31}
}

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:

{
  "properties": {"since": "2019"},
  "weight": 2.5
}

DELETE /api/v1/edges/:id

Delete an edge.

Response 204: No content.

Graph Operations

GET /api/v1/graph/stats

Get graph statistics.

Response 200:

{
  "vertex_count": 150,
  "edge_count": 420,
  "storage_type": "disk",
  "size_bytes": 65536
}

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:

{
  "vertices": [...],
  "edges": [...],
  "count": 5
}

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:

{
  "vertices": [...],
  "edges": [...]
}

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.