---
title: "Analytics"
description: "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."
canonical_url: "https://twominutebilling.com/help/developers/concepts/analytics"
---
# 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.

<endpoint method="GET" path="/v1/analytics/measures">



</endpoint>

<endpoint method="GET" path="/v1/analytics">



</endpoint>

<endpoint method="POST" path="/v1/analytics/query">



</endpoint>

## 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.

```json
{
  "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:

<table>
<thead>
  <tr>
    <th>
      Measure
    </th>
    
    <th>
      Kind
    </th>
    
    <th>
      Unit
    </th>
    
    <th>
      What it is
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        mrr
      </code>
    </td>
    
    <td>
      stock
    </td>
    
    <td>
      money
    </td>
    
    <td>
      The rate the live book bills each month, at the instant asked for.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cmrr
      </code>
    </td>
    
    <td>
      stock
    </td>
    
    <td>
      money
    </td>
    
    <td>
      Recurring revenue under commitment, including terms already booked to stop. Not sliceable by country.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        active_subscriptions
      </code>
    </td>
    
    <td>
      stock
    </td>
    
    <td>
      count
    </td>
    
    <td>
      Subscriptions live at that instant, revenue or not.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        paying_subscriptions
      </code>
    </td>
    
    <td>
      stock
    </td>
    
    <td>
      count
    </td>
    
    <td>
      Active subscriptions carrying revenue above zero and set to renew.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mrr_new
      </code>
    </td>
    
    <td>
      flow
    </td>
    
    <td>
      money
    </td>
    
    <td>
      Recurring revenue from subscriptions starting in the period.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mrr_expansion
      </code>
    </td>
    
    <td>
      flow
    </td>
    
    <td>
      money
    </td>
    
    <td>
      Increases on subscriptions that were already live.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mrr_reactivation
      </code>
    </td>
    
    <td>
      flow
    </td>
    
    <td>
      money
    </td>
    
    <td>
      Revenue from subscriptions that had stopped and resumed.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mrr_contraction
      </code>
    </td>
    
    <td>
      flow
    </td>
    
    <td>
      money
    </td>
    
    <td>
      Decreases on subscriptions that stayed live.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mrr_churn
      </code>
    </td>
    
    <td>
      flow
    </td>
    
    <td>
      money
    </td>
    
    <td>
      Revenue lost to cancellation.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mrr_paused
      </code>
    </td>
    
    <td>
      flow
    </td>
    
    <td>
      money
    </td>
    
    <td>
      Revenue removed by a pause. A pause is not churn and is reported apart from it.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        mrr_fx_impact
      </code>
    </td>
    
    <td>
      flow
    </td>
    
    <td>
      money
    </td>
    
    <td>
      How much a converted MRR total moved because exchange rates did. Signed; <code>
        0
      </code>
      
       in a single-currency answer.
    </td>
  </tr>
</tbody>
</table>

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.

<warning title="Branch on `kind`, not on the measure id">

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.

</warning>

## Units

`unit` is on every response and is never implied.

<table>
<thead>
  <tr>
    <th>
      <code>
        unit
      </code>
    </th>
    
    <th>
      It holds
    </th>
    
    <th>
      Read it as
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        money
      </code>
    </td>
    
    <td>
      Integer <strong>
        minor units
      </strong>
      
       in the stated <code>
        currency
      </code>
    </td>
    
    <td>
      <code>
        4820640
      </code>
      
       with <code>
        "currency": "INR"
      </code>
      
       is ₹48,206.40
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        count
      </code>
    </td>
    
    <td>
      A whole number
    </td>
    
    <td>
      <code>
        312
      </code>
      
       subscriptions
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        bps
      </code>
    </td>
    
    <td>
      Basis points
    </td>
    
    <td>
      <code>
        480
      </code>
      
       is 4.8%
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ratio
      </code>
    </td>
    
    <td>
      <code>
        value
      </code>
      
       over <code>
        denominator
      </code>
    </td>
    
    <td>
      Divide at read time
    </td>
  </tr>
</tbody>
</table>

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:

```json
{
  "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:

```json
"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

<endpoint method="GET" path="/v1/analytics?measure=mrr&grain=month&range=12m¤cy=INR&business=biz_2Nk4">



</endpoint>

```json
{
  "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

<endpoint method="GET" path="/v1/analytics?measure=mrr_new&grain=month&range=6m&split=plan&split_limit=3¤cy=INR&business=biz_2Nk4">



</endpoint>

```json
{
  "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

<endpoint method="POST" path="/v1/analytics/query">



</endpoint>

```json
{
  "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:

```json
{
  "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:

```json
"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."
}
```

<caution title="A missing period is unknown, not zero">

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.

</caution>

## 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.

<table>
<thead>
  <tr>
    <th>
      You asked for
    </th>
    
    <th>
      You get
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      A dimension the measure does not carry
    </td>
    
    <td>
      <code>
        Committed monthly recurring revenue cannot be split by plan. It can be split by currency, business.
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A grain that does not exist
    </td>
    
    <td>
      <code>
        There is no grain called "fortnight". Grains are day, week, month, quarter, year.
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      A measure that does not exist
    </td>
    
    <td>
      <code>
        There is no measure called "arr". Ask GET /v1/analytics/measures for the list.
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Money in more than one currency
    </td>
    
    <td>
      <code>
        Monthly recurring revenue is money held per currency and is never summed across them. Ask for one of: INR, USD.
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      More than 400 periods
    </td>
    
    <td>
      <code>
        That range covers more than 400 day periods. Ask for a shorter range or a coarser grain.
      </code>
    </td>
  </tr>
</tbody>
</table>

## 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

- [Read your numbers in the dashboard](https://twominutebilling.com/help/guides/read-your-numbers)
- [MRR, churn and SaaS metrics reporting](https://twominutebilling.com/product/reporting)


## Sitemap

See the full [sitemap](https://twominutebilling.com/sitemap.md) for all pages.
