Query analytics with VoidQL
Run read-only SQL over a project's events, people, purchases and catalog from the API or an AI agent.
VoidQL is a read-only dialect of SQL over a project's Voidhash data: events, people, subscriptions, transactions, the catalog, paywalls, experiments, push and ads. Use it from your backend, scripts or an AI agent when a built-in report doesn't answer your question. Studio's Analytics → Query page runs the same language; see Query with VoidQL in the Help center.
The Query page is in early access. Ask us on Discord, or email support@voidhash.com on a paid plan, to turn it on for your team.
The API operations and MCP tools need no early access, and they work on a self-hosted server too.
| Where | Scope | Rows returned |
|---|---|---|
POST /api/v1/projects/{projectId}/analytics/voidql/query | One project | 100 by default, up to 10,000 with limit |
voidql_query MCP tool | One project | 100 by default, up to 1,000 |
| Analytics → Query in Studio (Voidhash Cloud) | Every project in the team whose analytics you can read | Up to 100,000 |
Every operation needs read access to Analytics (project:analytics:read). See
API keys.
Run a query
curl https://api.voidhash.com/api/v1/projects/$VOIDHASH_PROJECT_ID/analytics/voidql/query \
-H "Authorization: Bearer $VOIDHASH_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "SELECT store, count() AS active FROM subscriptions WHERE is_active GROUP BY store", "limit": 100 }'The response has columns (each with name and type), rows (one object per row) and
isTruncated, which is true when the query had more rows than limit. Times come back as UTC
text. A query changes nothing, so it is safe to repeat. From a terminal, run
voidhash api call voidql.query --project my-app --body '{"query": "…"}'.
Two more operations help you write queries:
GET …/analytics/voidql/schemalists every table and its columns, with types, descriptions and which columns are personal data, plus the functions a query may call. Thevoidql_schemaMCP tool returns the same, with the rules and example queries.POST …/analytics/voidql/explainand thevoidql_explainMCP tool check a query without running it: the columns it returns, the tables and projects it reads, whether it reads personal data, and an estimate of the rows it would scan.
Syntax
VoidQL is a subset of ClickHouse SELECT. One statement per query, with an optional trailing ;.
- Clauses:
WITH,SELECT [DISTINCT],FROM,JOIN(INNER,LEFT,RIGHT,FULL,CROSS, withONorUSING),WHERE,PREWHERE,GROUP BY(withROLLUP,CUBEorTOTALS),HAVING,QUALIFY,ORDER BY,LIMIT … OFFSET,LIMIT … BY. - Set operators:
UNION ALL,UNION DISTINCT,INTERSECT,EXCEPT. A bareUNIONis an error. - Subqueries in
FROM(with an alias),IN (SELECT …),EXISTS (SELECT …)and as scalars. - Window functions:
fn(…) OVER (PARTITION BY … ORDER BY … ROWS BETWEEN … AND …). - Literals: strings in single quotes (
''escapes a quote), numbers,TRUE,FALSE,NULL. - Case: keywords, table names and function names are case-insensitive; column names are not.
Only listed functions are allowed. They include:
| Kind | Functions |
|---|---|
| Aggregate | count, countIf, countDistinct, sum, sumIf, avg, avgIf, min, max, any, argMin, argMax, median, quantile, percentile, stddevPop, varPop, corr |
| Window | row_number, rank, dense_rank, lagInFrame, leadInFrame, first_value, last_value |
| Conditional | if, multiIf, coalesce, ifNull, nullIf, greatest, least |
| Text and URL | lower, upper, length, trim, concat, substring, startsWith, endsWith, position, replaceAll, match, toString, domain, path, extractURLParameter |
| Date | now, today, toDate, toStartOfDay (and of hour, week, month, quarter, year), addDays, subtractDays (and minutes to years), dateDiff, toYear, toMonth, toDayOfWeek, toHour, toTimeZone |
| Math | round, floor, ceil, abs, sqrt, pow, exp, log |
| Conversion | toFloat64OrNull, toInt64OrNull, toDateOrNull |
count(DISTINCT x) works as countDistinct(x). The schema operation returns the full list.
Times are UTC. Date functions take a time zone as their last argument:
toStartOfDay(event_ts, 'Europe/Prague'). JSON keys read as text with a dot:
properties.$app_version on events, traits.plan on people. There are no query parameters, so
build values into the query text and escape quotes.
Tables
Every table is scoped to the project automatically; a query can't set or widen the scope.
| Data | Tables |
|---|---|
| Events and people | events, people, people_history, person_traits, identities, person_facts, touches, cohorts, cohort_members |
| Purchases | subscriptions, subscription_history, transactions, purchases, entitlements, access_shares, checkout_sessions, store_notifications, exchange_rates |
| Metrics as Studio counts them | revenue, mrr_movements, subscription_changes, trial_conversions |
| Catalog | projects, products, perks, product_perks, store_products |
| Paywalls and experiments | paywalls, paywall_releases, placements, placement_showings, experiments, experiment_variants, feature_flags, feature_flag_history, feature_flag_variants, feature_flag_overrides |
| Push | push_devices, push_device_owners, push_sends, push_deliveries |
| Ads | ad_accounts, ads, ad_creatives, ad_issues, ad_changes, ad_spend |
| Operations | tracking_domains, webhook_endpoints, webhook_deliveries, webhook_delivery_attempts, dead_letters, deletion_requests |
Know these before you count money or subscriptions:
- Environments are rows. Add
environment = 'production'for real revenue and subscriptions. - Money is in minor units.
*_amountcolumns are in the row's currency and*_usdcolumns in US cents.revenue.amount_usdis in US dollars, andad_spendin millionths of the ad account's currency. VoidQL doesn't convert to your reporting currency. - Balances add up over time. MRR at a moment is
sum(mrr_change_usd)of every movement up to it, so filter the range after summing. - A
LEFT JOINwithout a match reads'',0or1970-01-01, notNULL, except for columns the schema marks as nullable.
MRR by month, from its movements:
WITH monthly AS (
SELECT toStartOfMonth(event_ts) AS month, sum(mrr_change_usd) AS net_new
FROM mrr_movements WHERE environment = 'production' GROUP BY month
)
SELECT month, net_new / 100 AS net_new_usd,
sum(net_new) OVER (ORDER BY month) / 100 AS mrr_usd
FROM monthly ORDER BY monthPersonal data
People's email, name and traits, and event keys that identify a person, such as properties.$email
or context.$ip, also need read access to Customers (project:persons:read). Without it, the
whole query is rejected with personal_data. people.id identifies a person without it.
Limits
| Limit | Value |
|---|---|
| Query length | 50,000 characters |
| Run time | 20 seconds |
| Memory | 2 GB per query |
| Joins | 8 per SELECT |
| Subqueries | 16 per query |
| Queries running at once | 4 per team |
| Quota | 60 queries a minute and 1,000 an hour per team |
A self-hosted server applies the quota only once you create its read-only analytics users; see Analytics users.
Statements other than SELECT, such as INSERT, DROP or SETTINGS, are rejected before they
run.
Errors
A query that can't run fails with an error code and a message that says what to change:
| Status | Code | Meaning |
|---|---|---|
| 400 | voidql_syntax | The query doesn't parse. |
| 400 | voidql_unsupported | It uses a keyword or function VoidQL doesn't have. |
| 400 | voidql_unknown_relation, voidql_unknown_field | A table or column doesn't exist. param names the field, and the message suggests the closest name. |
| 400 | voidql_too_complex | Too many joins, subqueries or nesting levels. |
| 400 | voidql_rejected | The database refused it, usually over a column type. |
| 403 | personal_data | It reads personal data without project:persons:read. |
| 429 | voidql_quota | Your team ran too many queries, or 4 are already running. Try again shortly. |
| 503 | voidql_timeout, voidql_memory, voidql_too_large | It ran too long, used too much memory or would read too much. Narrow the time range or aggregate first. |
| 503 | voidql_unavailable | The query service couldn't be reached. Retry with backoff. |
Next steps
- MCP tools for analytics: let an AI agent write and run queries.
- Events: what
eventsholds and how it's captured. - Metric definitions: how Studio computes MRR, churn and the other metrics.
- Query with VoidQL in Studio.
Something wrong or missing on this page? Tell us by email or on Discord.