Sending data
Data enters as an immutable version of a dataset. Which model you send decides what can ever be computed from it, so that decision comes first — and validation is designed so you only make one round trip to fix a bad file.
Data goes in as versions
You never patch a dataset. You post a new version — a complete statement of the
data as of now — and it becomes the active one. The version it replaces becomes
superseded and stays addressable by its id.
This buys three things that partial updates cannot:
- Reproducibility. Last quarter's board pack can be recomputed from exactly the rows that produced it, because those rows still exist under their own version id.
- Caching without invalidation. Every cache key contains the version id, so a new upload rotates every key at once. Nothing is ever explicitly evicted and there is no stale window to reason about.
- Atomicity. A version either validates entirely and becomes active, or is rejected entirely and changes nothing. There is no half-applied upload to clean up.
Choose an input model
inputModel is set when the dataset is created and cannot be changed afterwards,
because it determines which of the 706 metric definitions can be computed from it. A dataset
built from transactions has no contracts; bookings and CMRR do not exist for it, and no amount of
later work makes them exist.
contracts One row per contract The richest model and the default. A contract carries a term, so the engine knows what is signed as well as what has started, which is what makes contracted measures — CMRR, CARR, contracted users — and the bookings waterfall possible at all.
Unavailable: Nothing — the full 706
revenue_events Transactions or usage records For usage-based and marketplace businesses where no fixed contract value exists. Events aggregate into customer-months at ingest, so a very large event stream never reaches the metric math at event grain.
Unavailable: Bookings, contract stats, CMRR, contracted users
One caveat that outranks the choice above. The versions endpoint accepts
exactly two body shapes today — contracts inline, or an uploadId
pointing at a workbook. There is no revenueEvents or customerMonths
payload, so a revenue_events dataset can be created and then cannot be given data.
Pick contracts unless you have heard otherwise from us.
Once a version is active, GET /v1/datasets/{id}/metrics/catalog tells you exactly what your data can answer, so you never have to infer capability from the model
name.
Sending a version
Contracts go in as JSON. Two fields are required: contractId and
customer. Everything else is optional in the sense that the upload
succeeds without it — which is not the same as safe. startDate and
endDate are validated only if you send them, and term decides whether
the row produces any metrics at all.
The dimensions you can segment by later are a fixed set, not a free-form object:
product and productCategory. A row carrying other keys — a
dimensions object, a customerSize, an industry — is
accepted and those keys are discarded, silently. Note the spelling of
product: productName is not read.
curl -X POST \
https://ptx-api.plantactic.com/v1/datasets/9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77/versions \
-H "Authorization: Bearer ptx_test_4e91c8..." \
-H "Idempotency-Key: 7c1a-acme-2026-02" \
-H "Content-Type: application/json" \
-d '{
"contracts": [
{
"contractId": "C-1041",
"customer": "Northwind Logistics",
"product": "Platform",
"productCategory": "Software",
"revenueModel": "RECURRING",
"signedDate": "2026-01-14",
"startDate": "2026-02-01",
"endDate": "2027-01-31",
"term": 12,
"tcv": 84000,
"licenses": 120,
"pricePerLicense": 58.33
}
]
}' Four fields decide how much of a row survives, and they do not fail alike. All four are optional, every combination is accepted, and nothing in the response tells you which of them bit.
-
No
term, or arevenueModelcontainingONE_TIME: the row still produces bookings TCV and recognized revenue, and produces zero ARR, MRR, CARR, CMRR, logos and users. This is the dangerous one — the grid looks populated and is wrong only where you were looking. -
A
tcvof zero, or nosignedDate: the row produces nothing at all. A missingsignedDatealso moves the axis start, because the axis is derived from the earliest signature.
revenueModel is what decides recurring: anything not containing
ONE_TIME counts as recurring, including an absent value.
signedDate and startDate are distinct on purpose and both matter. A
contract signed in January that starts in February is bookings in January and live revenue from
February, and conflating them is the most common way an ARR waterfall ends up a month out.
Validation: all of it, at once
If any row fails validation, nothing is written and the response is a
422 carrying every problem found — not the first one, not a sample of ten. Fix them
in one pass and send again.
Errors are RFC 9457 problem details, so the
standard members (type, title, status,
detail, instance) are where you expect them, with an
errors array carrying the specifics.
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://developer.plantactic.com/errors/ingest-validation-failed",
"title": "Unprocessable Entity",
"status": 422,
"detail": "3 row(s) were rejected. Nothing was written.",
"instance": "/v1/datasets/9f2c4b1e-.../versions",
"errors": [
{
"row": 1,
"field": "endDate",
"code": "not_a_date",
"message": "'2025-13-01' is not an ISO-8601 date (expected YYYY-MM-DD)."
},
{
"row": 2,
"field": "customer",
"code": "required",
"message": "customer is required."
},
{
"row": 3,
"field": "endDate",
"code": "end_before_start",
"message": "endDate 2026-01-31 is before startDate 2027-03-01."
}
]
}
Each entry names the row as you count it in your source, the field, a stable
code to branch on, and a message written for a person. Branch on
code: the message is prose we will keep improving. A row with several bad cells
produces several entries, so you are told about all of them rather than discovering the second
on your next attempt.
A validator that stopped at the first error would turn a hundred bad rows into a hundred round trips, which is the whole reason it does not.
Large uploads
Two thousand rows is where an inline upload stops being synchronous. Past that it is accepted
with a 202 and built off the request thread; a workbook always is. Three limits
bind on top: 10MB for an inline request body, 20MB for a workbook, and 50,000 rows
either way. Tens of thousands of contracts will approach the
inline cap well before the row ceiling, which is the practical reason to send a workbook rather
than a body. Neither should hold a connection open for two minutes, and neither does: both
routes end at the same place, a version in building that you poll.
For a workbook, ask for somewhere to put it first. You get an id and a short-lived URL; PUT the
file there, then create the version referencing the uploadId rather than inlining
rows.
HTTP/1.1 201 Created
{
"uploadId": "8c3f21d4-9b07-4e6a-a512-77d0e5b394ca",
"url": "https://… (short-lived, PUT the workbook here)",
"expiresAt": "2026-02-03T10:11:07Z",
"maxBytes": 20971520
} # 2. PUT the workbook at the url you were given
curl -X PUT "https://…" --upload-file contracts.xlsx
# 3. Create the version from it — no rows in the body
curl -X POST \
https://ptx-api.plantactic.com/v1/datasets/9f2c4b1e-.../versions \
-H "Authorization: Bearer ptx_test_4e91c8..." \
-H "Idempotency-Key: 7c1a-acme-2026-02-workbook" \
-H "Content-Type: application/json" \
-d '{ "uploadId": "8c3f21d4-9b07-4e6a-a512-77d0e5b394ca" }' There is no GET /v1/uploads/{id}, deliberately — an upload is not
a resource with a state, it is a place to put bytes. The version it becomes is the resource, and
that is what carries a Location and what you poll.
HTTP/1.1 202 Accepted
Location: /v1/datasets/9f2c4b1e-.../versions/3b71a0c9-...
{
"versionId": "3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b",
"datasetId": "9f2c4b1e-7a83-4d02-9c15-6b0e2a1d4f77",
"status": "building",
"rowCount": 41208,
"createdAt": "2026-02-03T09:41:07Z",
"activatedAt": null,
"livemode": false
}
Poll that URL. status is one of four: building is the only one that
is not terminal, and active, invalid and superseded all
end your loop. That is part of the contract rather than an implementation detail: when the
caller owns the polling loop, a state machine with no defined end is a hang in someone else's
production system.
{
"versionId": "3b71a0c9-2e64-4b18-8f3a-51d7c9e02a6b",
"status": "active",
"rowCount": 41208,
"createdAt": "2026-02-03T09:41:07Z",
"activatedAt": "2026-02-03T09:43:52Z",
"livemode": false
} The previous version keeps answering the whole time. A build in progress
supersedes nothing; queries continue against the version that is already active until the new
one reaches active, and an invalid one never becomes active at all.
An invalid version carries the same problem-details body a synchronous
422 would have, including the full error array.
Idempotency
Idempotency-Key is required on every mutating call, including this
one. A retried upload — after a timeout, a 502, a dropped connection — must not create a second
version of the same data, and with a stable key it does not: you get the original result back.
Derive the key from something that identifies the upload rather than the attempt. The dataset and the period you are loading is a good key; a fresh UUID per retry is exactly the wrong one.
Keys are matched for 24 hours. Within that window, the same key with the same
body replays the original result — and the same key with a different body is a
422 telling you to use a new one, which is what you will hit the first time you fix
a bad row and retry.