REST API
HTTP API for query execution, catalog discovery, drill-down, and dimension values.
Updated
The datahashi engine serves governed queries over HTTP. Data endpoints require Authorization: Bearer <key>. The key is ENGINE_API_KEY or a JWT.
The engine never accepts raw SQL in the body. It never accepts a tenant or a principal in the body. Identity comes from the verified token only.
Every body accepts version. Omit it to use the current published model.
Endpoints
| Path | Method | Auth | Description |
|---|---|---|---|
/healthz |
GET | None | Liveness check |
/v1/openapi.yaml |
GET | None | OpenAPI 3.1 schema |
/.well-known/oauth-protected-resource |
GET | None | RFC 9728 MCP OAuth discovery |
/v1/query |
POST | Bearer | Run a Semantic Query (JSON or streaming CSV) |
/v1/drill-down |
POST | Bearer | Turn one aggregated cell into explore rows |
/v1/catalog |
POST | Bearer | List what this caller may query |
/v1/dimension-values |
POST | Bearer | Live distinct values of one dimension |
/v1/hierarchies/expand |
POST | Bearer | One drill step along a hierarchy |
/v1/lineage |
POST | Bearer | What a model member depends on |
/v1/model-tests |
POST | Bearer | Run the model’s own tests |
/mcp |
GET/POST | Bearer | Model Context Protocol streamable HTTP listener |
1. Run a query (POST /v1/query)
Runs a structured query against the semantic model.
Query parameters
format=csv: Stream the result as CSV instead of JSON. Also triggered byAccept: text/csv.
Request body
{
"measures": ["Order.revenue", "Order.order_count"],
"dimensions": [
"Customer.region",
{ "name": "order_date", "entity": "Order", "grain": "month" }
],
"filters": [
{ "dimension": "status", "entity": "Order", "op": "eq", "value": "completed" }
],
"having": [
{ "ref": "revenue", "op": "gt", "value": 5000 }
],
"order": [
{ "ref": "revenue", "desc": true }
],
"limit": 100,
"offset": 0,
"time_zone": "UTC"
}
filters are combined with AND. Nested boolean logic goes in filter_group, not inside filters. See Semantic query syntax.
include_sql returns the compiled SQL only when the engine runs with ENGINE_ALLOW_SQL=true. include_resolved is an MCP field. The REST body does not use it.
JSON response (200 OK)
{
"columns": [
{ "name": "region", "type": "string", "kind": "dimension" },
{ "name": "order_date", "type": "timestamp", "kind": "dimension" },
{ "name": "revenue", "type": "decimal", "kind": "measure" },
{ "name": "order_count", "type": "int", "kind": "measure" }
],
"rows": [
["na", "2026-03-01T00:00:00Z", 45200, 310],
["emea", "2026-03-01T00:00:00Z", 38100, 275]
],
"row_count": 2,
"truncated": false,
"meta": {
"model_version": "m1:…",
"duration_ms": 41,
"roles": ["analyst"],
"rollup": { "used": "monthly_order_summary" }
}
}
columns lines up with each row: dimensions, then metrics, then measures. columns[].type is int, float, decimal, string, bool, date, or timestamp. columns[].kind is dimension, measure, or metric. A metric is float. A sum is decimal. A count is int. There is no separate annotation array.
Whole amounts are JSON integers. A ratio is not rounded. REST always includes resolved. MCP includes it only when include_resolved is true.
truncated is true when a positive limit was hit exactly, or when Query Runner stopped at its own max_rows.
2. Drill-down (POST /v1/drill-down)
Turns one aggregated cell into the explore rows behind it. measure is a raw measure token, never a metric. CSV is supported.
Request body
{
"measure": "Order.revenue",
"cell": [
{ "dimension": "region", "entity": "Customer", "value": "na" },
{ "dimension": "order_date", "entity": "Order", "grain": "month", "value": "2026-03-01" }
],
"limit": 50
}
A cell value with grain becomes a half-open range [start, next). A cell value without grain is an equality.
3. Catalog discovery (POST /v1/catalog)
Returns the metrics, measures, dimensions, relationships, rollups, and hierarchies this caller may use. A name that is absent cannot be queried. Unknown and ungranted names look the same.
Request body
{}
Send { "version": "<published version>" } to pin a model version. The body has no model, entity, or tags field. The MCP tool list_catalog accepts an entity filter. This route does not.
Response (200 OK)
{
"version": "current",
"entities": ["Customer", "Order"],
"metrics": [
{
"name": "revenue",
"description": "Total completed order amount",
"default_time_dimension": "order_date",
"entities": ["Order"],
"tags": ["finance"]
}
],
"dimensions": [
{
"name": "region",
"entity": "Customer",
"type": "string",
"values": ["na", "emea", "apac"]
}
],
"measures": [],
"relationships": [],
"rollups": [],
"hierarchies": [],
"collisions": []
}
entities is a list of names. Dimensions are not nested inside entities. collisions lists bare dimension names that exist on more than one granted entity. Those dimensions also set ambiguous to true.
4. Dimension values (POST /v1/dimension-values)
Lists live distinct values for one dimension. Results follow row-level security. The cap is ENGINE_DIMENSION_VALUES_CAP.
Request body
{
"entity": "Customer",
"dimension": "region",
"q": "em",
"limit": 50
}
q is a case-insensitive substring.
Response (200 OK)
{
"values": ["emea"],
"truncated": false
}
5. Other routes
| Route | Body | Result |
|---|---|---|
/v1/hierarchies/expand |
{hierarchy, path?, metrics?, measures?, filters?, segments?, limit?, time_zone?} |
One drill step. Same shape as /v1/query. |
/v1/lineage |
{node?, direction?} |
node is an id such as metric:revenue. direction is upstream or downstream. |
/v1/model-tests |
{names?} |
Runs tests declared on the model. An unknown name is 400. |
Errors
Every error is one flat object. error is a string, not an object.
{
"error": "unknown metric",
"code": "not_found",
"reason": "unknown_metric",
"ref": "unknown_metric",
"suggestions": ["revenue"]
}
| Field | Meaning |
|---|---|
code |
Coarse class. See the table below. |
reason |
Stable machine token. Clients may ignore it. |
ref |
The offending name, when one exists. |
suggestions |
Close valid names, when the engine has them. |
| HTTP Status | code |
Cause |
|---|---|---|
400 Bad Request |
invalid |
Bad JSON, unknown name in a filter, bad grain, or bad time zone |
401 Unauthorized |
unauthenticated |
Missing or invalid Bearer token |
403 Forbidden |
forbidden |
Not permitted, workspace suspended, row filter could not be applied, or runner policy denied |
404 Not Found |
not_found |
Unknown or ungranted name or version. The two look the same. |
408 Request Timeout |
request_timeout |
The request deadline passed |
413 Payload Too Large |
request_too_large |
Request body exceeds 1 MB |
429 Too Many Requests |
over_budget |
ENGINE_MAX_BYTES or the tenant budget was exceeded |
499 Client Closed Request |
request_canceled |
The client went away |
502 Bad Gateway |
bad_gateway |
Runner refused this engine (bad key, source, or CIDR). reason is runner_misconfigured. |
503 Service Unavailable |
overloaded or unavailable |
Admission queue is full, or the runner is paused or the warehouse is unreachable |
504 Gateway Timeout |
gateway_timeout |
The runner or the warehouse timed out |
500 Server Error |
engine_error |
Unexpected internal error. The message is masked. |
A 503 from a full lane or a paused runner includes Retry-After. A paused runner uses Retry-After: 30.
Next: Semantic model YAML · Semantic query syntax.