ZeroLock API Reference
Overview
ZeroLock exposes both HTTP and WebSocket APIs:
- HTTP API: OCC operations, CRDT operations, shard management, approvals, auth admin, metrics
- WebSocket API: Real-time CRDT sync, invalidation notifications, conflict notifications
Base URL: http://localhost:8080 (default)
WebSocket URL: ws://localhost:8080/ws
This reference is checked directly against the current gateway source (packages/gateway/src/routes/*.ts, src/plugins/*.ts), not just the SDKs — where a response shape below differs from what you might expect from an SDK's typed return value, the gateway's raw JSON is what's shown here, and the SDK's job is just to parse it into a typed object.
Conventions
- All request and response bodies are
application/jsonunless noted otherwise. - All timestamps are ISO 8601 / RFC 3339 UTC strings (e.g.
2026-01-15T10:30:00.000Z). - Every commit is naturally idempotent-safe under retry, without a separate idempotency key: because a commit names the
expected_versionit's building on, retrying the exact same commit after a network timeout either succeeds once (if it never reached the gateway the first time) or fails with409 STALE_VERSION(if it already applied and the version has since moved) — it never applies twice. This does not apply to appends on additive array shards (see Append to an Array Shard), which are unconditional CRDT operations with no version check at all — retrying an append after a timeout will append twice. - Error responses share a common shape: a machine-readable
errorcode (e.g.STALE_VERSION) plus amessagestring, plus whatever additional fields are useful for recovering from that specific error (e.g.current_version). See Error Code Reference for the full table.
HTTP API
Readiness
GET /ready returns 200 {"status":"ready"} while the gateway can serve traffic, and 503 when storage is unreachable or the gateway is draining for shutdown. It needs no API key. Point load balancer health checks here; /health only says the process is alive.
Health Check
GET /healthReturns gateway health and connection statistics. Useful as a liveness/readiness probe target for orchestrators and load balancers — see Deployment. Always bypasses authentication and rate limiting, regardless of configuration.
Response:
{
"status": "ok",
"timestamp": "2025-01-15T10:30:00.000Z",
"websocket": {
"connections": 5,
"subscriptions": 12
}
}curl http://localhost:8080/healthShards
Get Shard
GET /shards/:id?agent_id=<agent_id>Read a shard's current value and version. Works identically for both decision and additive shards — the gateway tries the OCC store first, then falls back to the CRDT store. Records the read in the delivery log for staleness detection — this is what makes read_set validation on later commits possible, so always pass a real agent_id, not a placeholder, if you intend to rely on staleness checks.
Query Parameters:
agent_id(required): Agent making the request
Response (200):
{
"value": { "count": 42 },
"version": 3
}Note there is no id or type field in the response body — you already know the ID (you asked for it), and the gateway does not currently echo the shard's type back on a plain read. If your application needs to know whether a shard is additive or decision, track that yourself at creation time.
Errors:
404: Shard not found
curl "http://localhost:8080/shards/my-shard?agent_id=my-agent"Create Shard
POST /shards
Content-Type: application/jsonCreate a new shard.
Request Body:
{
"id": "my-shard",
"type": "decision",
"initial_value": { "count": 0 },
"config": {
"reversible": true,
"owner_policy": "multi-writer",
"value_schema": { "type": "object" }
}
}Shard Types:
decision: OCC-backed, version-checked (mutual exclusion) — use for state where exactly one value must winadditive: CRDT-backed (lock-free convergence) — use for state where every contribution should survive (queues, logs, collaborative text)
Config Options:
crdt_type(additive only):maporarrayfor commits today;textcan be created but committing to it currently fails — see Architecture — Additive Shardsowner_policy(decision only):single-writerormulti-writerreversible:true(default) orfalse(requires approval — see Approvals below)value_schema: JSON Schema for validation; a commit whosenew_valuedoesn't satisfy this schema is rejected before it's ever considered against the current version
Response (201):
{
"id": "my-shard",
"version": 1
}Errors:
409: Shard already exists
curl -X POST http://localhost:8080/shards \
-H "Content-Type: application/json" \
-d '{ "id": "my-shard", "type": "decision", "initial_value": { "count": 0 } }'Commit Shard
POST /shards/:id/commit
Content-Type: application/jsonCommit a new value. What actually happens with this one endpoint depends entirely on the target shard's type — the gateway checks getCrdtType(id) before doing anything else and routes to one of three genuinely different code paths. See Architecture — Request Lifecycle for the full routing diagram.
Commit to a Decision Shard (OCC)
Request Body:
{
"agent_id": "my-agent",
"expected_version": 3,
"new_value": { "count": 43 },
"read_set": ["dependency-shard-1", "dependency-shard-2"],
"approval_id": "apr_xyz789"
}Fields:
agent_id: Agent making the commitexpected_version: Current version (OCC check)new_value: New value to set — this replaces the shard's value entirely; there is no partial-patch semantics on a decision shard the way there is on an additive map shardread_set(optional): Other shards read before this commit — if any of them have changed since your last read, the commit is rejected even though the shard you're writing to hasn't moved. Use this whenever a commit's value was computed from more than one shard's data, so a change to any dependency invalidates the computation, not just a change to the shard being written.approval_id(required only if the shard was created withreversible: false): the ID returned from a priorPOST /approvals/requestcall whose approval has since reachedstatus: "approved"— see Approvals
Response (200):
{
"version": 4,
"audit_entry_id": "ae_abc123"
}Errors:
404: Shard not found409: Version conflict (STALE_VERSION) or cross-shard staleness (READ_SET_STALE)403: Shard is irreversible andapproval_idis missing, invalid, unapproved, or doesn't match this shard/agent/version — see Error Code Reference for the specific sub-codes
Conflict Response (409, STALE_VERSION):
{
"error": "STALE_VERSION",
"stale_shard_id": "my-shard",
"current_version": 5,
"message": "Stale version for shard 'my-shard': expected 3, current is 5"
}Note this body does not include the shard's current_value — only its current_version. If your retry logic needs the current value to recompute a new one, issue a fresh GET /shards/:id after catching the conflict; don't assume the value travels with the error.
curl -X POST http://localhost:8080/shards/my-shard/commit \
-H "Content-Type: application/json" \
-d '{ "agent_id": "my-agent", "expected_version": 3, "new_value": { "count": 43 } }'Commit to an Additive Map Shard
Request Body:
{
"agent_id": "my-agent",
"expected_version": 0,
"new_value": { "some-key": "some-value", "another-key": null }
}expected_version is accepted but ignored for additive shards — there is no version check on this path at all. new_value must be a plain object; it's applied as a grow/update-only patch: every key present is set to its given value, except a value of exactly null, which deletes that key. Any key simply absent from new_value is left completely untouched, regardless of what else has changed on the shard since you last read it. See Architecture — Additive Shards for why this is deliberate.
Errors:
400 UNSUPPORTED_DIFF: the shard'scrdt_typeisn'tmap, ornew_valueisn't a plain object (e.g. you sent an array, or attempted this against atext-typed shard)
Append to an Array Shard
Request Body:
{
"agent_id": "my-agent",
"expected_version": 0,
"append": { "any": "json value" }
}expected_version is ignored here too. The append field carries the item to push — this is a distinct wire field from new_value, and is the only way to append to an additive array shard; sending new_value (even as a single-item array) against an array-typed shard is not a supported way to append and will fail. Every concurrent caller's append survives regardless of what else is appending at the same time — there is no conflict this operation can return.
Errors:
400 UNSUPPORTED_DIFF: the shard'scrdt_typeisn'tarray
Both TypeScript's client.appendItem(shardId, item) and Python's client.append_item(shard_id, item) are thin wrappers that send exactly this request — see TypeScript SDK / Python SDK.
Approvals (Layer 4)
Approvals gate any commit against a shard created with reversible: false. There is no "pending commit" queued anywhere on the server when an agent's commit is rejected for lacking approval — the agent must explicitly call POST /approvals/request, wait for it to resolve, and then retry its original commit carrying the resulting approval_id. See Architecture — Layer 4 for the design rationale.
Request Approval
POST /approvals/request
Content-Type: application/jsonRequest approval for an irreversible operation. Only valid for a shard whose current schema has reversible: false — requesting approval for a reversible shard returns 400 NOT_IRREVERSIBLE.
Request Body:
{
"shard_id": "payment-shard",
"agent_id": "payment-agent",
"proposed_value": { "amount": 1000, "to": "merchant" },
"expected_version": 2,
"description": "Payment processing",
"ttl_seconds": 3600
}There is no operation field — every approval is implicitly a proposed commit. description and ttl_seconds are both optional; ttl_seconds defaults to one hour.
Response (201):
{
"id": "apr_xyz789",
"status": "pending",
"expires_at": "2025-01-15T11:00:00.000Z"
}Errors:
400 MISSING_SHARD_ID/MISSING_AGENT_ID/MISSING_VERSION404: Shard not found400 NOT_IRREVERSIBLE: shard is reversible, no approval needed409 APPROVAL_EXISTS: a pending approval already exists for this shard — resolve or wait for it to expire before requesting another
curl -X POST http://localhost:8080/approvals/request \
-H "Content-Type: application/json" \
-d '{ "shard_id": "payment-shard", "agent_id": "payment-agent", "proposed_value": { "amount": 1000 }, "expected_version": 2, "description": "Payment processing" }'Get Approval
GET /approvals/:idCheck approval status.
Response (200):
{
"id": "apr_xyz789",
"shard_id": "payment-shard",
"agent_id": "payment-agent",
"status": "approved",
"proposed_value": { "amount": 1000 },
"expected_version": 2,
"description": "Payment processing",
"resolved_by": "admin-agent",
"resolved_at": "2025-01-15T10:35:00.000Z"
}Status Values:
pending: Awaiting approvalapproved: Approved (see the note below — this does not always mean the underlying commit succeeded)rejected: Rejectedexpired: TTL exceeded
Poll this endpoint (or use the SDK's waitForApproval / wait_for_approval helper, which wraps polling for you) to find out when a pending approval resolves.
List Approvals
GET /approvals?shard_id=<id>&status=<status>&limit=<n>List approvals, optionally filtered by shard_id and/or status, for admin and debugging use. All query parameters are optional.
Response (200):
{ "approvals": [ { "id": "apr_xyz789", "status": "pending", "...": "..." } ] }Approve
POST /approvals/:id/approve
Content-Type: application/jsonApprove the request. This is the only call that actually causes the originally-requested commit to apply — approving triggers OCCStore.commit() against the shard's current version at approval time, not the version that was current when the approval was requested.
Request Body:
{
"agent_id": "admin-agent"
}Response (200) — approval resolved and commit applied:
{
"id": "apr_xyz789",
"status": "approved",
"version": 3,
"audit_entry_id": "ae_abc123"
}Response (200) — approval resolved but the underlying commit failed a version check:
{
"id": "apr_xyz789",
"status": "approved"
}This second shape is important and easy to miss: if the target shard changed between the original request and this approval, the approval itself still moves to approved — it does not roll back or re-open — but no version/audit_entry_id is returned, because the commit never applied. Always check for the presence of version in the response before treating an "approved" result as "the operation actually happened."
Errors:
400 MISSING_AGENT_ID404 NOT_FOUND: approval doesn't exist or was already resolved400 INVALID_STATE: approval isn't in a state that can be approved
Reject
POST /approvals/:id/reject
Content-Type: application/jsonReject the pending operation. Once rejected, the original commit never applies — there is no way to "un-reject" an approval; the requesting agent must submit a fresh request_approval call if it still wants the operation to happen.
Request Body:
{
"agent_id": "admin-agent",
"reason": "Insufficient funds verification"
}Response (200):
{ "id": "apr_xyz789", "status": "rejected" }Metrics
Prometheus Metrics
GET /metricsReturns metrics in Prometheus exposition format — point a Prometheus scrape config at this endpoint directly; see Deployment for the accompanying Grafana setup. Always bypasses authentication and rate limiting.
Example Metrics:
zerolock_uptime_seconds 3600
zerolock_http_requests_total{method="GET",route="/shards/:id",status="200"} 1523
zerolock_http_duration_ms{method="POST",route="/shards/:id/commit",quantile="0.99"} 42
zerolock_shard_commits_total{status="success"} 892
zerolock_shard_commits_total{status="conflict"} 45
zerolock_shard_reads_total 3021
zerolock_shard_creates_total 88
zerolock_ws_connections 12
zerolock_ws_subscriptions 34
zerolock_ws_messages_sent_total 5678
zerolock_ws_baseline_messages_total 45000
zerolock_ws_cost_savings 0.8740
zerolock_ws_invalidations_total 5210
zerolock_ws_conflict_notifications_total 45zerolock_shard_commits_total{status="conflict"} climbing relative to status="success" is usually the first signal of write contention worth investigating — see Deployment — High Conflict Rates for what to do about it. zerolock_http_duration_ms is exposed as p50/p95/p99 quantiles per route, computed over the process's full in-memory history (it resets on restart, and via POST /metrics/reset).
WebSocket Metrics (JSON)
GET /metrics/wsReturns WebSocket-specific metrics for cost audit as JSON rather than Prometheus text — this is the endpoint behind Layer 2's message-reduction figure (see Architecture — Layer 2); costSavingsPercent is computed live against your own traffic, not a fixed constant.
Response:
{
"connections": 12,
"subscriptions": 34,
"messagesSent": 5678,
"baselineMessages": 45000,
"costSavings": 0.874,
"costSavingsPercent": "87.40%",
"invalidationsSent": 5210,
"conflictNotificationsSent": 45,
"messagesByType": { "invalidate": 5210, "conflict_notify": 45, "sync_response": 423 }
}Reset Metrics
POST /metrics/resetResets all in-memory HTTP, shard, and WebSocket metrics counters back to zero and restarts the uptime clock. Intended for test harnesses that want a clean metrics baseline between runs, not for routine production use — resetting in production loses your Prometheus counters' history between scrapes if your scrape interval crosses a reset.
WebSocket API
Connect to: ws://localhost:8080/ws?agent_id=<agent_id>
The connection is long-lived and bidirectional: subscribe/unsubscribe and CRDT sync messages flow client→server, while invalidate and conflict_notify flow server→client asynchronously, with no request/response pairing required for the latter two. Both SDKs' WebSocket clients support autoReconnect — on an unexpected disconnect, re-establish the connection and re-issue subscribe for any shard you were previously watching, since subscriptions do not survive a reconnect automatically.
Message Types
Subscribe
{ "type": "subscribe", "shard_id": "my-shard", "agent_id": "my-agent" }Unsubscribe
{ "type": "unsubscribe", "shard_id": "my-shard", "agent_id": "my-agent" }Sync Request (CRDT)
Request current state for an additive shard.
{
"type": "sync_request",
"shard_id": "crdt-shard",
"agent_id": "my-agent",
"state_vector": "<base64-encoded-yjs-state-vector>"
}Response:
{
"type": "sync_response",
"shard_id": "crdt-shard",
"update": "<base64-encoded-yjs-update>",
"version": 5
}Passing a state vector lets the gateway send back only the delta the client is missing, rather than the whole CRDT document — omit it (or send an empty vector) on first connect to receive full state.
Sync Update (CRDT)
Send a Yjs update to the server.
{
"type": "sync_update",
"shard_id": "crdt-shard",
"agent_id": "my-agent",
"update": "<base64-encoded-yjs-update>"
}Server Messages
Invalidate (Layer 2)
{ "type": "invalidate", "shard_id": "my-shard", "version": 4, "changed_by": "other-agent" }Only subscribers to my-shard receive this — not every connected client — which is the mechanism behind Layer 2's subscriber-scoped push.
Conflict Notify (Layer 3)
{
"type": "conflict_notify",
"shard_id": "my-shard",
"agent_id": "my-agent",
"current_version": 5,
"winning_value": { "count": 50 },
"rejected_value": { "count": 43 },
"diff": { "count": { "from": 43, "to": 50 } }
}Registering a handler for this message is what enables Layer 3 resolution strategies (accept, merge, or escalate to an LLM judge) — see Architecture — Layer 3 for the full pattern.
Authentication
Disabled by default. When enabled (via --auth-required and/or an API keys file), include the key in requests:
x-api-key: sk_live_abc123...The key is required on the WebSocket handshake too (GET /ws), as an x-api-key header. Earlier builds skipped authentication for WebSocket upgrades, and the auth, rate-limit and metrics hooks did not apply to the /shards and /approvals routes at all; both are fixed, so --auth-required now protects every route except the bypassed ones below.
Optional trace headers (any request, and the WebSocket handshake):
| Header | Meaning |
|---|---|
x-zerolock-run-id | Groups operations into a run (also accepted as ?run_id= on /ws) |
x-zerolock-trace-id | Trace ID to join gateway events to your own tracing (a W3C traceparent header is used if this is absent) |
x-zerolock-metadata | URL-encoded JSON object of primitive values, up to 20 keys / 2 KB |
These are recorded in each audit entry's metadata as run_id, trace_id and client_metadata. Invalid values are ignored, never rejected.
Permissions:
shard:read:GET /shards/:idshard:write:POST /shards/:id/commitshard:create:POST /shardsshard:delete:DELETE /shards/:idaudit:read:GET /audit,GET /events/streamapproval:request,approval:resolve,approval:read: request, approve/reject, and view approvals (approval:*grants all three). A key cannot resolve a request it created.admin: every permission within its own environmentplatform:admin: environments, keys and usage across the whole gateway (not implied byadmin, and not grantable through the API)
Always bypassed regardless of auth configuration: /health, /metrics, /metrics/ws, /metrics/reset.
Admin routes (require the admin permission on the calling key):
| Endpoint | Method | Purpose |
|---|---|---|
/admin/auth/reload | POST | Re-read the API keys file from disk without restarting the gateway |
/admin/auth/keys | GET | List configured keys (name, permissions, expiry — never the key value itself, only its first 8 characters) |
/admin/rate-limit/stats | GET | Current rate-limit store size and total requests seen |
Environments, keys and usage (platform admin)
| Endpoint | Method | Purpose |
|---|---|---|
/admin/environments | POST / GET | Create or update an environment (id, name, limits) / list environments |
/admin/environments/:id | DELETE | Delete an environment and everything in it (shards, audit history, approvals, keys, usage). Its keys stop working at once and its WebSocket clients are disconnected with close code 4000. The default environment cannot be deleted |
/admin/environments/:id/usage | GET | Daily counters (operations, reads, commits, creates, conflicts, errors); ?from=&to= as YYYY-MM-DD |
/admin/keys | POST / GET | Create a key (returned once; stored hashed) / list keys (?environment=) |
/admin/keys/:id | DELETE | Revoke a key |
Environment limits: max_operations_per_day (UTC day), max_shards, max_ws_connections. Exceeding one returns 429 with { "error": "QUOTA_EXCEEDED", "limit": "<name>" }.
Shard listing and deletion
GET /shards?limit=&after= lists shards (id order, next_cursor is the last id). DELETE /shards/:id?agent_id= removes a shard; its audit history is kept.
Audit trail and live events
GET /audit returns the environment's events newest first: { events, next_cursor }. Pass next_cursor back as ?cursor= for older events. Filters: shard_id, agent_id, run_id, trace_id, operation (comma-separated), since, until, limit (max 1000).
GET /events/stream is a Server-Sent Events stream of new events (event: audit, id: <seq>), accepting the same filters. It starts at the newest event, or resumes after Last-Event-ID / ?after=. At most 20 streams per environment.
POST /events lets a client report claim_attempt, claim_won or claim_lost ({ agent_id, shard_id, kind, detail }) so task-claim outcomes appear in the trace. Operations recorded by the gateway itself include read, create, commit, append, conflict, delete and the approval lifecycle.
Authentication is opt-in via the --auth-required flag (or by simply providing an API keys file without requiring it, which authenticates keys that are present but still lets unauthenticated requests through); see Deployment — Authentication Setup for generating keys and writing the permissions file.
Rate Limiting
When rate limiting is enabled, responses include headers:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 1705315800Exceeded limits return 429 Too Many Requests with a Retry-After header (seconds). 429 is included in both SDKs' default retryableStatuses list, so a client using the default retry configuration backs off and retries automatically rather than surfacing the error to your application code. /health is always exempt from rate limiting; WebSocket upgrade requests are never rate-limited (the connection is long-lived, not a repeated request).
Error Code Reference
A consolidated view of every HTTP status this API returns and what it means, gathered from the endpoint sections above and verified against the current route handlers:
| Status | error code(s) | Where it appears |
|---|---|---|
200 | — | Successful read, commit, or approval action |
201 | — | POST /shards, POST /approvals/request |
400 | MISSING_ID, INVALID_TYPE, MISSING_AGENT_ID, MISSING_VERSION, MISSING_SHARD_ID, NOT_IRREVERSIBLE, INVALID_STATE, UNSUPPORTED_DIFF | POST /shards, POST /shards/:id/commit, POST /approvals/* |
403 | APPROVAL_REQUIRED, INVALID_APPROVAL, APPROVAL_NOT_APPROVED, APPROVAL_WRONG_SHARD, APPROVAL_WRONG_AGENT, APPROVAL_WRONG_VERSION | POST /shards/:id/commit, only for reversible: false shards |
404 | NOT_FOUND | GET /shards/:id, POST /shards/:id/commit, GET/POST /approvals/:id* |
409 | ALREADY_EXISTS (on create) or STALE_VERSION / READ_SET_STALE (on commit), APPROVAL_EXISTS | POST /shards, POST /shards/:id/commit, POST /approvals/request |
429 | — | Any endpoint (except /health), when rate limiting is enabled — see Retry-After header |
A 409 on create and a 409 on commit mean different things despite sharing a status code — check the response body's error field (or which endpoint you called) rather than branching on status code alone. Note the approval-required family of errors is 403, not 423 — if you've seen other ZeroLock documentation reference 423 Locked for this, that was inaccurate and has been corrected here to match the actual gateway behavior.
