Analytics query cookbook

Copy-paste recipes for common questions. Each is the query value you send to POST /query. See the Schema reference for every column.

Reminder: monetary columns are cents (divide by 100; fact_cost.cost_amount is fractional cents), credit amounts are decimal counts of credits (never divided by 100), UInt64/Int64 results come back as JSON strings, and Decimal results come back as JSON numbers (wrap in toString() for exact digits beyond 2^53).

Prerequisites

  • A Paid API key with access to your organization’s Analytics API.
  • Signals, costs, delivered value, invoices, or credit activity already present for the tables you want to query.
  • Familiarity with the table names and joins in the Schema reference.

Usage and signals

Total signals in the last 30 days

1SELECT count() AS signals
2FROM fact_signal
3WHERE created_at > now() - INTERVAL 30 DAY

Signal volume by type

1SELECT signal_name, count() AS signals
2FROM fact_signal
3WHERE created_at > now() - INTERVAL 30 DAY
4GROUP BY signal_name
5ORDER BY signals DESC

Daily signal time-series

1SELECT toDate(created_at) AS day, count() AS signals
2FROM fact_signal
3WHERE created_at > now() - INTERVAL 30 DAY
4GROUP BY day
5ORDER BY day

Signals by customer (resolving names)

1SELECT c.name AS customer, count() AS signals
2FROM fact_signal s
3JOIN dim_customer c ON s.customer_id = c.id
4WHERE s.created_at > now() - INTERVAL 30 DAY
5GROUP BY customer
6ORDER BY signals DESC
7LIMIT 20

Querying signal metadata

Signals carry a free-form JSON data payload. Access a field with data.<path> and cast it to a type. First discover available keys with GET /signals/metadata, then filter or group on them.

Casting a missing key with ::String yields an empty string (''), not NULL, so a '' bucket in a grouped result represents signals that don’t carry that key.

Group by a string metadata field

1SELECT data.opportunity_stage::String AS stage, count() AS signals
2FROM fact_signal
3WHERE created_at > now() - INTERVAL 30 DAY
4GROUP BY stage
5ORDER BY signals DESC

Filter on a metadata field

1SELECT count() AS completed_calls
2FROM fact_signal
3WHERE signal_name = 'call'
4 AND data.call_status::String = 'completed'
5 AND created_at > now() - INTERVAL 7 DAY

Aggregate a numeric metadata field

1SELECT
2 data.account_tier::String AS tier,
3 avg(data.call_length_sec::Float64) AS avg_call_seconds
4FROM fact_signal
5WHERE signal_name = 'call'
6 AND created_at > now() - INTERVAL 30 DAY
7GROUP BY tier

AI cost

cost_amount is exact fractional cents (a typical AI call costs a fraction of a cent), so SUM(cost_amount) / 100 gives exact dollars and matches the Cost Explorer in the Paid UI.

Total spend (dollars) by vendor

1SELECT vendor, count() AS calls, sum(cost_amount) / 100 AS dollars
2FROM fact_cost
3WHERE start_time > now() - INTERVAL 30 DAY
4GROUP BY vendor
5ORDER BY dollars DESC

Spend by model with token usage

1SELECT
2 model,
3 count() AS calls,
4 sum(input_tokens) AS input_tokens,
5 sum(output_tokens) AS output_tokens,
6 sum(cost_amount) / 100 AS dollars
7FROM fact_cost
8WHERE start_time > now() - INTERVAL 30 DAY
9GROUP BY model
10ORDER BY dollars DESC

Daily cost trend

1SELECT toDate(start_time) AS day, sum(cost_amount) / 100 AS dollars
2FROM fact_cost
3WHERE start_time > now() - INTERVAL 30 DAY
4GROUP BY day
5ORDER BY day

Cost by customer

1SELECT c.name AS customer, sum(co.cost_amount) / 100 AS dollars
2FROM fact_cost co
3JOIN dim_customer c ON co.customer_id = c.id
4WHERE co.start_time > now() - INTERVAL 30 DAY
5GROUP BY customer
6ORDER BY dollars DESC
7LIMIT 20

Cost per signal (joining usage and cost)

Signals and costs share a trace_id, so you can attribute AI cost to the signal that incurred it.

1SELECT
2 s.signal_name,
3 count(DISTINCT s.signal_id) AS signals,
4 sum(co.cost_amount) / 100 AS dollars,
5 sum(co.cost_amount) / 100 / count(DISTINCT s.signal_id) AS dollars_per_signal
6FROM fact_signal s
7JOIN fact_cost co ON s.trace_id = co.trace_id
8WHERE s.created_at > now() - INTERVAL 30 DAY
9GROUP BY s.signal_name
10ORDER BY dollars DESC

Revenue

Monthly revenue (posted invoices)

1SELECT
2 toStartOfMonth(issue_date) AS month,
3 sum(total_incl_tax) / 100 AS revenue
4FROM fact_invoice
5WHERE status = 'posted'
6 AND issue_date > now() - INTERVAL 12 MONTH
7GROUP BY month
8ORDER BY month

Revenue by customer

1SELECT c.name AS customer, sum(i.total_incl_tax) / 100 AS revenue
2FROM fact_invoice i
3JOIN dim_customer c ON i.customer_id = c.id
4WHERE i.status = 'posted'
5GROUP BY customer
6ORDER BY revenue DESC
7LIMIT 20

Revenue by product (invoice lines → order lines → products)

1SELECT p.name AS product, sum(il.total_after_tax) / 100 AS revenue
2FROM fact_invoice_line il
3JOIN fact_order_line ol ON il.order_line_id = ol.id
4JOIN dim_product p ON ol.product_id = p.id
5GROUP BY product
6ORDER BY revenue DESC

Fixed-fee vs usage revenue split (posted invoices)

charge_type is usage for metered lines and oneTime/recurring/seatBased for fixed charges. It is empty on manual lines (no linked pricing) and on lines predating charge-type attribution. Bucket those as unknown rather than folding them into fixed, or they will inflate the fixed-fee share.

1SELECT
2 multiIf(
3 il.charge_type = 'usage', 'usage',
4 il.charge_type IN ('oneTime', 'recurring', 'seatBased'), 'fixed',
5 'unknown'
6 ) AS billing_basis,
7 count() AS lines,
8 sum(il.total_after_tax) / 100 AS revenue
9FROM fact_invoice_line il
10JOIN fact_invoice i ON il.invoice_id = i.id
11WHERE i.status = 'posted'
12GROUP BY billing_basis
13ORDER BY revenue DESC

A non-trivial unknown total means manual or unattributed lines carry real revenue. Inspect them before treating the fixed-vs-usage split as complete. For the full breakdown, group by il.charge_type directly (oneTime / recurring / seatBased / usage, plus empty for unattributed).

Outstanding / overdue invoices

1SELECT c.name AS customer, sum(i.amount_due) / 100 AS outstanding
2FROM fact_invoice i
3JOIN dim_customer c ON i.customer_id = c.id
4WHERE i.payment_status IN ('pending', 'partiallyPaid', 'overdue')
5GROUP BY customer
6ORDER BY outstanding DESC

Payments and cash flow

1SELECT toDate(payment_date) AS day, sum(amount) / 100 AS collected
2FROM fact_payment
3WHERE payment_status = 'posted'
4 AND payment_date > now() - INTERVAL 30 DAY
5GROUP BY day
6ORDER BY day

Billed usage

Which signals drive billed revenue

1SELECT signal_name, sum(subtotal) / 100 AS billed
2FROM fact_usage_summary
3WHERE status = 'billed'
4GROUP BY signal_name
5ORDER BY billed DESC

Overage (usage above the included allowance)

1SELECT
2 signal_name,
3 sum(signal_count) AS used,
4 sum(included_quantity) AS included,
5 sum(greatest(signal_count - included_quantity, 0)) AS overage
6FROM fact_usage_summary
7WHERE included_quantity IS NOT NULL
8GROUP BY signal_name

Credits

Credit amounts and balances are Decimal(30, 6), not integers: a single spend can be a fraction of a credit, and they are counts of credits rather than cents, so never divide them by 100. As with fractional cents, round after aggregating rather than per row.

Net credit flow by currency

1SELECT cc.name AS currency, sum(t.amount) AS net_credits
2FROM fact_credit_transaction t
3JOIN dim_credits_currency cc ON t.credits_currency_id = cc.id
4GROUP BY currency

Top credit-spending signals

1SELECT signal_name, -sum(amount) AS credits_spent
2FROM fact_credit_transaction
3WHERE type = 'SPEND'
4 AND created_at > now() - INTERVAL 30 DAY
5GROUP BY signal_name
6ORDER BY credits_spent DESC

Credit consumption by user within a customer

If your signals include external_user_id in the data payload (see First signals), you can break down credit spend per user:

1SELECT
2 s.data.external_user_id::String AS external_user_id,
3 cc.name AS currency,
4 -sum(t.amount) AS credits_spent
5FROM fact_credit_transaction t
6JOIN fact_signal s ON t.signal_mongo_id = s.signal_id
7JOIN dim_credits_currency cc ON t.credits_currency_id = cc.id
8WHERE t.type = 'SPEND'
9 AND t.created_at > now() - INTERVAL 30 DAY
10GROUP BY external_user_id, currency
11ORDER BY credits_spent DESC

Delivered value

delivered_value_cents is denominated in each row’s delivered_value_currency, which is not always USD: organizations using value models can deliver value in EUR, GBP, and other currencies. Always group by (or filter on) delivered_value_currency; summing cents across currencies produces meaningless totals. Cents are exact and may carry fractions, so round after aggregating.

Value delivered by type

1SELECT
2 value_type_name,
3 delivered_value_currency AS currency,
4 round(sum(delivered_value_cents) / 100, 2) AS value_amount,
5 sum(unit_value) AS total_units
6FROM fact_delivered_value
7WHERE created_at > now() - INTERVAL 30 DAY
8GROUP BY value_type_name, delivered_value_currency
9ORDER BY value_amount DESC

Value vs. cost (ROI) by customer

1SELECT
2 c.name AS customer,
3 dv.delivered_value_currency AS currency,
4 round(sum(dv.delivered_value_cents) / 100, 2) AS value_amount
5FROM fact_delivered_value dv
6JOIN dim_customer c ON dv.customer_id = c.id
7WHERE dv.created_at > now() - INTERVAL 30 DAY
8GROUP BY customer, currency
9ORDER BY value_amount DESC
10LIMIT 20

Paginating large result sets

Results are capped at 10,000 rows. For larger extracts, page with a WHERE filter on an ordered column (keyset pagination) rather than a large OFFSET:

1SELECT signal_id, signal_name, created_at
2FROM fact_signal
3WHERE created_at < '2026-06-01 00:00:00' -- last value from the previous page
4ORDER BY created_at DESC
5LIMIT 1000