Headless API
What this covers
The headless API is a JSON-in, JSON-out query interface for non-SQL clients. It lets mobile apps, microservices, embedded analytics widgets, and custom integrations query the Tessallite semantic layer without writing SQL. Queries are expressed as lists of measure and dimension names with optional filters, and results come back as JSON arrays. This article covers authentication, the query and metadata endpoints, rate limiting, and worked examples.
When to use headless API vs JDBC/XMLA
| Use case | Recommended interface |
|---|---|
| BI tools (Excel, Power BI, Tableau, DBeaver) | JDBC or XMLA |
| Mobile apps | Headless API |
| Microservice-to-microservice integration | Headless API |
| Embedded analytics in a web app | Headless API |
| Ad-hoc SQL exploration | JDBC |
| Automated reporting scripts | Either (headless is simpler if you don't need SQL) |
The headless API and JDBC/XMLA share the same semantic layer, the same aggregate routing, and the same security model. The difference is the query language: headless uses JSON measure/dimension names; JDBC/XMLA use SQL or DAX.
Authentication
The headless API uses the same JWT tokens as the Tessallite SPA. Obtain a token via the login endpoint:
TOKEN=$(curl -s -X POST http://host:3000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"tenant_id":"acme-demo","email":"admin@acme-demo.com","password":"acme-demo"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
Include the token in all subsequent requests:
Authorization: Bearer $TOKEN
Query endpoint
POST /query-router/api/v1/headless/query
Resolves measure and dimension names against the semantic model, builds SQL via the routing pipeline (aggregate/pocket/source), executes, and returns rows.
Request body:
{
"project_id": "uuid",
"model_id": "uuid",
"measures": ["revenue", "order_count"],
"dimensions": ["region", "order_date"],
"filters": [
{"dimension": "region", "operator": "eq", "value": "US"},
{"dimension": "order_date", "operator": "between", "values": ["2025-01-01", "2025-12-31"]}
],
"limit": 100,
"offset": 0,
"order_by": [{"field": "revenue", "direction": "desc"}]
}
Required fields: project_id, model_id, measures (at least one). The project_id must be the project the model belongs to — a mismatch returns 403.
Optional fields: dimensions, filters, limit, offset, order_by, persona_id.
Choosing a persona: if your user is assigned more than one persona on the model, pass persona_id to say which one the query should run as. Users with a single assigned persona are locked to it automatically, and embed tokens always use the persona baked into the token — in both cases you can leave the field out. The persona's column restrictions and default filters are applied to every query, exactly as they are in the Query Panel and through BI tools.
Filter operators:
| Operator | Description | Value field |
|---|---|---|
eq | Equals | value |
neq | Not equals | value |
gt | Greater than | value |
gte | Greater than or equal | value |
lt | Less than | value |
lte | Less than or equal | value |
in | In list | values (array) |
not_in | Not in list | values (array) |
between | Between (inclusive) | values (2-element array) |
like | SQL LIKE pattern | value |
is_null | Is NULL | (none) |
is_not_null | Is not NULL | (none) |
Friendly aliases. If your client already speaks a Cube-style filter vocabulary, the API accepts these spellings and converts them for you: ne and notEquals mean neq, equals means eq, set means in, inDateRange means between, and contains becomes a like with the value wrapped in %...% (so contains "VI" matches "VISA"). For the scalar operators you may also put the value in values[0] instead of value.
Mistakes fail loudly. An unknown operator, a scalar operator without a value, an empty in list, or a between without exactly two values returns 422 with a message listing what is accepted — the API never guesses and never runs a weaker filter than you asked for.
Response:
{
"columns": ["region", "revenue", "order_count"],
"rows": [
{"region": "US", "revenue": 125000, "order_count": 430},
{"region": "EU", "revenue": 98000, "order_count": 312}
],
"page_row_count": 2,
"total_rows": 2,
"query_id": "a1b2c3d4e5f6g7h8"
}
Reading the response fields.
page_row_countis the number of rows in this page of the result — the count of objects inrows. It is not a total-matching-row count; the API does not run a separateCOUNT(*). To know whether more pages exist, requestlimit + 1rows and check whether you got the extra one.total_rowscarries the same value aspage_row_count. It is kept only for wire back-compat with older clients and will be removed in a future version — preferpage_row_count.query_ididentifies this exact page of this query: it folds inlimit,offsetandorder_by, so two different pages of the same query get different ids. It is safe to use as a result-cache key. It is a stable hash, not a server-side handle — you cannot fetch a result by replaying the id.
Metadata endpoints
GET /query-router/api/v1/headless/models
Lists all models accessible to the authenticated user's tenant.
[
{
"id": "uuid",
"project_id": "uuid",
"slug": "sales-model",
"display_name": "Sales Model",
"description": "Revenue and order metrics"
}
]
GET /query-router/api/v1/headless/models/{model_id}/measures
Lists the measures in the specified model that your persona is allowed to see. If your user has more than one persona on the model, add ?persona_id=<uuid> to pick one — the listing then matches exactly what that persona can query.
[
{
"id": "uuid",
"name": "revenue",
"display_name": "Revenue",
"description": "Total revenue",
"format": "$#,##0",
"aggregation_type": "sum",
"variant_kind": null
}
]
GET /query-router/api/v1/headless/models/{model_id}/dimensions
Lists the dimensions in the specified model that your persona is allowed to see (same ?persona_id= rule as measures). data_type is the source column's database type — useful for choosing sensible filter operators (for example, between for dates and numbers). Calculated dimensions without a source column report null.
[
{
"id": "uuid",
"name": "region",
"display_name": "Region",
"description": "Sales region",
"is_time_dim": false,
"data_type": "character varying"
}
]
Rate limiting
The headless API enforces a per-tenant rate limit (default: 100 requests per minute). When the limit is exceeded, the API returns:
HTTP 429 Too Many Requests
Every response includes the X-RateLimit-Remaining header showing how many requests remain in the current window. Monitor this header to implement client-side throttling. A 429 response carries a Retry-After header with the number of seconds to wait before retrying.
The rate limit is configurable via the HEADLESS_RATE_LIMIT environment variable. The same per-tenant limit and headers apply to the plugin execution endpoint (/query-router/api/v1/plugin/execute); both surfaces draw from one shared per-tenant budget.
Multi-instance note. The limiter counts requests per server process. When the query-router runs as more than one instance (for example, several Cloud Run replicas), each instance keeps its own counter, so the effective per-tenant ceiling is the configured limit multiplied by the number of running instances, and counters reset when an instance is recycled. The limit therefore protects the source from sustained floods but is not an exact per-tenant quota across a horizontally scaled deployment.
Worked example: curl
# 1. Authenticate
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"tenant_id":"acme-demo","email":"admin@acme-demo.com","password":"acme-demo"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
# 2. List models
curl -s http://localhost:3000/query-router/api/v1/headless/models \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 3. List measures for a model
MODEL_ID="<model-uuid-from-step-2>"
curl -s "http://localhost:3000/query-router/api/v1/headless/models/$MODEL_ID/measures" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 4. Query
curl -s -X POST http://localhost:3000/query-router/api/v1/headless/query \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"project_id\": \"<project-uuid>\",
\"model_id\": \"$MODEL_ID\",
\"measures\": [\"revenue\"],
\"dimensions\": [\"region\"],
\"limit\": 10
}" | python3 -m json.tool
Pitfalls
- Forgetting pagination. Without
limit, the API returns up to a hard cap of 100,000 rows — it does not stream an unbounded result set. For large datasets, always set an explicitlimitand page withoffset, and never assume a single response holds every matching row. - Persona restrictions. If the authenticated user's persona restricts certain columns, querying those measures or dimensions returns 403. Check the persona configuration if you get unexpected 403 errors.
- Measure names are semantic names, not display names. Use the metadata endpoint to discover the correct
namefield (e.g.revenue, notRevenue).