Skip to content

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.

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

JSON:

{"columns":[{"name":"id","type":"BIGINT"}],"data":[{"id":3}],"row_count":1}
  • arrow is an Arrow IPC stream (application/vnd.apache.arrow.stream) and is the fastest format for large results.
  • DECIMAL, HUGEINT and UUID are sent as strings, so no precision is lost.
  • Every response carries X-Request-Id, the same id as in the audit log.

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.

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.

  • duckdb_tables(), duckdb_columns() and similar functions, information_schema.*, pg_catalog.*, sqlite_master, SHOW TABLES: 403 for every role. Use /v1/schema.
  • DESCRIBE/SHOW of 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).

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.

Terminal window
curl -u analyst https://curral.example.com/v1/schema
curl -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, schema and table are case-insensitive.
  • masked and row_filtered show the protections that apply to the caller.
  • comment carries COMMENT ON text 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.

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.