developerUpdated 2026-05-04

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 caseRecommended interface
BI tools (Excel, Power BI, Tableau, DBeaver)JDBC or XMLA
Mobile appsHeadless API
Microservice-to-microservice integrationHeadless API
Embedded analytics in a web appHeadless API
Ad-hoc SQL explorationJDBC
Automated reporting scriptsEither (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:

OperatorDescriptionValue field
eqEqualsvalue
neqNot equalsvalue
gtGreater thanvalue
gteGreater than or equalvalue
ltLess thanvalue
lteLess than or equalvalue
inIn listvalues (array)
not_inNot in listvalues (array)
betweenBetween (inclusive)values (2-element array)
likeSQL LIKE patternvalue
is_nullIs NULL(none)
is_not_nullIs 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.

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

Related