Skip to content

Entity Analytics

These independent analytics operations do not use the retired dashboard/KPI surface. Both require the exact analytics:read API-key scope. Neither * nor analytics:* substitutes for this scope.

OperationPOST pathMCP tool
ListCatalog/public-api/publicapi.v1.AnalyticsService/ListCatalogheyx_analytics_catalog
ExecuteSQL/public-api/publicapi.v1.AnalyticsService/ExecuteSQLheyx_analytics_query
ExecuteQuery/public-api/publicapi.v1.AnalyticsService/ExecuteQuery-

The current actor must have Board full access and full access to each referenced domain. Membership, expiry, account/workspace lifecycle and live role authority are checked independently of API scopes. Restricted actors are rejected, not silently given partial totals. Queries run through the standard queued execution, admission, fairness and usage-accounting path. No query or widget is saved.

Call ListCatalog with {} first. It returns authorized entities, field types, operators, aggregations, relationships, current-member support, sqlSupport and engine limits. The current roots are customers, appointments, orders, offers, workorders, problems, payments and appointment_results. Offers are workspace quotes; commercial orders and work orders are different counting grains. fieldlessAggregations lists COUNT and RATIO.

Generated Reference
POST /public-api/publicapi.v1.AnalyticsService/ListCatalog

Lists authorized entities, fields, relationships, operations and limits.

Request AnalyticsCatalogRequest
Response AnalyticsCatalogResponse

AnalyticsCatalogRequest

No fields.

AnalyticsCatalogResponse

Field Type
entities repeated AnalyticsEntityDescriptor
limits AnalyticsLimits
authorization_policy string
fieldless_aggregations repeated AnalyticsAggregation
sql_support string

The MCP tool accepts only a sql string. The public endpoint accepts the same AnalyticsSQLRequest:

{
"sql": "SELECT count(*) AS count FROM customers WHERE heyx.timezone('UTC') LIMIT 100"
}

This is a restricted adapter over the typed query engine, not database SQL. Submitted text is parsed and never sent to PostgreSQL. Use one virtual catalog entity, start WHERE with heyx.timezone('IANA/Zone'), and include LIMIT 1..100. Optional time predicates immediately follow the timezone:

SELECT scheduled_start_at AS day, count(*) AS count
FROM appointments
WHERE heyx.timezone('Europe/Amsterdam')
AND heyx.calendar_range(scheduled_start_at, '2026-08-01', '2026-08-31')
AND status <> 'cancelled'
GROUP BY scheduled_start_at
ORDER BY day ASC NULLS LAST
LIMIT 100

The grammar also supports heyx.timestamp_range, contains, current_member and exact correlated relationship EXISTS. sqlSupport is the authoritative summary. Table aliases, joins, CTEs, arbitrary subqueries/functions, parameters, casts, DML and schema-qualified or physical tables fail closed.

Generated Reference
POST /public-api/publicapi.v1.AnalyticsService/ExecuteSQL

Parses restricted SQL into the typed current-state query contract, then executes that contract. Submitted SQL is never sent to the database.

Request AnalyticsSQLRequest
Response AnalyticsSQLResponse

AnalyticsSQLRequest

Field Type
sql string

AnalyticsSQLResponse

Field Type
result AnalyticsResult

Count non-cancelled appointments by scheduled day in an explicit local period:

{
"query": {
"version": 1,
"entity": "appointments",
"semantics": "ANALYTICS_QUERY_SEMANTICS_CURRENT_STATE",
"timezone": "Europe/Amsterdam",
"timeRange": {
"field": "scheduled_start_at",
"calendar": {"startDate": "2026-08-01", "endDate": "2026-08-31"}
},
"filter": {
"compare": {
"field": "status",
"operator": "ANALYTICS_OPERATOR_NEQ",
"values": [{"text": "cancelled"}]
}
},
"aggregate": {
"measures": [{"aggregation": "ANALYTICS_AGGREGATION_COUNT"}],
"dimensions": [{"field": "scheduled_start_at", "bucket": "ANALYTICS_TIME_BUCKET_DAY"}]
},
"sort": [{"column": "scheduled_start_at_day"}],
"limit": 100
}
}

For raw records, replace aggregate with records: {"fields": ["id", "status"]}. Only one operation is accepted. Sort columns must be selected output column IDs. Unknown fields, operators, oneof combinations, executable input and unsupported semantics fail rather than being ignored.

Filters use all/any groups containing children, typed compare nodes, or related: {relationship, filter}. Each related filter is one correlated existence test, preserving the root grain regardless of matching child count. Predicates inside it match the same child row; separate related nodes can match different rows. Catalogued member fields accept {"currentMember": true}. The primary appointment assignee field does not represent secondary-assignee membership.

COUNT counts root rows and omits field. COUNT_DISTINCT counts non-null values. SUM, AVG, MIN, MAX and PERCENTILE_DISC are available on catalogued compatible fields. Discrete percentile uses percentile: {"unscaled": "95", "scale": 2} for the 95th percentile and selects an original value without money interpolation. Money aggregates require an unbucketed currency dimension. RATIO is the other fieldless aggregation; see Ratios.

Dimension and measure id values are optional. Empty IDs normalize to field for unbucketed dimensions, field_bucket for bucketed dimensions, count for COUNT, and aggregation_field for other measures. Explicit IDs are preserved and must be valid and unique. Generated collisions receive deterministic _2, _3, and later suffixes after all explicit IDs are reserved. Sort may reference these generated aliases; an empty sort column is invalid. The response’s normalizedQuery always includes the resolved IDs.

Calendar dates are inclusive in the query timezone; timestamp ranges use an inclusive start and exclusive end, encoded as RFC3339 strings with at most microsecond precision. Empty timezone uses the workspace timezone. DAY, WEEK (Monday start), MONTH, QUARTER and YEAR buckets require an explicit range. Time buckets are sparse: absent periods are not fabricated zeroes.

timeRange.relative resolves on the server at execution time in the query timezone. Periods are TODAY, THIS_WEEK (ISO Monday start), THIS_MONTH, THIS_QUARTER, THIS_YEAR, and the rolling LAST_7_DAYS, LAST_30_DAYS, LAST_90_DAYS (ending today inclusive) and LAST_12_MONTHS (whole months, ending this month). offset shifts whole periods within -24..24: -1 is the previous period, and rolling periods shift by their own length. The response’s resolvedTimeRange carries the absolute half-open bounds while normalizedQuery keeps the relative form, so a saved query stays current.

{
"timeRange": {
"field": "scheduled_start_at",
"relative": {"period": "ANALYTICS_RELATIVE_PERIOD_THIS_MONTH", "offset": -1}
}
}

Period-over-period comparison is two requests with different offsets, not one query.

On TIMESTAMP fields a comparison value may be {"nowOffsetSeconds": "-86400"}: execution time plus the signed offset, bounded to five years. It is input-only and preserved as-is in normalizedQuery.

{
"compare": {
"field": "created_at",
"operator": "ANALYTICS_OPERATOR_GTE",
"values": [{"nowOffsetSeconds": "-604800"}]
}
}

measure.condition is an ordinary filter (same fields, operators, related, currentMember and nowOffsetSeconds rules as the root filter). Only root rows matching it contribute to that measure, so one grouped query can carry several differently filtered measures. Condition nodes count toward the 100-node filter budget. SUM over no matching rows is NULL; COUNT is zero.

{
"id": "cancelled",
"aggregation": "ANALYTICS_AGGREGATION_COUNT",
"condition": {
"compare": {"field": "status", "operator": "ANALYTICS_OPERATOR_EQ", "values": [{"text": "cancelled"}]}
}
}

ANALYTICS_AGGREGATION_RATIO is fieldless. numerator and denominator name two sibling non-RATIO measures in the same aggregate whose output type is INTEGER or DECIMAL (money, timestamp and text measures are rejected). Omit field, percentile and condition on the ratio itself; put conditions on the sibling measures. The result is a DECIMAL and is NULL when the denominator is zero or NULL. An empty id normalizes to <numerator>_per_<denominator>.

{
"aggregate": {
"measures": [
{"id": "total", "aggregation": "ANALYTICS_AGGREGATION_COUNT"},
{"id": "cancelled", "aggregation": "ANALYTICS_AGGREGATION_COUNT",
"condition": {"compare": {"field": "status", "operator": "ANALYTICS_OPERATOR_EQ", "values": [{"text": "cancelled"}]}}},
{"id": "cancel_rate", "aggregation": "ANALYTICS_AGGREGATION_RATIO", "numerator": "cancelled", "denominator": "total"}
],
"dimensions": [{"field": "scheduled_start_at", "bucket": "ANALYTICS_TIME_BUCKET_WEEK"}]
}
}

Relative ranges, now-relative values, conditional measures and ratios are available only through ExecuteQuery. The restricted SQL grammar used by ExecuteSQL and the heyx_analytics_query MCP tool has no syntax for them.

All queries describe current persisted state. Time filters select current rows; they do not reconstruct historical status, ownership or balances. There are no implicit archive, cancellation, date or personal-scope filters. Historical as-of, event-history reconstruction, funnels, arbitrary formulas beyond sibling ratios, custom fields and arbitrary JSON paths are unsupported.

Generated Reference
POST /public-api/publicapi.v1.AnalyticsService/ExecuteQuery

Executes a typed current-state aggregate or bounded record projection. No saved widget, executable code, history or implicit status filters.

Request AnalyticsQueryRequest
Response AnalyticsQueryResponse

AnalyticsQueryRequest

Field Type
query AnalyticsQuery

AnalyticsQueryResponse

Field Type
result AnalyticsResult

The response is {result} with ordered columns and matching rows[].cells, normalizedQuery, generatedAt, resolvedTimeRange, timezone, status, truncated, warnings and aggregationComplete. SQL responses also include canonical normalizedSql. Preserve the normalized forms for follow-up requests instead of guessing date windows or changing the query grain.

Result status is READY or EMPTY. Cells distinguish READY, NULL and UNAVAILABLE; non-ready cells have no value. An authorized empty COUNT is READY zero. A failed query is an error, never an empty population or zero. Unresolved commercial snapshots make their affected grouped monetary measure UNAVAILABLE rather than silently producing an incomplete amount.

Integer values use quoted int64 JSON strings. Decimal is {unscaled, scale}: its value is unscaled * 10^-scale. Money is {amount: Decimal, currency} in major units. Do not convert exact values through JavaScript Number. Different currencies and one-time, monthly and yearly amounts are never combined automatically.

The engine allows a 32 KiB submitted SQL string, 100 output rows, 100 filter nodes, eight filter levels, two relationship hops, eight measures, three dimensions, 20 projected fields, a 32 KiB protobuf query, a five-year range and a 15-second execution deadline. The public JSON request envelope is limited to 256 KiB. Results are complete core projections, bounded to 256 KiB in public protobuf and JSON. Oversize results fail without partial rows. truncated means more output rows/groups exist; aggregates are computed before that output limit. aggregationComplete does not mean every group was returned or every measure was available.

MCP additionally bounds its combined text and structured-content result envelope to 256 KiB. A result that fits Connect may therefore exceed MCP’s limit. Narrow filters/projection or lower the output limit; an oversize response is not an empty result. Read-only retries re-execute under current data and authority; they do not promise an identical snapshot.