Analytics
Every chart, tile and export in tmbilling is answered by one read. You ask for a measure over a window, optionally cut by one dimension, and the response describes itself: its unit, its currency, whether it is a value at an instant or a sum over a period, when it was computed, and — where the history is only partly known — exactly which periods are missing.
There are three calls.
/v1/analytics/measures/v1/analytics/v1/analytics/queryStart from discovery, not from a list
Ask what exists before asking for it. A measure that is not in this payload does not exist, and new ones appear here before anything else announces them — so a client that renders its picker from this response can never offer a cut the engine would refuse.
{
"object": "list",
"data": [
{
"id": "mrr",
"name": "Monthly recurring revenue",
"description": "The rate the live book bills each month, measured at the instant asked for. Per currency, never summed across currencies.",
"kind": "stock",
"unit": "money",
"aggregate": "last",
"dimensions": ["plan", "country", "currency", "business"],
"grains": ["day", "week", "month", "quarter", "year"],
"currency": "per_currency",
"supports_compare": true
}
]
}
The measures available today are the MRR ledger and the subscription stocks:
| Measure | Kind | Unit | What it is |
|---|---|---|---|
mrr | stock | money | The rate the live book bills each month, at the instant asked for. |
cmrr | stock | money | Recurring revenue under commitment, including terms already booked to stop. Not sliceable by country. |
active_subscriptions | stock | count | Subscriptions live at that instant, revenue or not. |
paying_subscriptions | stock | count | Active subscriptions carrying revenue above zero and set to renew. |
mrr_new | flow | money | Recurring revenue from subscriptions starting in the period. |
mrr_expansion | flow | money | Increases on subscriptions that were already live. |
mrr_reactivation | flow | money | Revenue from subscriptions that had stopped and resumed. |
mrr_contraction | flow | money | Decreases on subscriptions that stayed live. |
mrr_churn | flow | money | Revenue lost to cancellation. |
mrr_paused | flow | money | Revenue removed by a pause. A pause is not churn and is reported apart from it. |
mrr_fx_impact | flow | money | How much a converted MRR total moved because exchange rates did. Signed; 0 in a single-currency answer. |
Losses are positive magnitudes: mrr_churn of 120000 means ₹1,200 was lost, not gained.
In a converted answer the movements are each converted at their own day's rate and MRR at the
day it is read, so opening + movements + mrr_fx_impact = closing, to the minor unit — except
for a resumed pause, which no flow measures and which is never counted as FX. The flows
are stated in the currency of the MRR book: a window whose only churn was in INR still answers in
the base currency when the book spans two.
Stock versus flow
This is the distinction that produces a wrong chart which looks right.
- A stock is a value at an instant.
mrrfor September is the MRR on the last day of September — never the sum of September's days, which would inflate it about thirtyfold. - A flow is a sum over a window.
mrr_newfor September is every start in September added up.
The response echoes kind on every answer, and total_basis says which rule produced the headline
figure: value_at_end for a stock, sum_over_window for a flow.
kind — it is on the response precisely so you do not have to know which
measure you were handed.Units
unit is on every response and is never implied.
unit | It holds | Read it as |
|---|---|---|
money | Integer minor units in the stated currency | 4820640 with "currency": "INR" is ₹48,206.40 |
count | A whole number | 312 subscriptions |
bps | Basis points | 480 is 4.8% |
ratio | value over denominator | Divide at read time |
Money is never converted and never summed across currencies here. There is no exchange rate at this layer, and inventing one would produce a figure nobody could reproduce. If a window holds more than one currency, the call is refused until you name one:
{
"error": {
"type": "invalid_request_error",
"message": "Monthly recurring revenue is money held per currency and is never summed across them. Ask for one of: INR, USD."
}
}
Counts are different — a subscription has exactly one currency, so counting across currencies counts
each one once. currency_basis on the response says which happened: per_currency or
across_currencies.
Windows resolve in a real timezone
range takes a named window — 7d, 30d, 90d, 6m, 12m, this_month, last_month,
this_quarter, this_year — or you give explicit start and end dates. The two are mutually
exclusive.
"This month" is not the same month everywhere, so a named window resolves in the business's
timezone when you pass business, and the organization's otherwise. The response says which:
"range": {
"name": "12m",
"start": "2025-10-01",
"end": "2026-09-20",
"timezone": "Asia/Kolkata",
"timezone_source": "business"
}
grain cuts that window into periods independently of how far back it reaches. The first and last
period are clipped to the window, so a month in progress ends today rather than at its calendar end.
Worked example: one stock
/v1/analytics?measure=mrr&grain=month&range=12m¤cy=INR&business=biz_2Nk4{
"object": "analytics_result",
"measure": "mrr",
"name": "Monthly recurring revenue",
"kind": "stock",
"unit": "money",
"aggregate": "last",
"currency": "INR",
"currency_basis": "per_currency",
"scope": "business",
"business": "biz_2Nk4",
"grain": "month",
"range": { "name": "12m", "start": "2025-10-01", "end": "2026-09-20", "timezone": "Asia/Kolkata", "timezone_source": "business" },
"as_of": "2026-09-20T06:03:11.402Z",
"computed_at": "2026-09-20T05:58:00.118Z",
"series": [
{ "t": "2026-08-31", "label": "Aug 2026", "value": 4599200 },
{ "t": "2026-09-20", "label": "Sep 2026", "value": 4820640 }
],
"total": 4820640,
"total_basis": "value_at_end",
"freshness": { "includes_current_period": true, "max_age_seconds": 300 }
}
total is ₹48,206.40 — the value on 20 September, not the sum of the twelve months.
Worked example: one flow, split and bounded
/v1/analytics?measure=mrr_new&grain=month&range=6m&split=plan&split_limit=3¤cy=INR&business=biz_2Nk4{
"measure": "mrr_new",
"kind": "flow",
"unit": "money",
"total": 1840000,
"total_basis": "sum_over_window",
"split": {
"dimension": "plan",
"limit": 3,
"returned": 4,
"truncated": true,
"series": [
{ "key": "prod_9fQ2", "label": "prod_9fQ2", "total": 820000, "points": [] },
{ "key": "prod_5bX7", "label": "prod_5bX7", "total": 410000, "points": [] },
{ "key": "prod_1aC3", "label": "prod_1aC3", "total": 260000, "points": [] },
{ "key": "other", "label": "Other (11 more)", "total": 350000, "points": [] }
]
}
}
A split is bounded by construction. Rows come back biggest-first by magnitude with ties broken
by key, split_limit (default 20, maximum 200) fixes how many you get, and everything past it is
folded into one explicit other row computed as the total minus the rows kept. So a split by a
high-cardinality dimension can never return unbounded rows, and the rows always add back up to
total — 820000 + 410000 + 260000 + 350000 = 1840000.
Worked example: a board in one request
/v1/analytics/query{
"business": "biz_2Nk4",
"grain": "month",
"range": "12m",
"currency": "INR",
"queries": [
{ "key": "mrr", "measure": "mrr", "compare": "previous" },
{ "key": "new", "measure": "mrr_new" },
{ "key": "churn", "measure": "mrr_churn" },
{ "key": "by_plan", "measure": "mrr", "split": "plan", "split_limit": 8 }
]
}
Top-level grain, range, start, end, currency, compare and business are defaults. Anything set on an individual query wins. Up to 24 questions per call.
A refused question fails alone — it comes back in its own slot and every other question still answers, because one bad tile must not blank a board:
{
"object": "analytics_batch",
"as_of": "2026-09-20T06:03:11.402Z",
"data": [
{ "key": "mrr", "object": "analytics_result", "total": 4820640 },
{
"key": "by_plan",
"object": "analytics_error",
"measure": "cmrr",
"status": 422,
"error": { "type": "invalid_request_error", "message": "Committed monthly recurring revenue cannot be split by plan. It can be split by currency, business." }
}
]
}
Coverage: when the history is partial
This is the field to read before you publish a chart. Where a period has no computed figure,
coverage appears and names it:
"coverage": {
"complete_from": "2026-08-06",
"missing_periods": ["2026-07-30", "2026-07-31"],
"note": "2 of 30 day periods have no stored figure, so this series is partial. A period listed here is unknown, not zero — do not plot it as zero or include it in an average."
}
coverage is absent whenever
every period asked for has a computed figure, so its presence is the whole signal.Freshness
as_of is when the answer was assembled. computed_at is when the oldest figure in it was
computed — that is the age to show a reader. freshness.max_age_seconds matches the cache-control
header: five minutes while the window reaches the period in progress, an hour for closed history,
and zero (never cached) whenever coverage is present.
Refusals explain themselves
Anything coherent but unanswerable is a 422 carrying a sentence, never a bare code — because a client discovering the API programmatically has nothing else to go on.
| You asked for | You get |
|---|---|
| A dimension the measure does not carry | Committed monthly recurring revenue cannot be split by plan. It can be split by currency, business. |
| A grain that does not exist | There is no grain called "fortnight". Grains are day, week, month, quarter, year. |
| A measure that does not exist | There is no measure called "arr". Ask GET /v1/analytics/measures for the list. |
| Money in more than one currency | Monthly recurring revenue is money held per currency and is never summed across them. Ask for one of: INR, USD. |
| More than 400 periods | That range covers more than 400 day periods. Ask for a shorter range or a coarser grain. |
Versioning
Additive within v1. New measures, dimensions and response fields may appear at any time. Nothing is removed or re-typed without a version boundary and an announced deprecation. Read
GET /v1/analytics/measures rather than hard-coding a list, and ignore fields you do not recognise.
Related
Entitlements
Turn what a customer bought into what your application lets them do, with features, plan grants and per-subscription overrides that one API call can check.
Trials
Give a plan a free trial, start trials with or without a card, change or end a trial early, and know exactly what happens to the subscription when it ends.