Skip to content

Querying metrics

One endpoint answers every question about a dataset, and it always answers in the same shape: a period axis, and rows of dense numbers aligned to it. This page documents that shape field by field, because a client written against it correctly will not need rewriting when we add metrics.

The request

One field. horizon is the month the engine computes as of, and it is the only key this endpoint reads — omit the body entirely and it defaults to the current month.

POST /v1/datasets/{id}/metrics
curl -X POST \
  https://ptx-api.plantactic.com/v1/datasets/9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77/metrics \
  -H "Authorization: Bearer ptx_test_4e91c8..." \
  -H "Content-Type: application/json" \
  -d '{ "horizon": "2026-12" }'

Anything else you send is accepted and ignored. There is no windowing, no period type, no section selection and no filtering here yet, so a body carrying metricGroups or filters gets the same full grid as an empty one, with nothing in the response to tell you the fields went nowhere. Idempotency-Key is required on mutating calls but not on this one — it changes nothing.

The envelope

POST /v1/datasets/{id}/metrics returns a grid. The top level tells you what was computed and over what window. Nothing in it describes the input it was computed from.

200 OK · top level
{
  "horizon":   "2026-12",
  "axisStart": "2024-11",
  "axisEnd":   "2028-12",
  "months":    50,
  "periods":   ["2024-11", "2024-12", ..., "2028-12"],
  "sections":  [ ... ]
}
  • horizon marks where known data ends — the month you asked to compute as of, or the current month if you sent no body. Periods past it are still on the axis, because a contract signed today produces revenue next year.
  • axisStart, axisEnd and months describe the axis. The axis is derived from the data and the horizon; you do not choose it.
  • sections is the grid itself — always the whole of it, the same seventeen sections for every dataset, each with its rows. It is not a subset you asked for, and its length does not vary with your data: a section your data cannot support is present with its rows at zero.

Six keys, and that is the whole envelope. Within /v1/ we add fields, never remove or repurpose them, so ignore keys you do not recognise rather than failing on them.

The period axis

periods is an ordered array of period labels — "2026-01" for a monthly axis. It is the only index you need. Every row in every section carries a values array of exactly months entries, aligned to it position for position.

Dense, not sparse. There are no absent keys to defend against and no gaps to interpolate: a period that has no value carries null in that slot, and the array length never varies between rows in one response. The samples below excerpt those arrays to six months so the columns line up on the page; on the wire they are full length.

Rows and rowIds

Sections group rows into the blocks a finance reader expects — arr_waterfall, retention, financial, unit_economics and the rest. A section is { sectionId, title, rows }; a row looks like this.

one row, values excerpted to six months
{
  "rowId": "arr_waterfall.churned_live_arr",
  "metric": "CHURNED_LIVE_ARR",
  "displayName": "Churned Live ARR",
  "formatType": "ACCURATE_CURRENCY_VALUE",
  "preset": "DANGER",
  "values": [0.0, 0.0, 104999.63636363635,
             145000.0, 0.0, 139000.8],
  "secondValues": null,
  "formatType2": null
}

secondValues and formatType2 carry a companion series on the rows that have one — a second figure rendered alongside the first, in its own format. They are null on rows that do not, as is preset where none applies. All three keys are always present rather than omitted when empty, so treat them as nullable rather than optional: a client that branches on key presence will branch wrong.

Match on rowId, never on displayName. rowId and metric are stable identifiers and part of the contract. displayName is human-facing text that we will keep improving, and the two already diverge: the row identified as arr_waterfall.upsell_live_arr displays as "Expansion Live ARR". The label moved; the id did not, and it will not.

Format types

Nothing on the wire is pre-formatted. No currency symbols, no thousands separators, no locale, no rounding. Every row instead carries a formatType saying what kind of quantity the numbers are, and your client decides how it wants them to look.

formatType Meaning Wire → rendered
ACCURATE_CURRENCY_VALUE A money amount in the dataset currency 104999.63636363635 → $105.0k
ACCURATE_DOUBLE_VALUE An unbounded ratio or multiple 1.6212022957436623 → 1.62×
PERCENTAGE_VALUE A fraction, not a percentage. Multiply by 100. 0.9660171443381903 → 96.6%
INT_VALUE A count. Arrives as a JSON number; round for display. 69.0 → 69

The one that catches people: PERCENTAGE_VALUE carries a fraction. A GRR of 0.9660171443381903 is 96.6%, not 0.97%. The engine never multiplies by 100 for you, because the moment it does, a client that also multiplies produces a number nobody notices is wrong.

Presets

A waterfall is not a flat list — some rows are components of the row above, one is a subtotal, one is the close. preset carries that structure so you can render it without hard-coding which rows are which.

INDENT A component of the row above it — New, Expansion, Churned inside a waterfall.
DANGER A magnitude that subtracts. Churn and downsell arrive positive and reduce the total.
BOLD A subtotal. Net New sits between the components and the close.
TOTAL The closing figure for the block.

A row with no preset carries preset: null. The key is always there, so compare against null rather than testing whether the key exists.

Presets are presentation hints, not arithmetic. DANGER rows arrive as positive magnitudes that subtract: churned ARR of 104999.63636363635 reduces the close. Do not negate them and then also subtract them.

What null means

Null means not computable. It never means zero. This is the single most important property of the contract, and the reason the response carries raw numbers instead of strings.

Here is Burn Multiple over the same six months. Two of them are null: the two months in this window where net new ARR was negative. A burn multiple divided by negative net new ARR is not a small number — it is not a number.

financial.burn_multiple · 2026-01 → 2026-06
{
  "rowId": "financial.burn_multiple",
  "metric": "BURN_MULTIPLE",
  "displayName": "Burn Multiple",
  "formatType": "ACCURATE_DOUBLE_VALUE",
  "preset": null,
  "values": [1.6212022957436623, 1.987771353903428,
             1.2783939584048487, null,
             1.1204608486308791, null],
  "secondValues": null,
  "formatType2": null
}

Zero would have been a lie that survives every downstream step: it averages, it charts, it lands in a board deck. Null propagates as an absence, which is what it is.

Two rows break this rule, and you have to defend against them. financial.contracted_burn_multiple_t3m and …_ttm carry 2147483647Integer.MAX_VALUE — where they mean "undefined", rather than null. It is deliberate: those two are held to a persisted representation the application already renders as "N/A", and giving one metric two meanings depending on which system asked first would be worse. It is still a sentinel on the wire, so treat that value as absent on those two rows. Every other row uses null.

So: check for null before you arithmetic. A trailing average over a window containing nulls is an average over fewer months, not an average over zeroes.

The catalog: two axes

"706 metric definitions" is true of the engine and not necessarily true of your dataset. What can be computed depends on what you sent — a dataset built from transactions has no contracts, so bookings and CMRR do not exist for it at all.

Rather than let you discover that in production, ask. GET /v1/datasets/{id}/metrics/catalog answers computability from the grid your data actually produced. Cohortability it does not — that list is the same fixed set for every dataset.

GET /v1/datasets/{id}/metrics/catalog
curl https://ptx-api.plantactic.com/v1/datasets/9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77/metrics/catalog \
  -H "Authorization: Bearer ptx_test_4e91c8..."
200 OK
{
  "datasetId":  "9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77",
  "versionId":  "3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b",
  "inputModel": "contracts",
  "computable": {
    "sections": [
      { "sectionId": "bookings_tcv",  "title": "TCV",      "rowCount": 12 },
      { "sectionId": "arr_waterfall", "title": "Live ARR", "rowCount": 15 },
      { "sectionId": "mrr_waterfall", "title": "MRR",      "rowCount":  9 }
      …
    ],
    "metricCount": 178
  },
  "cohortable": {
    "measures": ["MRR", "CMRR", "ARR", "CARR",
                 "USERS", "CONTRACTED_USERS", "LOGOS"],
    "unavailable": [
      {
        "metric": "BOOKINGS_TCV",
        "reason": "bookings post entirely in the signed month, so every
                   later cohort offset is zero by construction"
      }
    ]
  }
}

Note that the response has two independent sections, because "can this be computed" and "can this be cohorted" are different questions with different answers. A metric can be fully computable and still refuse to be cohorted.

cohortable.measures is a flat list of measure names, and cohortable.unavailable pairs each excluded metric with the reason in plain words. The reason is prose meant for a person, not a slug to branch on — match on metric if you need to branch.

What can be cohorted

Exactly seven measures are cohortable, and the list is closed — not pending work:

  • MRR · CMRR · ARR · CARR
  • Users · Contracted Users
  • Logos

These are precisely the per-customer-per-month quantities the engine already carries, plus their annualised aliases. A cohort asks "of the customers who arrived in month M, how much do we still have at month M+n", which only has an answer if the quantity decomposes per customer per month.

Everything else is excluded, and the reasons fall into three groups. The catalog names only a few of them: unavailable is a short fixed list of the notable cases, not a per-metric enumeration — so the seven-measure allowlist is the rule to code against, not the absence of an entry.

  • Bookings post entirely in the month they were signed, so every later offset is zero by construction. A bookings cohort curve is a spike and then nothing — technically renderable, analytically meaningless.
  • Financials have no per-customer decomposition. A P&L line cannot be attributed to a vintage, and pretending otherwise would put a made-up allocation into a number somebody reports.
  • Ratios and composites — burn multiple, Rule of 40, NRR itself — are defined over a company-period, not over a customer-month. There is no per-customer burn multiple to cohort.

The catalog's cohortable block does not vary with your input model. The same seven measures and the same exclusions come back for every dataset, so read it as a property of the metrics rather than a capability check on your data: a revenue_events dataset is told CMRR and contracted users are cohortable exactly as a contracts one is.

One 404 for two different problems

Metrics and the catalog both answer 404 when the dataset does not exist and when it exists but has no active version. One status, one merged message, no way to tell them apart from the response.

That matters because the correct reaction differs: the first means check your id and stop, the second means an upload is still building and you should poll. Until they separate, resolve the dataset first — GET /v1/datasets/{id} answers whether it exists, and its activeVersionId answers whether anything is servable yet.

Metering

Metering is on compute, not calls: two requests can differ by orders of magnitude in the work they cause, so counting requests would price them identically. A unit is the number of contracts in the version multiplied by the number of months the grid spans, multiplied by the segment combinations evaluated — which is one today, because this endpoint takes no filters. It is a rectangle rather than a sum of each contract's term, so a short contract on a long axis costs the same as a long one. The multiplier is in the formula so that adding the sweep later is a number rather than a repricing.

You are told the price on the response that incurred it, in X-Plantactic-Compute-Units, and it is the same number we record — computed once, so a header and an invoice cannot disagree.

on every metrics response
POST /v1/datasets/{id}/metrics

X-Plantactic-Compute-Units: 19000

There is no conditional request yet. No ETag is returned and If-None-Match is not honoured, so every ask computes and every ask is metered. If you cache, key on the active versionId: it is immutable, so an answer computed under one can never go stale. The awkward part is getting it — the metrics response does not carry it and the request cannot pin one, so it comes from the catalog in a separate call, and a version can activate between the two. Treat a cache built this way as best-effort rather than exact.

Nothing is rejected for cost. Usage is recorded after the work is done rather than refused before it starts, so a query you did not mean to run is charged rather than blocked.