HTTP API
| Endpoint | Auth | |
|---|---|---|
POST /v1/query |
yes | run a statement, or a dry run |
GET /v1/schema |
yes | tables, views and columns the caller may query |
GET /v1/databases |
yes | attached databases |
GET /healthz |
no | liveness |
Prometheus /metrics is served on a separate port; see
Metrics.
POST /v1/query
Section titled “POST /v1/query”One statement per request. Parameters are bound server-side.
{"sql": "SELECT * FROM orders WHERE id = $1", "params": [42], "database": "sales", "format": "csv"}| Field | |
|---|---|
sql |
one SQL statement |
params |
values for $1, $2, … |
format |
json (default), csv, ndjson or arrow; also ?format= or Accept |
database |
catalog for unqualified names; defaults to the catalog file’s default |
max_rows |
lowers the row limit for this response; it can never raise it |
dry_run |
inspect and authorize without executing |
Responses
Section titled “Responses”JSON:
{"columns":[{"name":"id","type":"BIGINT"}],"data":[{"id":3}],"row_count":1}arrowis an Arrow IPC stream (application/vnd.apache.arrow.stream) and is the fastest format for large results.DECIMAL,HUGEINTandUUIDare sent as strings, so no precision is lost.- Every response carries
X-Request-Id, the same id as in the audit log.
Row limits
Section titled “Row limits”The smallest of --max-rows, the role’s max_rows and the request’s
max_rows wins. No LIMIT is added to the SQL: the server stops reading
when the limit is reached and signals the cut with X-Curral-Max-Rows and
the trailer X-Curral-Error: row limit reached. For cheap samples, put a
LIMIT in the SQL yourself.
Errors
Section titled “Errors”Before the stream starts, errors are {"error": "..."} with a status:
| Status | |
|---|---|
| 400 | invalid SQL or request |
| 401 | not authenticated |
| 403 | denied by the policy, the engine or row/column protection |
| 429 | over the user’s concurrency quota, or locked out |
| 499 | client disconnected |
| 503 | queue full, or the audit log cannot be written |
| 504 | query timeout |
Once a 200 has been sent, an error goes into the X-Curral-Error trailer
(and an "error" field in JSON). X-Curral-Row-Count carries the total.
What is blocked
Section titled “What is blocked”duckdb_tables(),duckdb_columns()and similar functions,information_schema.*,pg_catalog.*,sqlite_master,SHOW TABLES: 403 for every role. Use/v1/schema.DESCRIBE/SHOWof a table is authorized as a read of that table.- DuckDB’s hints in errors (“Did you mean…?”) can name objects the caller cannot read, so they are stripped from responses (kept in the logs).
GET /v1/schema
Section titled “GET /v1/schema”Lists tables, views and columns, showing each user only what they could
query: every object goes through the same inspection, policy and
protections as SELECT * FROM object.
curl -u analyst https://curral.example.com/v1/schemacurl -u analyst 'https://curral.example.com/v1/schema?database=lake&schema=analytics&table=customers'{"default_database":"lake", "databases":[{"name":"lake","type":"iceberg","schema":"analytics","read_only":false}], "tables":[{"database":"lake","schema":"analytics","name":"customers","kind":"table", "columns":[{"name":"name","type":"VARCHAR","nullable":true}, {"name":"ssn","type":"VARCHAR","nullable":true,"masked":true}], "row_filtered":true}]}- Filters
database,schemaandtableare case-insensitive. maskedandrow_filteredshow the protections that apply to the caller.commentcarriesCOMMENT ONtext when present.
The listing is cached (--schema-cache-ttl, 10 minutes) and filtered per user
in memory, so it uses no DuckDB slot: about 0.5 ms cached versus ~4 s cold
against R2. DDL through curral refreshes it immediately; policy and RLS
changes apply at once.
Dry run
Section titled “Dry run”Add "dry_run": true to see what the policy decides, without executing:
{"dry_run":true,"decision":"deny","decided_by":"policy","statement_type":"DELETE", "database":"sales","tables":["sales.main.orders"],"targets":["sales.main.orders"], "functions":[],"databases":["sales"],"resolved":true,"policy_sha256":"..."}The response also shows limits, row filters and masked columns that would apply. Invalid SQL still returns 400.