Wadjet exposes a REST API for executing queries, managing tables, and monitoring health.
http://<host>:<port>
Default listen address: :8080
If authentication is configured (see Security), include credentials in requests:
API Key:
Authorization: Bearer wadjet-key-abc123
JWT:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
mTLS:
curl --cert client.pem --key client-key.pem --cacert ca.pem https://...POST /v1/queries
Execute a SQL query and return results.
Request:
{
"sql": "SELECT src_ip, SUM(bytes_in) AS total FROM flow_logs GROUP BY src_ip ORDER BY total DESC LIMIT 10"
}Headers:
| Header | Required | Description |
|---|---|---|
Content-Type |
Recommended | application/json (the server does not check it — the body is decoded as JSON regardless) |
Authorization |
If auth configured | Bearer token |
Response (200 OK):
{
"query_id": "q-7f3a2b1c",
"columns": ["src_ip", "total"],
"rows": [
{"src_ip": "10.0.1.50", "total": 104857600},
{"src_ip": "10.0.2.30", "total": 52428800},
{"src_ip": "10.0.3.10", "total": 26214400}
],
"stats": {
"elapsed": "45ms",
"rows_scanned": 2500000,
"plan": "Scan(flow_logs) → Filter → Aggregate(src_ip) → Sort(total DESC) → Limit(10)"
}
}Response Fields:
| Field | Type | Description |
|---|---|---|
query_id |
string | Unique identifier for this query execution |
columns |
[]string | Ordered list of result column names |
rows |
[]object | Array of row objects (column name → value) |
stats.elapsed |
string | Wall-clock execution time |
stats.rows_scanned |
int | Rows read from storage on the embedded (no-coordinator) path. On the coordinator path — wadjetd serve --mode=standalone and wadjetd serve --mode=coordinator — this carries the result row count instead. |
stats.plan |
string | Human-readable execution plan |
Error Response (400/401/403/500):
{
"error": "parse error: syntax error at position 42 near 'FORM'"
}Every refusal a STATEMENT earns carries the PostgreSQL SQLSTATE alongside the
message — from SELECT, from DML, from EXPLAIN, and from the DDL statements
this same endpoint runs, including an unknown type name (42704), a missing
function (42883), a missing table (42P01), a duplicate one (42P07), a
duplicate function name (42723) and a column named twice in
CREATE TABLE (42701) — and the class decides the HTTP status.
A statement this engine parses and does not run is refused 0A000
(feature_not_supported) with a message naming the statement:
ALTER TABLE is not supported, and likewise CREATE VIEW, DROP VIEW and
CREATE SNAPSHOT — no door runs those four.
CREATE ALERT, DROP ALERT and ALTER ALERT answer the same way on this
endpoint, which has no handler for them. On a server they run through the
gRPC Query RPC against a coordinator started with --enable-alerts; psql
does not reach that handler either, and answers alerts are disabled on this connection (also 0A000). An embedding program runs them on its own DB
with Config.EnableAlerts. See
SQL reference for why the door
decides.
Every one of these classes is the same on every door.
| SQLSTATE class | Meaning | Status |
|---|---|---|
0A |
feature not supported (0A000) |
400 |
22 |
data exception (22003, 22012, 22P02, 2201E, …) |
400 |
23 |
integrity constraint violation (23502, 23505) |
400 |
42 |
syntax error or access rule violation (42601, 42703, 42704, 42P01, 42P07, 42883, …) |
400 |
| anything else | server-side or transport failure (XX000 internal, 58030 I/O) |
500 |
The promotion to 400 replaces a 5xx only. A response that names the
resource keeps its status with the class beside it: DESCRIBE nosuchtable,
DROP TABLE nosuchtable and ANALYZE nosuchtable are 404 with
"sqlstate": "42P01", and a CREATE TABLE for a name already taken is 409
with "sqlstate": "42P07". One missing table is one class on every statement
that can name one.
A statement refused for what it contains is the client's error, not the
server's, which is why those four classes answer 400 Bad Request. An error
the statement did not cause — a malformed request body, a missing sql field —
carries no SQLSTATE and answers with the error key alone, under 400 or
401.
An authorization denial is PostgreSQL's 42501 (insufficient_privilege)
on the wire and through the embedded API, and 403 on this endpoint —
including when it is raised deep inside planning rather than by the handler,
so one refusal has one status. A role holding only read that runs
CREATE TABLE, DROP TABLE, ANALYZE, CREATE FUNCTION or a table function
such as read_csv is refused with that class and nothing is created, dropped,
registered, opened or fetched. See
Security for which
permission each statement needs.
The message for a classified error is the engine's own, the same text the
PostgreSQL wire protocol puts in its ErrorResponse:
{
"error": "unknown column \"nosuchcol\" (available: bytes_in, src_ip, ts)",
"sqlstate": "42703"
}DML: the same endpoint runs INSERT, UPDATE, DELETE and MERGE
through the same implementation the embedded API and the PostgreSQL wire
protocol use, so a statement's table state and command tag do not depend on
which door it arrived by — the tag is PostgreSQL's own rendering, INSERT 0 3
rather than INSERT 3. The SQLSTATE is the same class on every door, and so is
the message; this door additionally turns the class into an HTTP status, as the
error section above records. A DML statement answers with a single row
carrying its command tag:
{
"query_id": "q-1756900000000",
"columns": ["result"],
"rows": [{"result": "DELETE 2"}],
"stats": {"elapsed": "12ms", "rows_scanned": 0}
}cURL Example:
curl -s -X POST http://localhost:8080/v1/queries \
-H "Content-Type: application/json" \
-H "Authorization: Bearer wadjet-key-abc123" \
-d '{"sql": "SELECT * FROM flow_logs LIMIT 5"}' | jq .GET /v1/tables
Return all tables registered in the catalog.
Response (200 OK):
{
"tables": ["flow_logs", "syslog", "snmp_traps", "device_inventory"]
}cURL Example:
curl -s http://localhost:8080/v1/tables | jq .GET /v1/tables/{name}
Return the schema and partition keys for a specific table.
Response (200 OK):
{
"name": "flow_logs",
"schema": {
"columns": [
{"name": "timestamp", "type": "Timestamp"},
{"name": "src_ip", "type": "IPv4"},
{"name": "dst_ip", "type": "IPv4"},
{"name": "src_port", "type": "Int32"},
{"name": "dst_port", "type": "Int32"},
{"name": "protocol", "type": "String"},
{"name": "bytes_in", "type": "Int64"},
{"name": "bytes_out", "type": "Int64"}
]
},
"partition_keys": ["date"]
}Error Response (404):
{
"error": "table not found: nonexistent_table"
}GET /v1/queries
Returns a list of currently active and recently completed queries.
Response (200 OK):
{
"queries": [
{
"query_id": "q-7f3a2b1c",
"state": "running",
"sql": "SELECT ...",
"elapsed": "2s"
}
]
}POST /v1/queries/async
Submit a query for asynchronous execution. Returns immediately with a query ID that can be polled for results.
Note: The async endpoints (
POST /v1/queries/async,GET /v1/queries,GET /v1/queries/{queryID},GET /v1/queries/{queryID}/results,DELETE /v1/queries/{queryID}) need a coordinator.wadjetd serve --mode=coordinatorhas one, andwadjetd serve --mode=standaloneembeds a coordinator, a worker and NATS in one process, so these work in both modes. Worker mode runs the worker;wadjet serveruns the embedded engine over pgwire (see LICENSING.md). They return503 Service Unavailableonly when the HTTP server is constructed without a coordinator, which is the embedded-library path.
A query belongs to the identity that submitted it. With authentication enabled, its status, its SQL, its results, its cancellation and its result files are readable and actionable by that principal and by an identity holding
admin, and by nobody else: another identity gets403 Forbiddenwithpermission denied: query "<id>" belongs to another principal, and a refused cancel does not cancel.GET /v1/querieslists only the caller's own queries — every user query for an administrator — and never the coordinator's internal per-stage entries. The query ID is a full UUID.
Request:
{
"sql": "SELECT src_ip, SUM(bytes_in) AS total FROM flow_logs GROUP BY src_ip ORDER BY total DESC"
}Response (202 Accepted):
{
"query_id": "9e8d7c6b-4b21-4f0e-9c3a-0d9f7e2a1c55"
}GET /v1/queries/{queryID}
Check the status of an async query.
Response (200 OK):
{
"query_id": "9e8d7c6b-4b21-4f0e-9c3a-0d9f7e2a1c55",
"state": "completed",
"total_rows": 150,
"elapsed": "340ms"
}States: pending, running, completed, failed, cancelled
GET /v1/queries/{queryID}/results
Retrieve the results of a completed async query.
Response (200 OK):
Same format as the synchronous POST /v1/queries response.
DELETE /v1/queries/{queryID}
Cancel a running query.
Response (200 OK):
{
"query_id": "9e8d7c6b-4b21-4f0e-9c3a-0d9f7e2a1c55",
"state": "cancelled"
}GET /v1/health
Returns server health status.
Response (200 OK):
{
"status": "ok"
}GET /metrics
Returns Prometheus-formatted metrics. See Operations for details on available metrics.
Response (200 OK):
# HELP wadjet_queries_total Total number of queries executed
# TYPE wadjet_queries_total counter
wadjet_queries_total 1523
# HELP wadjet_query_rows_scanned_total Total rows scanned across all queries
# TYPE wadjet_query_rows_scanned_total counter
wadjet_query_rows_scanned_total{table="flow_logs"} 45000000
# HELP wadjet_query_duration_seconds Query execution time
# TYPE wadjet_query_duration_seconds histogram
wadjet_query_duration_seconds_bucket{le="0.01"} 892
wadjet_query_duration_seconds_bucket{le="0.1"} 1400
wadjet_query_duration_seconds_bucket{le="1"} 1510
wadjet_query_duration_seconds_bucket{le="10"} 1523
These read the cluster's operational state or destroy stored artifacts. With
authentication enabled every one of them requires the admin permission
and answers 403 Forbidden to any other identity — including one that may run
queries. The refusal names the permission it wanted:
{"error":"unauthorized: \"admin\" permission required (identity \"reader-user\", role \"reader\")"}| Endpoint | Permission | What it does |
|---|---|---|
GET /v1/dlq?limit=N |
admin |
Lists dead-letter entries, most recent first |
GET /v1/dlq/{entryID} |
admin |
One dead-letter entry |
DELETE /v1/dlq |
admin |
Purges the whole dead-letter queue |
GET /v1/workers |
admin |
Active workers with their memory usage |
POST /v1/results/cleanup |
admin |
Deletes stale query result files past the TTL |
DELETE /v1/results/{queryID} |
owner or admin |
Deletes one query's result files |
A dead-letter entry carries task_data, the serialized distributed task the
worker failed on: it holds that statement's SQL and expression text, the
identity fields it ran under, object paths and trace IDs. It is other
principals' query text, which is why reading the queue is an administrator's
operation and not a reader's.
The result endpoints answer 503 Service Unavailable when the coordinator has
no object store configured for results.
import requests
WADJET_URL = "http://localhost:8080"
HEADERS = {
"Content-Type": "application/json",
"Authorization": "Bearer wadjet-key-abc123",
}
def query(sql: str) -> dict:
resp = requests.post(
f"{WADJET_URL}/v1/queries",
json={"sql": sql},
headers=HEADERS,
)
resp.raise_for_status()
return resp.json()
# Top talkers
result = query("""
SELECT src_ip, SUM(bytes_in) AS total
FROM flow_logs
WHERE date = '2026-03-15'
GROUP BY src_ip
ORDER BY total DESC
LIMIT 10
""")
for row in result["rows"]:
print(f"{row['src_ip']}: {row['total']:,} bytes")package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type QueryRequest struct {
SQL string `json:"sql"`
}
type QueryResponse struct {
QueryID string `json:"query_id"`
Columns []string `json:"columns"`
Rows []map[string]any `json:"rows"`
Stats map[string]any `json:"stats"`
}
func query(sql string) (*QueryResponse, error) {
body, _ := json.Marshal(QueryRequest{SQL: sql})
req, _ := http.NewRequest("POST", "http://localhost:8080/v1/queries", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer wadjet-key-abc123")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result QueryResponse
json.NewDecoder(resp.Body).Decode(&result)
return &result, nil
}
func main() {
result, _ := query("SELECT src_ip, COUNT(*) AS n FROM flow_logs GROUP BY src_ip LIMIT 10")
for _, row := range result.Rows {
fmt.Printf("%s: %v\n", row["src_ip"], row["n"])
}
}const WADJET_URL = "http://localhost:8080";
const API_KEY = "wadjet-key-abc123";
interface QueryResult {
query_id: string;
columns: string[];
rows: Record<string, unknown>[];
stats: { elapsed: string; rows_scanned: number };
}
async function query(sql: string): Promise<QueryResult> {
const res = await fetch(`${WADJET_URL}/v1/queries`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({ sql }),
});
if (!res.ok) throw new Error(`Query failed: ${res.statusText}`);
return res.json();
}
// Usage
const result = await query(`
SELECT src_ip, SUM(bytes_in) AS total
FROM flow_logs
WHERE date = '2026-03-15'
GROUP BY src_ip
ORDER BY total DESC
LIMIT 10
`);
console.table(result.rows);# One-liner for quick queries
wadjet_query() {
curl -s -X POST http://localhost:8080/v1/queries \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${WADJET_API_KEY}" \
-d "{\"sql\": \"$1\"}" | jq .
}
# Usage
wadjet_query "SELECT COUNT(*) as total FROM flow_logs WHERE date = '2026-03-15'"The HTTP server processes queries concurrently with no built-in rate limiting. For production deployments, place a reverse proxy (e.g., nginx, Caddy, Envoy) in front of Wadjet to enforce rate limits, connection limits, and request timeouts.
| Endpoint | Request | Response |
|---|---|---|
POST /v1/queries |
application/json |
application/json |
GET /v1/queries |
— | application/json |
POST /v1/queries/async |
application/json |
application/json |
GET /v1/queries/{id} |
— | application/json |
GET /v1/queries/{id}/results |
— | application/json |
DELETE /v1/queries/{id} |
— | application/json |
GET /v1/tables |
— | application/json |
GET /v1/tables/{name} |
— | application/json |
GET /v1/health |
— | application/json |
GET /metrics |
— | text/plain (Prometheus) |