Skip to content

Use the native HTTP and WebSocket API

Nimbus speaks plain HTTP and WebSocket, so any language with an HTTP client can use it without an SDK or codegen. This guide walks through authenticating, scoping requests to a tenant, working with documents, and subscribing to live query results.

If you have not run a server yet, start with the self-host quickstart.

A server started with nimbus start protects its native API with a local admin token. Nimbus creates the token on first boot and stores it as a JSON file:

PlatformToken file
Linux~/.local/share/nimbus/auth/token
macOS~/Library/Application Support/nimbus/auth/token
Windows%LOCALAPPDATA%\nimbus\auth\token.json

Read the token field and export it for the rest of this guide:

Terminal window
# Linux
export NIMBUS_TOKEN=$(jq -r .token ~/.local/share/nimbus/auth/token)
# macOS
export NIMBUS_TOKEN=$(jq -r .token "$HOME/Library/Application Support/nimbus/auth/token")

Send it on every request, either as a bearer token or in the X-Nimbus-Admin-Token header:

Terminal window
curl -s http://localhost:8080/api/tenants \
-H "Authorization: Bearer $NIMBUS_TOKEN"

Requests without a valid credential get a 401 with code auth.unauthorized. Browser-based callers must also use loopback origins. See the HTTP API reference for the full access rules.

The URL path scopes every data operation to a tenant: /api/tenants/{tenant_id}/.... Create a tenant first:

Terminal window
curl -s -X POST http://localhost:8080/api/tenants \
-H "Authorization: Bearer $NIMBUS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id": "demo"}'

The server replies 201 Created with {"id": "demo"}. Tenants are fully isolated from each other: documents, schemas, scheduled jobs, and subscriptions never cross tenant boundaries.

Insert a document by naming a table and its fields. Nimbus creates tables implicitly on the first write:

Terminal window
curl -s -X POST http://localhost:8080/api/tenants/demo/documents \
-H "Authorization: Bearer $NIMBUS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"table": "messages", "fields": {"text": "hello world", "author": "you"}}'

The response is 201 Created with the generated document id:

{"id": "01jx2x9w7d2f0v6q8t3k5r9e1b"}

Read it back. Single documents and table listings are GET requests:

Terminal window
# One document
curl -s http://localhost:8080/api/tenants/demo/documents/messages/<id> \
-H "Authorization: Bearer $NIMBUS_TOKEN"
# Every document in the table
curl -s http://localhost:8080/api/tenants/demo/documents/messages \
-H "Authorization: Bearer $NIMBUS_TOKEN"

Returned documents carry three system fields alongside your own: _id, _creationTime, and _updateTime (epoch milliseconds). Update with PATCH (a partial patch object) and delete with DELETE on the same /documents/{table}/{document_id} path.

POST a query object to filter, order, and limit results. The request requires filters. Pass an empty array to match everything:

Terminal window
curl -s -X POST http://localhost:8080/api/tenants/demo/query \
-H "Authorization: Bearer $NIMBUS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"table": "messages",
"filters": [{"field": "author", "op": "eq", "value": "you"}],
"order": {"field": "_creationTime", "direction": "desc"},
"limit": 10
}'

For large result sets, use /query/paginated with a page_size and follow the returned cursor. The HTTP API reference lists both endpoints, all filter operators, and the paginated shapes.

5. Subscribe to live results over WebSocket

Section titled “5. Subscribe to live results over WebSocket”

You can register the same query as a subscription. The server pushes a fresh snapshot whenever a mutation changes the result. Connect to /ws with the nimbus.v2 subprotocol and identify the tenant with an X-Tenant-Id header or a tenant_id query parameter.

This example uses the ws package, which lets you set headers during the upgrade:

import WebSocket from "ws";
const socket = new WebSocket("ws://localhost:8080/ws?tenant_id=demo", ["nimbus.v2"], {
headers: { Authorization: `Bearer ${process.env.NIMBUS_TOKEN}` },
});
socket.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
switch (frame.type) {
case "hello":
// Complete the handshake, then subscribe.
socket.send(JSON.stringify({ type: "client_hello", protocol: "nimbus.v2" }));
socket.send(
JSON.stringify({
type: "subscribe",
request_id: "messages-1",
query: { table: "messages", filters: [] },
}),
);
break;
case "subscription_result":
// frame.data is the full current result set for the query.
console.log(`subscription ${frame.subscription_id}:`, frame.data);
break;
case "op.error":
case "error":
case "fatal_error":
console.error(frame.error.code, frame.error.message);
break;
}
});

Two handshake rules to respect: reply to the server’s hello frame with client_hello within 10 seconds, and send only JSON text frames. The full frame catalog, handshake failure modes, and reconnect semantics are in the WebSocket protocol reference.

HTTP and WebSocket errors use one envelope:

{
"error": {
"code": "op.invalid_input",
"message": "invalid document id `abc`",
"requestId": "req-...",
"timestamp": "2026-06-10T17:03:21Z",
"severity": "error",
"retryable": false,
"detail": null,
"remediation": { "action": "fix_request", "message": "Correct the request payload before retrying." }
}
}

Branch on code, retry when retryable is true, and surface remediation.message to operators. The complete code catalog with HTTP status mappings is in the error reference.