Concepts

Analytics

One endpoint answers every number on a dashboard. Learn the measures, why a stock is not a flow, which unit a figure is in, and how a partial history says so.

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.

GET/v1/analytics/measures
GET/v1/analytics
POST/v1/analytics/query

Start 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:

MeasureKindUnitWhat it is
mrrstockmoneyThe rate the live book bills each month, at the instant asked for.
cmrrstockmoneyRecurring revenue under commitment, including terms already booked to stop. Not sliceable by country.
active_subscriptionsstockcountSubscriptions live at that instant, revenue or not.
paying_subscriptionsstockcountActive subscriptions carrying revenue above zero and set to renew.
mrr_newflowmoneyRecurring revenue from subscriptions starting in the period.
mrr_expansionflowmoneyIncreases on subscriptions that were already live.
mrr_reactivationflowmoneyRevenue from subscriptions that had stopped and resumed.
mrr_contractionflowmoneyDecreases on subscriptions that stayed live.
mrr_churnflowmoneyRevenue lost to cancellation.
mrr_pausedflowmoneyRevenue removed by a pause. A pause is not churn and is reported apart from it.
mrr_fx_impactflowmoneyHow 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. mrr for 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_new for 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.

Never sum a stock across periods, and never take the last value of a flow. If you write generic charting code, branch on 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.

unitIt holdsRead it as
moneyInteger minor units in the stated currency4820640 with "currency": "INR" is ₹48,206.40
countA whole number312 subscriptions
bpsBasis points480 is 4.8%
ratiovalue over denominatorDivide 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

GET/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

GET/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

POST/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."
}
A missing period is unknown, not zero. Plotting it as zero flatters a retention curve and understates churn, and nothing in the numbers themselves reveals it. 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 forYou get
A dimension the measure does not carryCommitted monthly recurring revenue cannot be split by plan. It can be split by currency, business.
A grain that does not existThere is no grain called "fortnight". Grains are day, week, month, quarter, year.
A measure that does not existThere is no measure called "arr". Ask GET /v1/analytics/measures for the list.
Money in more than one currencyMonthly recurring revenue is money held per currency and is never summed across them. Ask for one of: INR, USD.
More than 400 periodsThat 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.

Copyright © 2026