Our engine never sees your warehouse password
datahashi

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 by Accept: 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.