Pawrly exposes a JSON-over-HTTP API so any client — curl, a JavaScript app, a webhook, a no-code tool — can query your workspace without a gRPC or MCP client. Like the CLI and MCP server, it's a frontend: it runs the same engine, so it sees exactly the data, caching, and semantic models you do.
The two primary endpoints are POST /v1/sql (raw SQL) and POST /v1/query (a governed semantic query); the rest read the catalog or manage materialized tables.
Running it
The REST API rides the same address as the Console:
pawrly console --addr 127.0.0.1:8787That one process serves the Console UI, gRPC-Web, and REST on the same port. It honors the global engine-selection flags (--config, --home, --remote), so you can point it at a shared daemon. pawrly serve --console does the same.
Without a token it refuses to bind anything but a loopback address. To accept remote connections, require a bearer token — resolved from the config's secret backend or an environment variable of the same name:
pawrly console --addr 0.0.0.0:8787 --bearer-token-from API_TOKENEvery request (except /healthz and the spec endpoints) must then carry Authorization: Bearer <token>. Terminate TLS in front for a public deployment so the token never crosses the wire in cleartext.
Quick start
# raw SQL
curl -s localhost:8787/v1/sql \
-H 'content-type: application/json' \
-d '{"sql":"SELECT status, COUNT(*) AS n FROM data.orders GROUP BY status"}'
# with a bearer token
curl -s localhost:8787/v1/sql \
-H 'authorization: Bearer '"$API_TOKEN" \
-H 'content-type: application/json' \
-d '{"sql":"SELECT 1 AS hello"}'{ "columns": ["hello"], "rows": [{ "hello": "1" }], "row_count": 1, "truncated": false }Endpoints
| Method + path | Purpose |
|---|---|
POST /v1/sql |
Run raw SQL |
POST /v1/query |
Run a governed semantic query |
POST /v1/explain |
Optimized / analyzed plan for a SQL string |
GET /v1/sources |
List sources |
GET /v1/sources/{name} |
One source's info |
GET /v1/tables |
List tables |
GET /v1/tables/{schema}.{table} |
Describe a table |
GET /v1/schema |
Full catalog snapshot |
GET /v1/semantic/models |
List semantic models |
GET /v1/semantic/models/{name} |
Describe a semantic model |
GET /v1/cache |
Cache inventory |
PUT /v1/materialized/{name} |
Create or replace a materialized table |
DELETE /v1/materialized/{name} |
Drop a materialized table |
GET /v1/health |
Engine health report |
GET /healthz |
Liveness (unauthenticated) |
GET /v1/openapi.json / .yaml |
OpenAPI 3.0 spec (unauthenticated) |
POST /v1/sql
Run raw SQL. Body fields:
| Field | Default | Meaning |
|---|---|---|
sql |
— | the query (required) |
params |
{} |
substitutions for ${param:KEY} placeholders |
format |
json |
json, ndjson, or csv |
limit |
1000 |
row cap |
json returns { columns, rows, row_count, truncated } with rows as objects. ndjson streams one JSON object per line (application/x-ndjson); csv is RFC 4180.
curl -s localhost:8787/v1/sql -H 'content-type: application/json' \
-d '{"sql":"SELECT * FROM data.orders LIMIT 2","format":"ndjson"}'{"id":"1","customer":"acme","amount_cents":"1000"}
{"id":"2","customer":"ben","amount_cents":"2500"}POST /v1/query
Run a structured query against the semantic layer — the curated business vocabulary (orders.revenue by orders.status) rather than raw columns. The body is the semantic query; the result is the same { columns, rows, row_count, truncated } shape.
curl -s localhost:8787/v1/query -H 'content-type: application/json' -d '{
"measures": ["orders.revenue"],
"dimensions": ["orders.order_date.month", "orders.status"],
"filters": [{ "member": "orders.status", "op": "equals", "values": ["paid"] }],
"order_by": [{ "member": "orders.order_date.month", "direction": "asc" }],
"limit": 100,
"params": { "tenant_id": "acme" }
}'params binds ${param:NAME} placeholders used by a model's row-level-security predicates. If a model requires one and you omit it, the query is refused before any scan.
POST /v1/explain
Return the query plan for a SQL string. By default it plans without scanning any data; with analyze it runs the query and annotates the plan with runtime metrics. Body fields:
| Field | Default | Meaning |
|---|---|---|
sql |
— | the query to plan (required) |
analyze |
false |
when true, execute and annotate the plan with runtime metrics (EXPLAIN ANALYZE) |
Responds with { "plan": "<text>" }.
curl -s localhost:8787/v1/explain -H 'content-type: application/json' \
-d '{"sql":"SELECT status, COUNT(*) FROM data.orders GROUP BY status"}'{ "plan": "Projection: data.orders.status, count(*)\n Aggregate: groupBy=[[data.orders.status]], aggr=[[count(*)]]\n TableScan: data.orders" }Reading the catalog
curl -s localhost:8787/v1/sources # all sources
curl -s localhost:8787/v1/sources/github # one source
curl -s localhost:8787/v1/tables # all tables
curl -s localhost:8787/v1/tables/github.pulls # one table's schema + wiki
curl -s 'localhost:8787/v1/schema?sources=github,data&compact=true' # whole catalog in one call
curl -s localhost:8787/v1/semantic/models # semantic models
curl -s localhost:8787/v1/cache # cached tables
curl -s localhost:8787/v1/health # { ok, version, active_queries, sources_ok, ... }Materialized tables
Persist a query, file, or URL as a named, self-backed table queryable as materialized.<name> (see materialized tables). PUT is create-or-replace by name; the body is the spec, tagged by kind:
# materialize a query result
curl -s -X PUT localhost:8787/v1/materialized/top_customers \
-H 'content-type: application/json' \
-d '{"kind":"query","sql":"SELECT * FROM data.customers ORDER BY revenue DESC LIMIT 100"}'
# or a local / remote file
# {"kind":"file","path":"./snapshots/q3.parquet"}
# {"kind":"url","url":"https://example.com/data.csv","format":"csv"}
# drop it
curl -s -X DELETE localhost:8787/v1/materialized/top_customersPUT returns { name, file_path, row_count, size_bytes }. DELETE returns { "dropped": true, "name": "<name>" }, or 404 if no such table exists.
Errors
Every error is a JSON envelope with a stable PAWRLY_* code and an HTTP status:
{ "error": { "code": "PAWRLY_SAFETY_REQUIRED_FILTER",
"message": "refusing to scan `tvmaze` without a filter on `request_path`" } }| Status | When |
|---|---|
400 |
invalid SQL, plan error, or a safety violation |
401 |
missing or invalid bearer token |
404 |
unknown table, source, or materialized table |
408 |
query timeout |
499 |
client cancelled the request |
500 |
internal error |
503 |
engine out of memory |
The same source-level safety policies (require_filters_on, max_unfiltered_rows, query timeouts) apply over REST, and the API is read-only for data — INSERT/UPDATE/DELETE/DDL are refused.
OpenAPI spec
The full contract is published as an OpenAPI 3.0 document, served live:
curl -s localhost:8787/v1/openapi.json # JSON
curl -s localhost:8787/v1/openapi.yaml # YAMLPoint Swagger UI, Postman, or a client generator at it to explore the API or generate a typed client.