HTTP API Reference
The Cloacina API server (cloacinactl server start) exposes a REST
API backed by PostgreSQL or SQLite for managing API keys, tenants,
workflows, executions, triggers, and computation-graph health.
All authenticated routes are mounted under the /v1/ prefix. The
unauthenticated probes /health, /ready, and /metrics are at the
root. The server subcommand was renamed from cloacinactl serve to
cloacinactl server start in an earlier release; older docs may
still mention the old name.
All endpoints except health checks require a valid API key passed as a Bearer token:
Authorization: Bearer clk_a1b2c3d4e5f6...
Key format: API keys use the clk_ prefix followed by a cryptographically random string.
Validation flow:
- Extract the
Authorization: Bearer <key>header - Hash the key with SHA-256
- Check the LRU cache (256 entries, 30-second TTL)
- On cache miss, validate against the database
- On success, insert
AuthenticatedKeyinto request extensions
Error responses:
| Status | Body | Cause |
|---|---|---|
401 |
{"error": "missing or malformed Authorization header"} |
No Authorization header or not Bearer scheme |
401 |
{"error": "invalid or revoked API key"} |
Key not found or has been revoked |
500 |
{"error": "internal error during authentication"} |
Database error during validation |
These endpoints require no authentication.
Liveness check. Always returns 200.
Response: 200 OK
{"status": "ok"}
Readiness check. Verifies two things: the database connection pool is healthy, and no loaded computation graphs have crashed.
Response: 200 OK
{"status": "ready"}
Response: 503 Service Unavailable — database path
{"status": "not ready", "reason": "database unreachable"}
Response: 503 Service Unavailable — crashed-graph path
{
"status": "not ready",
"reason": "crashed computation graphs",
"crashed_graphs": ["pricing_graph", "alerts_graph"]
}
The crashed_graphs array names every loaded graph whose reactor
task is no longer running. The graph scheduler’s supervision loop
attempts to restart crashed graphs every 5 seconds; if a graph stays
crashed past MAX_RECOVERY_ATTEMPTS (5 consecutive failures) it is
permanently abandoned and remains in this list until the package is
reloaded.
Prometheus metrics endpoint.
Response: 200 OK with Content-Type: text/plain; version=0.0.4
# HELP cloacina_up Server is running
# TYPE cloacina_up gauge
cloacina_up 1
Create a new API key. The plaintext key is returned exactly once and cannot be retrieved again. Requires admin role.
Request:
{
"name": "ci-deploy",
"role": "admin"
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Human-readable name for the key |
role |
string | no | Key role. Lowercase string: "admin", "write", or "read". Defaults to "admin". |
Naming note: the request field is
role; the response field ispermissions. They carry the same value (e.g.,"admin"). The split is a historical artifact and intentional for backward compatibility — clients should sendrolein requests and readpermissionsfrom responses.
Response: 201 Created
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "ci-deploy",
"key": "clk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"permissions": "admin",
"tenant_id": null,
"is_admin": false,
"created_at": "2026-04-02T14:30:00+00:00"
}
Errors:
| Status | Body | Cause |
|---|---|---|
403 |
{"error": "insufficient permissions"} |
Caller does not have admin role |
500 |
{"error": "failed to create API key"} |
Database error |
Exchange a Bearer token for a single-use WebSocket ticket. The ticket can be passed as a query parameter on WebSocket upgrade requests, avoiding long-lived API keys in URLs.
Capacity & lifecycle: Tickets are stored in an in-memory store
bounded to 1024 unconsumed tickets with a 60-second TTL.
Tickets are single-use — the first WebSocket upgrade consuming the
ticket invalidates it. If the store reaches capacity, the ticket
nearest to expiry is evicted; rapid /v1/auth/ws-ticket calls
without consumption can silently exhaust capacity. Plan ticket
issuance to match your client connection rate; if you hold a
ticket but disconnect without upgrading, the ticket is wasted and
you must request a new one.
Request: Bearer token in Authorization header. No request body.
Response: 200 OK
{
"ticket": "a3f8c1d2-b4e5-6789-0abc-def123456789",
"expires_in_seconds": 60
}
The ticket is single-use and expires after 60 seconds. See the WebSocket Protocol reference for how tickets are used during connection upgrade.
List all API keys. No hashes or plaintext values are returned. Requires admin role.
Response: 200 OK
{
"keys": [
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "ci-deploy",
"permissions": "admin",
"tenant_id": null,
"is_admin": false,
"created_at": "2026-04-02T14:30:00+00:00",
"revoked": false
}
]
}
Errors:
| Status | Body | Cause |
|---|---|---|
403 |
{"error": "insufficient permissions"} |
Caller does not have admin role |
500 |
{"error": "failed to list API keys"} |
Database error |
Revoke an API key. The key is immediately invalidated (the cache is cleared).
Path parameters:
| Parameter | Type | Description |
|---|---|---|
key_id |
UUID | The key’s unique identifier |
Response: 200 OK
{"status": "revoked", "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"}
Errors:
| Status | Body | Cause |
|---|---|---|
400 |
{"error": "invalid key ID format"} |
key_id is not a valid UUID |
404 |
{"error": "key not found or already revoked"} |
Key does not exist or was already revoked |
500 |
{"error": "failed to revoke API key"} |
Database error |
Create an API key scoped to a specific tenant. Only is_admin (god-mode) keys can create tenant-scoped keys.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
tenant_id |
string | Tenant identifier (schema name) |
Request:
{
"name": "acme-worker",
"role": "write"
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Human-readable name for the key |
role |
string | no | Key role: "admin", "write", or "read". Defaults to "admin". |
Response: 201 Created
{
"id": "b58cc437-2a56-70e0-2b2c-3d479f47ac10",
"name": "acme-worker",
"key": "clk_x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4",
"permissions": "write",
"tenant_id": "tenant_acme",
"is_admin": false,
"created_at": "2026-04-02T15:00:00+00:00"
}
Errors:
| Status | Body | Cause |
|---|---|---|
403 |
{"error": "admin access required"} |
Caller is not an is_admin (god-mode) key |
500 |
{"error": "failed to create API key"} |
Database error |
Tenants are isolated PostgreSQL schemas. Each tenant gets its own schema, database user, permissions, and migrations.
Note: The
tenant_idused in URL paths (e.g.,/v1/tenants/{tenant_id}/workflows) corresponds to theschema_namevalue used when creating the tenant viaPOST /v1/tenants.
Create a new tenant.
Request:
{
"schema_name": "tenant_acme",
"username": "acme_user",
"password": ""
}
| Field | Type | Required | Description |
|---|---|---|---|
schema_name |
string | yes | Schema name (alphanumeric + underscore) |
username |
string | yes | Database username for this tenant |
password |
string | no | Password. Empty string triggers auto-generation (32 chars, ~202 bits entropy). |
Response: 201 Created
{
"schema_name": "tenant_acme",
"username": "acme_user"
}
Security: The tenant password is never returned in the response, even when auto-generated. The password is set during provisioning and is not surfaced over the API. Operators who need the password must capture it at provisioning time via the database admin tooling, not via this endpoint.
Errors:
| Status | Body |
|---|---|
400 |
{"error": "<detail>"} |
List all tenant schemas.
Response: 200 OK
{
"tenants": [
{"schema_name": "tenant_acme"},
{"schema_name": "tenant_globex"}
]
}
Errors:
| Status | Body |
|---|---|
500 |
{"error": "<detail>"} |
Remove a tenant. Drops the schema (CASCADE) and the database user.
Operational caveat: The server’s
TenantDatabaseCachedoes not evict its connection pool when a tenant is deleted. Subsequent requests to the deleted tenant will fail with stale-pool errors. Restartcloacina-serverto reclaim the cache. See Operational Caveats below.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
schema_name |
string | The tenant’s schema name |
Response: 200 OK
{"status": "removed", "schema_name": "tenant_acme"}
Errors:
| Status | Body |
|---|---|
400 |
{"error": "<detail>"} |
Workflow packages are .cloacina archives uploaded via multipart form data, scoped to a tenant.
Upload a workflow package.
Content-Type: multipart/form-data
Form fields:
| Field | Type | Description |
|---|---|---|
file |
binary | The .cloacina package archive |
Example (curl):
curl -X POST http://localhost:8080/tenants/tenant_acme/workflows \
-H "Authorization: Bearer clk_a1b2c3d4..." \
-F "file=@my_workflow.cloacina"
Response: 201 Created
{
"package_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"tenant_id": "tenant_acme"
}
Errors:
| Status | Body | Cause |
|---|---|---|
400 |
{"error": "no 'file' field in multipart request"} |
Missing file field |
400 |
{"error": "empty package file"} |
Zero-byte file |
400 |
{"error": "<detail>"} |
Package validation or registration failure |
500 |
{"error": "internal registry error"} |
Registry initialization failure |
List all registered workflows for a tenant.
Response: 200 OK
{
"tenant_id": "tenant_acme",
"workflows": [
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"package_name": "etl_pipeline",
"version": "1.2.0",
"description": "Extract, transform, and load data",
"tasks": ["extract", "transform", "load"],
"created_at": "2026-04-01T10:00:00+00:00"
}
]
}
Get details for a specific workflow by package name.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
tenant_id |
string | Tenant identifier |
name |
string | Workflow package name |
Response: 200 OK
{
"tenant_id": "tenant_acme",
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"package_name": "etl_pipeline",
"version": "1.2.0",
"description": "Extract, transform, and load data",
"tasks": ["extract", "transform", "load"],
"created_at": "2026-04-01T10:00:00+00:00"
}
Errors:
| Status | Body |
|---|---|
404 |
{"error": "workflow 'etl_pipeline' not found"} |
Unregister a specific workflow version.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
tenant_id |
string | Tenant identifier |
name |
string | Workflow package name |
version |
string | Semantic version |
Response: 200 OK
{
"status": "deleted",
"package_name": "etl_pipeline",
"version": "1.2.0"
}
Errors:
| Status | Body |
|---|---|
404 |
{"error": "<detail>"} |
Execute a workflow. Returns immediately with a scheduled execution ID.
Request:
{
"context": {
"source_url": "s3://bucket/data.csv",
"batch_size": 1000
}
}
| Field | Type | Required | Description |
|---|---|---|---|
context |
object | no | JSON key-value pairs to inject into the workflow context |
Response: 202 Accepted
{
"execution_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"workflow_name": "etl_pipeline",
"tenant_id": "tenant_acme",
"status": "scheduled"
}
Errors:
| Status | Body |
|---|---|
400 |
{"error": "<detail>"} |
List pipeline executions for a tenant. Returns all recent executions (including running and completed).
Response: 200 OK
{
"tenant_id": "tenant_acme",
"executions": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"pipeline_name": "etl_pipeline",
"status": "running",
"started_at": "2026-04-02T14:35:00+00:00",
"completed_at": null
}
]
}
Get execution status.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
tenant_id |
string | Tenant identifier |
exec_id |
UUID | Execution identifier |
Response: 200 OK
{
"tenant_id": "tenant_acme",
"execution_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "Completed"
}
Errors:
| Status | Body | Cause |
|---|---|---|
400 |
{"error": "invalid execution ID"} |
exec_id is not a valid UUID |
404 |
{"error": "<detail>"} |
Execution not found |
Get the execution event log for a specific execution.
Response: 200 OK
{
"tenant_id": "tenant_acme",
"execution_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"events": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"event_type": "task_started",
"event_data": "{\"task_id\": \"extract\"}",
"created_at": "2026-04-02T14:35:01+00:00",
"sequence_num": 1
},
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"event_type": "task_completed",
"event_data": "{\"task_id\": \"extract\"}",
"created_at": "2026-04-02T14:35:05+00:00",
"sequence_num": 2
}
]
}
Errors:
| Status | Body |
|---|---|
400 |
{"error": "invalid execution ID"} |
500 |
{"error": "<detail>"} |
Read-only listing of cron and trigger schedules.
List all schedules (cron and trigger) for a tenant.
Response: 200 OK
{
"tenant_id": "tenant_acme",
"schedules": [
{
"id": "c3d4e5f6-a7b8-9012-cdef-234567890123",
"schedule_type": "cron",
"workflow_name": "etl_pipeline",
"enabled": true,
"cron_expression": "0 2 * * *",
"trigger_name": null,
"poll_interval_ms": null,
"next_run_at": "2026-04-03T02:00:00+00:00",
"last_run_at": "2026-04-02T02:00:00+00:00",
"created_at": "2026-03-01T10:00:00+00:00"
},
{
"id": "d4e5f6a7-b8c9-0123-def0-345678901234",
"schedule_type": "trigger",
"workflow_name": "inbox_processor",
"enabled": true,
"cron_expression": null,
"trigger_name": "check_inbox",
"poll_interval_ms": 5000,
"next_run_at": null,
"last_run_at": "2026-04-02T14:30:00+00:00",
"created_at": "2026-03-15T12:00:00+00:00"
}
]
}
Get trigger details and recent executions. Matches by trigger name or workflow name.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
tenant_id |
string | Tenant identifier |
name |
string | Trigger name or workflow name |
Response: 200 OK
{
"tenant_id": "tenant_acme",
"schedule": {
"id": "c3d4e5f6-a7b8-9012-cdef-234567890123",
"schedule_type": "cron",
"workflow_name": "etl_pipeline",
"enabled": true,
"cron_expression": "0 2 * * *",
"trigger_name": null
},
"recent_executions": [
{
"id": "e5f6a7b8-c9d0-1234-ef01-456789012345",
"scheduled_time": "2026-04-02T02:00:00+00:00",
"started_at": "2026-04-02T02:00:01+00:00",
"completed_at": "2026-04-02T02:05:30+00:00"
}
]
}
Errors:
| Status | Body |
|---|---|
404 |
{"error": "trigger 'my_trigger' not found"} |
Health endpoints for the computation graph system. These endpoints require authentication.
List all registered accumulators with their health status.
Response: 200 OK
{
"accumulators": [
{
"name": "market_data",
"status": "healthy"
}
]
}
List loaded computation graphs with their health status. paused reports the pause state of the graph’s reactor.
Response: 200 OK
{
"graphs": [
{
"name": "pricing_graph",
"health": {"state": "running"},
"accumulators": ["market_data", "risk_params"],
"paused": false
}
]
}
Get health details for a specific computation graph.
Path parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Graph name |
Response: 200 OK
{
"name": "pricing_graph",
"health": {"state": "running"},
"accumulators": ["market_data", "risk_params"],
"paused": false
}
Errors:
| Status | Body |
|---|---|
404 |
{"error": "graph 'pricing_graph' not found"} |
The API server also exposes WebSocket endpoints for real-time interaction with computation graphs:
/v1/ws/accumulator/{name}– push events into a graph accumulator/v1/ws/reactor/{name}– send commands (force-fire, pause, resume) and query reactor state
WebSocket connections authenticate via a single-use ticket obtained from POST /v1/auth/ws-ticket. See the WebSocket Protocol reference for connection details and message formats.
All error responses use a consistent JSON format:
{"error": "<human-readable message>"}
Unmatched routes return:
404 Not Found
{"error": "not found"}
These are non-obvious failure modes and invariants surfaced from the implementation. Operators deploying cloacina-server should be aware of them.
- The auth cache is an LRU with 256 entries and a 30-second TTL. Key updates (rare) are not visible until the TTL expires.
- Revoking a key via
DELETE /v1/auth/keys/{id}clears the entire cache, not just the revoked key. This makes revocation immediate but causes a brief spike in database validation queries as subsequent requests rewarm the cache.
- Tickets issued by
POST /v1/auth/ws-ticketare single-use with a 60-second TTL. A client that holds a ticket but disconnects without upgrading wastes the ticket; retries require a fresh ticket. - The
WsTicketStoreis bounded to 1024 unconsumed tickets. If capacity is reached, the ticket nearest to expiry is evicted. Rapid/v1/auth/ws-ticketcalls without consumption can exhaust capacity; there is no backpressure signal, just silent eviction.
- Workflow execution scheduling is NOT tenant-scoped. The
DefaultRunnerthat backsPOST /v1/tenants/{id}/workflows/{name}/executeis a single global instance; executions land in the runner’s schema (typicallypublic), not the tenant’s schema. In multi-tenant deployments this is a known isolation gap. Operators who need true per-tenant execution isolation must run a separatecloacina-serverinstance per tenant or wait for per-tenant runner support to ship. - The trigger list endpoint
GET /v1/tenants/{id}/triggersreturns schedules from the global schedule table and filters client-side by name. It is not a true per-tenant audit; the same schedule will appear regardless of which tenant ID is in the path if it matches the filter. - The
TenantDatabaseCachelazily creates per-tenant connection pools but never evicts. Deleting a tenant viaDELETE /v1/tenants/{name}drops the schema but leaves the cached pool. Subsequent requests to the deleted tenant fail with stale- pool errors. Restart the server to reclaim the cache.
- All routes share a global 100 MB body limit via
DefaultBodyLimit::max(100 * 1024 * 1024). Package uploads consume this; there’s no per-route override. - No request timeout is enforced by the server itself; rely on
OS / reverse-proxy timeouts. Long-running executions block the
handler thread;
/readymay stall if computation graphs are wedged. - Database admin operations are synchronous. Creating or deleting tenants blocks the request handler. Large schemas or slow databases can cause client timeouts; there is no async background-task path for provisioning.
- On first startup with no API keys in the database, the server
generates an admin key (or uses
--bootstrap-key/CLOACINA_BOOTSTRAP_KEYif supplied) and writes the plaintext to~/.cloacina/bootstrap-keywith mode0600. The plaintext is written exactly once and never logged. Capture it from the file; there is no way to retrieve it later. - On subsequent startups, the bootstrap path is skipped if any keys exist. There is no automatic re-bootstrap.
- When
--require-signatures(orCLOACINA_REQUIRE_SIGNATURES=true) is set, the server verifies package signatures at upload viacloacina::security::verify_package_bytes(). The verification requires a signature row in thepackage_signaturestable — the server does not sign packages, only verifies. Signing is done offline (e.g.,cloacinactl pack --sign <key>once the side-car generation is wired up). - A signature row keyed by the configured
verification_org_idmust exist before upload. Missing signature →403 Forbidden.
/metricsis public — no authentication is enforced. Operators who need to restrict access should reverse-proxy the endpoint and enforce auth at that layer.
- The graph scheduler restarts crashed accumulator/reactor tasks on
a 5-second supervision cadence. If an accumulator crashes
outside the window, it stays dead until the next check. There is
no active health check or alerting; the only signal is the
paused: truefield returned byGET /v1/health/graphs/{name}.
- The fallback handler returns
{"error": "not found"}as JSON, not HTML. Clients that expect HTML error pages need to handle the JSON shape.
- CLI Reference —
cloacinactl server startflags and bootstrap-key behavior. - DatabaseAdmin API — tenant provisioning internals.
- Multi-Tenancy Architecture — schema isolation design.
- WebSocket Protocol — real-time WebSocket endpoints and message formats.
- Reconciler Pipeline — how the server loads and unloads packaged workflows.