All HTTP endpoints are served on the same port as WebSocket and Raft traffic (default :4100).

Authentication

All endpoints except /api/v1/health and /api/v1/ready require a Bearer token in the Authorization header:
Authorization: Bearer <CONCORDANCE_API_KEY or CONCORDANCE_AGENT_API_KEY>
Requests with CONCORDANCE_AGENT_API_KEY have read-only access (used by Podium agents). Requests with CONCORDANCE_API_KEY have full read/write access (used by Diminuendo).

Health and Readiness

GET /api/v1/health

Health check. Always returns 200 if the process is running. Response:
{ "ok": true }

GET /api/v1/ready

Readiness check. Returns 200 only if a Raft leader has been elected. Response:
{ "ok": true }
Returns { "ok": false } if the cluster has no leader (e.g., during initial election or network partition).

Key-Value Operations

GET /api/v1/kv/{namespace}

List all keys in a namespace. Optionally filter by key prefix. Query parameters:
ParameterTypeDescription
prefixstringOptional key prefix filter
Example:
curl -H "Authorization: Bearer $API_KEY" \
  "http://localhost:4100/api/v1/kv/tenant%3Aacme%2Fsettings"
Response:
{
  "items": [
    {
      "namespace": "tenant:acme/settings",
      "key": "feature_flags",
      "value": { "dark_mode": true, "beta_features": false },
      "version": 5,
      "updatedBy": "admin:a1",
      "updatedAt": 1710700000000,
      "expiresAt": null
    },
    {
      "namespace": "tenant:acme/settings",
      "key": "max_agents",
      "value": 10,
      "version": 1,
      "updatedBy": "system",
      "updatedAt": 1710600000000,
      "expiresAt": null
    }
  ]
}
With prefix filter:
curl -H "Authorization: Bearer $API_KEY" \
  "http://localhost:4100/api/v1/kv/tenant%3Aacme%2Fsettings?prefix=feature"

GET /api/v1/kv/{namespace}/{key}

Get a single key-value entry. Example:
curl -H "Authorization: Bearer $API_KEY" \
  "http://localhost:4100/api/v1/kv/tenant%3Aacme%2Fuser%3Au1%2Fpreferences/theme"
Response (200):
{
  "namespace": "tenant:acme/user:u1/preferences",
  "key": "theme",
  "value": "dark",
  "version": 3,
  "updatedBy": "user:u1",
  "updatedAt": 1710700000000,
  "expiresAt": null
}
Response (404):
{ "error": "Not found" }

PUT /api/v1/kv/{namespace}/{key}

Set a key-value entry. This is a write operation that goes through Raft consensus. Must be sent to the leader. If this node is not the leader, it returns a 307 redirect to the leader’s address. Request body:
{
  "value": "dark",
  "actor": "user:u1"
}
FieldTypeRequiredDescription
valueanyYesThe value to store (JSON-serialized internally)
actorstringNoIdentity of the writer (defaults to "api")
Example:
curl -X PUT \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value": "dark", "actor": "user:u1"}' \
  "http://localhost:4100/api/v1/kv/tenant%3Aacme%2Fuser%3Au1%2Fpreferences/theme"
Response (200):
{
  "namespace": "tenant:acme/user:u1/preferences",
  "key": "theme",
  "value": "dark",
  "version": 4,
  "updatedBy": "user:u1",
  "updatedAt": 1710700050000,
  "expiresAt": null
}
Response (307 — not leader):
HTTP/1.1 307 Temporary Redirect
Location: http://10.0.1.2:4100
X-Raft-Leader: 10.0.1.2:4100
Response (503 — no leader / proposal failed):
{ "error": "No leader available" }

DELETE /api/v1/kv/{namespace}/{key}

Delete a key. Write operation — must be sent to the leader. Query parameters:
ParameterTypeDescription
actorstringIdentity of the deleter (defaults to "api")
Example:
curl -X DELETE \
  -H "Authorization: Bearer $API_KEY" \
  "http://localhost:4100/api/v1/kv/tenant%3Aacme%2Fuser%3Au1%2Fpreferences/theme?actor=user:u1"
Response (200):
{ "deleted": true }

Batch Operations

POST /api/v1/batch

Execute multiple set/delete operations atomically as a single Raft proposal. Write operation — must be sent to the leader. Request body:
{
  "operations": [
    { "op": "set", "namespace": "tenant:acme/settings", "key": "theme_default", "value": "light" },
    { "op": "set", "namespace": "tenant:acme/settings", "key": "max_sessions", "value": 50 },
    { "op": "delete", "namespace": "tenant:acme/settings", "key": "deprecated_flag" }
  ],
  "actor": "admin:a1"
}
FieldTypeRequiredDescription
operationsarrayYesArray of operations to execute atomically
operations[].op"set" or "delete"YesOperation type
operations[].namespacestringYesTarget namespace
operations[].keystringYesTarget key
operations[].valueanyFor set onlyValue to store
actorstringNoIdentity of the writer (defaults to "api")
Response (200):
{ "ok": true }

Change Polling

GET /api/v1/changes

Poll for recent changes. Useful for clients that cannot use WebSocket subscriptions. Query parameters:
ParameterTypeDefaultDescription
tenantstring""Filter changes by tenant ID
afternumber0Return changes with seq greater than this value
limitnumber100Maximum number of changes to return
Example:
curl -H "Authorization: Bearer $API_KEY" \
  "http://localhost:4100/api/v1/changes?tenant=acme&after=100&limit=50"
Response:
{
  "changes": [
    {
      "seq": 101,
      "op": "set",
      "namespace": "tenant:acme/settings",
      "key": "max_agents",
      "value": 20,
      "version": 2,
      "actor": "admin:a1",
      "timestamp": 1710700100000,
      "tenantId": "acme"
    },
    {
      "seq": 102,
      "op": "delete",
      "namespace": "tenant:acme/settings",
      "key": "deprecated_flag",
      "value": null,
      "version": 3,
      "actor": "admin:a1",
      "timestamp": 1710700100001,
      "tenantId": "acme"
    }
  ],
  "lastSeq": 102
}
Use lastSeq as the after parameter in subsequent polls to get only new changes.

Cluster Management

GET /api/v1/cluster/status

Get the current node’s cluster status. Response:
{
  "nodeId": "node-1",
  "role": "leader",
  "leader": "node-1",
  "peers": ["node-1", "node-2", "node-3"],
  "lastLogIndex": 4287,
  "currentTerm": 12,
  "healthy": true
}
FieldTypeDescription
nodeIdstringThis node’s ID
rolestring"leader", "follower", or "candidate"
leaderstring or nullCurrent leader’s node ID (null if no leader)
peersstring[]All known node IDs in the cluster
lastLogIndexnumberSequence number of the last changelog entry
currentTermnumberCurrent Raft term
healthybooleanTrue if a leader is elected

GET /api/v1/cluster/leader

Get the current leader’s node ID and network address. Response:
{
  "leader": "node-1",
  "address": "10.0.1.1:4100"
}
Returns { "leader": null, "address": null } if no leader is elected.

POST /api/v1/cluster/join

Add a new node to the cluster. Sent to an existing cluster member (typically the leader). Request body:
{
  "nodeId": "node-3",
  "address": "10.0.1.3:4100"
}
Response (200):
{
  "ok": true,
  "nodeId": "node-3",
  "address": "10.0.1.3:4100"
}
Response (400):
{ "error": "nodeId and address required" }

POST /api/v1/cluster/remove

Remove a node from the cluster. Request body:
{
  "nodeId": "node-3"
}
Response (200):
{
  "ok": true,
  "removed": "node-3"
}

OpenAPI Spec

GET /api/v1/openapi.json

Returns an OpenAPI 3.0.3 specification for all endpoints.

Error Responses

All error responses use JSON with an error field:
{ "error": "Unauthorized" }
StatusMeaning
307Not the leader — follow the Location header redirect
400Bad request (missing required fields)
401Missing or invalid API key
404Key not found
500WebSocket upgrade failure
503No leader available or proposal failed to commit

Leader Redirects

Write operations (PUT, DELETE, POST /batch) must be handled by the Raft leader. If a write request reaches a follower:
  1. The node returns HTTP 307 with Location header pointing to the leader
  2. The X-Raft-Leader header contains the leader’s host:port
  3. The client should retry the request at the redirected address
If no leader is known (during an election or network partition), the node returns 503.