Skip to content

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.

POST /v1/datasets/{id}/versions
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 a revenueModel containing ONE_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 tcv of zero, or no signedDate: the row produces nothing at all. A missing signedDate also 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.

422 Unprocessable Content
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.

POST /v1/uploads
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
}
then the workbook, then the version
# 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.

202 Accepted · the version, still building
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.

GET /v1/datasets/{id}/versions/{versionId} · terminal
{
  "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.