API Reference
Complete REST API endpoint documentation
The Nabu Store REST API exposes three services — BlobService, ClusterService, and InternalService — over HTTP/JSON (transcoded from gRPC). Use BlobService to store, retrieve, list, and delete binary objects (blobs) in your AI inference pipeline. Use ClusterService to manage cluster membership, monitor topology, and synchronize the consistent-hash ring. InternalService handles node-to-node replication and shard operations and is not intended for direct client use.
BlobService
PUT /v1/blobs — Store a blob
| Parameter | Type | Required | Description |
|---|---|---|---|
data | bytes (base64 in JSON) | Yes | Raw binary content of the blob to store. |
policy | string (enum) | No | Replication policy to apply. One of REPLICATION_POLICY_NONE, REPLICATION_POLICY_REPLICA2, REPLICATION_POLICY_REPLICA3, REPLICATION_POLICY_EC42, REPLICATION_POLICY_EC82. Defaults to REPLICATION_POLICY_REPLICA3 when unset or REPLICATION_POLICY_UNSPECIFIED. |
labels | map<string, string> | No | Arbitrary key-value metadata attached to the blob at write time. |
POST /v1/blobs/stream — Store a blob via client-side streaming (PutStream)
Each chunk in the stream is a PutChunk message:
| Field | Type | Required | Description |
|---|---|---|---|
id | BlobID | No | Blob identifier. Omit on the first chunk; the server assigns it. |
data | bytes | Yes | Chunk payload. |
final | bool | Yes | Set true on the last chunk to signal end-of-stream. |
policy | string (enum) | No | Replication policy, same values as PUT /v1/blobs. Include on the first chunk. |
GET /v1/blobs/{id} — Retrieve a blob
| Parameter | Type | Required | Description |
|---|---|---|---|
id | bytes (hex or base64 in path) | Yes | 16-byte blob identifier returned by a previous Put. |
GET /v1/blobs/{id}/stream — Retrieve a blob via server-side streaming (GetStream)
| Parameter | Type | Required | Description |
|---|---|---|---|
id | bytes | Yes | 16-byte blob identifier. |
DELETE /v1/blobs/{id} — Delete a blob
| Parameter | Type | Required | Description |
|---|---|---|---|
id | bytes | Yes | 16-byte blob identifier. |
GET /v1/blobs/{id}/stat — Retrieve blob metadata
| Parameter | Type | Required | Description |
|---|---|---|---|
id | bytes | Yes | 16-byte blob identifier. |
GET /v1/blobs — List blobs
| Parameter | Type | Required | Description |
|---|---|---|---|
cursor | bytes (base64) | No | Opaque pagination cursor returned in a previous ListResponse.next_cursor. Omit to start from the beginning. |
limit | int32 | No | Maximum number of blob IDs to return per page. Defaults to 100 when omitted or <= 0. |
prefix | string | No | Return only blob IDs whose string representation begins with this prefix. |
ClusterService
POST /v1/cluster/join — Add a node to the cluster
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id | string | Yes | Unique identifier for the joining node. |
address | string | Yes | Reachable network address (host:port) of the joining node. |
capacity_bytes | uint64 | Yes | Total storage capacity in bytes that this node contributes. |
POST /v1/cluster/leave — Remove a node from the cluster
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id | string | Yes | Identifier of the node that is leaving. |
graceful | bool | No | If true, the node is marked as leaving and data migration is initiated before removal. If false or omitted, the node is removed immediately. |
POST /v1/cluster/heartbeat — Send a node heartbeat
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id | string | Yes | Identifier of the reporting node. |
used_bytes | uint64 | Yes | Current storage usage in bytes on the reporting node. |
blob_count | uint64 | Yes | Number of blobs stored on the reporting node. |
ring_version | int64 | Yes | The consistent-hash ring version the node currently holds. Used to detect stale topology. |
GET /v1/cluster/state — Get cluster topology
No request parameters.
POST /v1/cluster/sync-ring — Synchronize the hash ring
| Parameter | Type | Required | Description |
|---|---|---|---|
ring_version | int64 | Yes | The ring version the caller wants to apply. The server updates its ring only if this version is newer than its own. |
BlobService
PUT /v1/blobs → PutResponse
Returns on successful write:
{
"id": { "id": "<base64-encoded 16-byte blob ID>" },
"size": 204800
}
| Field | Type | Description |
|---|---|---|
id.id | bytes | Assigned 16-byte blob identifier, derived from a content hash. |
size | int64 | Number of bytes stored. |
POST /v1/blobs/stream → PutResponse
Same shape as PutResponse above, returned after the server receives a chunk with final: true.
GET /v1/blobs/{id} → GetResponse
{
"data": "<base64-encoded blob bytes>",
"meta": {
"size": 204800,
"created_at": 1718000000000000000,
"modified_at": 1718000000000000000,
"checksum": "<base64-encoded checksum>",
"labels": { "model": "llama3", "tenant": "acme" }
}
}
If the blob is not found locally, the server transparently attempts retrieval from other cluster nodes before returning an error.
GET /v1/blobs/{id}/stream — server-streaming GetChunk messages
Each message contains:
| Field | Type | Description |
|---|---|---|
data | bytes | Chunk payload (up to 1 MB per chunk). |
final | bool | true on the last chunk. |
DELETE /v1/blobs/{id} → DeleteResponse
{ "deleted": true }
deleted is true whether or not the blob existed locally; the server removes it from the index regardless.
GET /v1/blobs/{id}/stat → StatResponse
{
"exists": true,
"meta": {
"size": 204800,
"created_at": 1718000000000000000,
"modified_at": 1718000000000000000,
"checksum": "<base64-encoded checksum>",
"labels": {}
}
}
If the blob does not exist, exists is false and meta is omitted.
GET /v1/blobs → ListResponse
{
"ids": [
{ "id": "<base64-encoded blob ID>" }
],
"next_cursor": "<base64-encoded opaque cursor>",
"has_more": true
}
next_cursor is present only when has_more is true. Pass it as cursor in your next request to retrieve the following page.
ClusterService
POST /v1/cluster/join → JoinResponse
{
"accepted": true,
"ring_version": 4,
"nodes": [
{
"node_id": "node-1",
"address": "10.0.0.1:7000",
"state": "active",
"capacity_bytes": 107374182400,
"used_bytes": 5368709120,
"blob_count": 1024
}
]
}
| Field | Type | Description |
|---|---|---|
accepted | bool | true if the node was admitted (including if it was already a member). |
ring_version | int64 | Current version of the consistent-hash ring after the join. |
nodes | NodeInfo[] | Full list of cluster members at the time of the join. |
POST /v1/cluster/leave → LeaveResponse
{ "acknowledged": true }
POST /v1/cluster/heartbeat → HeartbeatResponse
{
"ok": true,
"ring_version": 4,
"nodes": []
}
nodes is populated only when the caller's ring_version is behind the server's current version, giving the caller everything it needs to converge in a single round-trip. If the node is not recognized, ok is false and the server instructs the caller to rejoin.
GET /v1/cluster/state → GetClusterStateResponse
{
"nodes": [
{
"node_id": "node-1",
"address": "10.0.0.1:7000",
"state": "active",
"capacity_bytes": 107374182400,
"used_bytes": 5368709120,
"blob_count": 1024
}
],
"ring_version": 4
}
POST /v1/cluster/sync-ring → SyncRingResponse
{
"ring_version": 5,
"node_ids": ["node-1", "node-2", "node-3"],
"vnodes_per_node": 150
}
All errors are returned as standard HTTP status codes with a JSON error body:
{
"code": <grpc-status-code>,
"message": "<human-readable description>"
}
BlobService errors
| HTTP Status | gRPC Code | Condition |
|---|---|---|
400 Bad Request | INVALID_ARGUMENT | id field is missing, empty, or not exactly 16 bytes. Applies to Get, Delete, Stat, and both stream variants. |
404 Not Found | NOT_FOUND | Blob does not exist on the local node and could not be retrieved from any other cluster node (Get, GetStream). |
500 Internal Server Error | INTERNAL | Backend storage failure during Put, Get, or Delete. |
501 Not Implemented | UNIMPLEMENTED | The called method has no registered handler (returned by UnimplementedBlobServiceServer). |
500 Internal Server Error | INTERNAL | PutStream received no header chunk before EOF. |
ClusterService errors
| HTTP Status | gRPC Code | Condition |
|---|---|---|
404 Not Found | NOT_FOUND | Heartbeat received from a node_id that is not in the cluster. The response body includes commands: ["rejoin"]. |
500 Internal Server Error | INTERNAL | Unexpected failure during ring manipulation or state persistence. |
501 Not Implemented | UNIMPLEMENTED | The called method has no registered handler. |
General errors
| HTTP Status | gRPC Code | Condition |
|---|---|---|
401 Unauthorized | UNAUTHENTICATED | Request is missing a valid bearer token. |
403 Forbidden | PERMISSION_DENIED | Token is valid but the caller lacks permission for this operation. |
503 Service Unavailable | UNAVAILABLE | The target node is temporarily unreachable or restarting. Retry with exponential back-off. |
Store a blob
Store a small binary payload with a REPLICA3 policy and a custom label.
curl -X PUT https://nabu.example.com/v1/blobs \
-H "Authorization: Bearer $NABU_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": "SGVsbG8gTmFidSBTdG9yZSE=",
"policy": "REPLICATION_POLICY_REPLICA3",
"labels": {
"model": "llama3",
"tenant": "acme"
}
}'
Expected response (200 OK)
{
"id": { "id": "AQIDBAUGBwgJCgsMDQ4P" },
"size": 16
}
Save the id.id value — you need it for all subsequent operations on this blob.
Retrieve a blob
Fetch the full blob content and its metadata by ID.
curl -X GET https://nabu.example.com/v1/blobs/AQIDBAUGB wgJCgsMDQ4P \
-H "Authorization: Bearer $NABU_TOKEN"
Expected response (200 OK)
{
"data": "SGVsbG8gTmFidSBTdG9yZSE=",
"meta": {
"size": 16,
"created_at": 1718000000000000000,
"modified_at": 1718000000000000000,
"checksum": "c2hhMjU2aGFzaA==",
"labels": {
"model": "llama3",
"tenant": "acme"
}
}
}
Inspect blob metadata without downloading content
Use stat when you need to check existence or read labels without transferring the payload.
curl -X GET https://nabu.example.com/v1/blobs/AQIDBAUGB wgJCgsMDQ4P/stat \
-H "Authorization: Bearer $NABU_TOKEN"
Expected response — blob exists (200 OK)
{
"exists": true,
"meta": {
"size": 16,
"created_at": 1718000000000000000,
"modified_at": 1718000000000000000,
"checksum": "c2hhMjU2aGFzaA==",
"labels": { "model": "llama3" }
}
}
Expected response — blob not found (200 OK)
{ "exists": false }
List blobs with pagination
Retrieve the first page of up to 50 blob IDs.
curl -X GET "https://nabu.example.com/v1/blobs?limit=50" \
-H "Authorization: Bearer $NABU_TOKEN"
Expected response (200 OK)
{
"ids": [
{ "id": "AQIDBAUGB wgJCgsMDQ4P" },
{ "id": "EBESExQVFhcYGRobHB0e" }
],
"next_cursor": "HyAhIiMkJSYn",
"has_more": true
}
Fetch the next page by passing next_cursor as cursor:
curl -X GET "https://nabu.example.com/v1/blobs?limit=50&cursor=HyAhIiMkJSYn" \
-H "Authorization: Bearer $NABU_TOKEN"
Delete a blob
curl -X DELETE https://nabu.example.com/v1/blobs/AQIDBAUGB wgJCgsMDQ4P \
-H "Authorization: Bearer $NABU_TOKEN"
Expected response (200 OK)
{ "deleted": true }
Join a new node to the cluster
Call this from the new node (or your orchestration layer) after the node process is running.
curl -X POST https://nabu.example.com/v1/cluster/join \
-H "Authorization: Bearer $NABU_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"node_id": "node-4",
"address": "10.0.0.4:7000",
"capacity_bytes": 107374182400
}'
Expected response (200 OK)
{
"accepted": true,
"ring_version": 5,
"nodes": [
{ "node_id": "node-1", "address": "10.0.0.1:7000", "state": "active", "capacity_bytes": 107374182400, "used_bytes": 5368709120, "blob_count": 1024 },
{ "node_id": "node-4", "address": "10.0.0.4:7000", "state": "active", "capacity_bytes": 107374182400, "used_bytes": 0, "blob_count": 0 }
]
}
Get cluster state
Poll this endpoint from your monitoring system or health dashboard.
curl -X GET https://nabu.example.com/v1/cluster/state \
-H "Authorization: Bearer $NABU_TOKEN"
Expected response (200 OK)
{
"nodes": [
{ "node_id": "node-1", "address": "10.0.0.1:7000", "state": "active", "capacity_bytes": 107374182400, "used_bytes": 5368709120, "blob_count": 1024 },
{ "node_id": "node-2", "address": "10.0.0.2:7000", "state": "active", "capacity_bytes": 107374182400, "used_bytes": 4831838208, "blob_count": 987 },
{ "node_id": "node-3", "address": "10.0.0.3:7000", "state": "offline", "capacity_bytes": 107374182400, "used_bytes": 0, "blob_count": 0 }
],
"ring_version": 5
}
Graceful node removal
Mark a node as leaving so the cluster migrates its data before removing it from the ring.
curl -X POST https://nabu.example.com/v1/cluster/leave \
-H "Authorization: Bearer $NABU_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "node_id": "node-3", "graceful": true }'
Expected response (200 OK)
{ "acknowledged": true }
Poll GET /v1/cluster/state until node-3 no longer appears or its state transitions to leaving and then disappears from the list.
Blob IDs
Blob IDs are 16-byte content-addressed identifiers derived from a hash of the uploaded data. Storing the same bytes twice returns the same ID — the second write is a no-op if the blob already exists on the backend. There is no separate "update" operation; to change a blob's content you must store new bytes (which produces a new ID) and delete the old one.
Replication policies
| Policy enum value | Meaning |
|---|---|
REPLICATION_POLICY_UNSPECIFIED | Treated identically to REPLICATION_POLICY_REPLICA3 by the server. Avoid relying on this behavior — always specify an explicit policy. |
REPLICATION_POLICY_NONE | No redundancy. Use only for ephemeral or easily re-generated data. |
REPLICATION_POLICY_REPLICA2 | Two full copies across two different nodes. |
REPLICATION_POLICY_REPLICA3 | Three full copies. Default for most workloads. |
REPLICATION_POLICY_EC42 | Erasure coding, 4 data shards + 2 parity shards. Requires at least 6 nodes. |
REPLICATION_POLICY_EC82 | Erasure coding, 8 data shards + 2 parity shards. Requires at least 10 nodes; highest storage efficiency. |
The policy is set at write time and is stored in blob metadata. You cannot change a blob's replication policy after it has been stored — delete and re-upload with the new policy instead.
Streaming endpoints
PutStreamis a client-streaming RPC. Send chunks in order, setfinal: trueon the last chunk, then close the send stream. The server responds once with a singlePutResponse. Do not interleave chunks from different blobs in a single stream.GetStreamis a server-streaming RPC. The server sends chunks of up to 1 MB each. The last chunk hasfinal: true. Buffer or pipe the chunks in arrival order.- Use streaming variants for blobs larger than a few MB to avoid holding large payloads in memory.
Pagination
The cursor in ListRequest and next_cursor in ListResponse are opaque server-generated values. Do not attempt to parse or construct them manually. A cursor is valid only for the node that issued it.
Heartbeat and cluster convergence
Each node must send a Heartbeat to the leader (or any peer, depending on your deployment topology) at regular intervals. If a node's ring_version in the heartbeat is behind the server's current version, the heartbeat response includes the full nodes list so the caller can catch up within a single round-trip — no separate SyncRing call is needed during normal operation.
Replication is best-effort on Put
A successful PutResponse confirms the blob is durably stored on the receiving node and that replication to peer nodes was attempted. Replication failures are logged as warnings but do not cause the Put to fail. Poll GET /v1/cluster/state and your monitoring stack to verify replica health independently.
Delete propagation
As of the current release, DELETE /v1/blobs/{id} removes the blob from the local node and its index only. Propagation to replica nodes is not yet implemented. To ensure full removal across the cluster, issue the DELETE request to each node or implement a reconciliation loop in your application layer.
InternalService is node-to-node only
The InternalService endpoints (Replicate, ReplicateStream, DeleteInternal, FetchShard, StoreShard, Migrate) are used exclusively for intra-cluster communication. Do not call these endpoints from your application code. They are not authenticated with user-facing credentials and their request/response shapes may change without notice.
Timestamps
created_at and modified_at in BlobMeta are Unix nanosecond timestamps (int64). Divide by 1e9 to convert to seconds.
