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.
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.
{
"horizon": "2026-12",
"axisStart": "2024-11",
"axisEnd": "2028-12",
"months": 50,
"periods": ["2024-11", "2024-12", ..., "2028-12"],
"sections": [ ... ]
} -
horizonmarks 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,axisEndandmonthsdescribe the axis. The axis is derived from the data and the horizon; you do not choose it. -
sectionsis 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.
{
"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.
{
"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 2147483647 — Integer.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.
curl https://ptx-api.plantactic.com/v1/datasets/9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77/metrics/catalog \
-H "Authorization: Bearer ptx_test_4e91c8..." {
"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.
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.