> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hollerith.monarcha.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# REST API

> Use Hollerith in your apps

Most people integrating with Hollerith should use the [Python SDK](/reference/python-sdk),
which makes all of these calls for you. This page is for direct integration in another
language.

## Row data never travels in the JSON body

```
POST /v1/uploads       →  a presigned URL and an object key
PUT  <presigned URL>   →  your gzipped payload, straight to object storage
POST /v1/predictions   →  {objectKey, contentHash, counts}
GET  /v1/predictions/{id}          →  status
GET  /v1/predictions/{id}/result   →  the scored rows
```

That is the shape of every job. The JSON body you submit carries an object key, a content
hash and row counts — never the rows.

The dataset itself is serialized to JSON, gzipped, and uploaded directly to object storage.
The control plane only ever sees the reference.

## Base origin and auth

```sh theme={null}
curl https://hollerith.monarcha.ai/v1/model-limits
```

The base origin is your deployment's, the same value you would set as `HOLLERITH_BASE_URL`.

That origin proxies exactly two path prefixes: `/v1/*` and `/sdk/*`. Nothing else on it is
part of the API, and there is no other public prefix.

```sh theme={null}
Authorization: Bearer hk_live_...
```

`GET /v1/model-limits` and the wheel download take no key. Every other endpoint requires one.

* **No `Authorization` header** — `missing_api_key`, 401.
* **Unrecognized key** — `invalid_api_key`, 401.
* **Revoked key** — `revoked_api_key`, 401.
* **A key from another organization's resource** — `org_forbidden`, 403.

## Everything succeeds with 200

Submitting a job returns 200. Never 201, never 202, including for work that has not started.

Progress lives in the `status` field of the returned job, not in the HTTP status. Poll for it.

## Endpoints

| Method | Path                             | Auth | Returns                 |
| ------ | -------------------------------- | ---- | ----------------------- |
| GET    | `/v1/model-limits`               | none | The published limits    |
| POST   | `/v1/uploads`                    | key  | A presigned upload plan |
| POST   | `/v1/fits`                       | key  | `{job, context}`        |
| GET    | `/v1/fits/{contextId}`           | key  | `FittedContext`         |
| POST   | `/v1/predictions`                | key  | `PredictionJob`         |
| GET    | `/v1/predictions/{jobId}`        | key  | `PredictionJob`         |
| GET    | `/v1/predictions/{jobId}/result` | key  | `PredictionResult`      |
| POST   | `/v1/evaluations`                | key  | `PredictionJob`         |
| GET    | `/v1/evaluations/{jobId}/result` | key  | `EvaluationResult`      |
| POST   | `/v1/forecasts`                  | key  | `PredictionJob`         |
| GET    | `/sdk/<wheel>.whl`               | none | The SDK wheel           |

Every field on the wire is camelCase. Ids are opaque strings; do not parse them.

There is no `GET /v1/forecasts/{id}` and no bare `GET /v1/evaluations/{id}`. Both return 400.

***

## Worked example: upload, submit, poll, read

### 1. Build and hash the payload

```python theme={null}
import gzip, hashlib, json

payload = {
  "task": "classification",
  "targetColumn": "species",
  "featureColumns": ["sepal_length", "sepal_width", "petal_length", "petal_width"],
  "trainingFeatures": [
    {"sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2},
    {"sepal_length": 7.0, "sepal_width": 3.2, "petal_length": 4.7, "petal_width": 1.4},
    {"sepal_length": 6.3, "sepal_width": 3.3, "petal_length": 6.0, "petal_width": 2.5}
  ],
  "trainingTarget": ["setosa", "versicolor", "virginica"],
  "rows": [
    {"sepal_length": 5.0, "sepal_width": 3.4, "petal_length": 1.5, "petal_width": 0.2},
    {"sepal_length": 6.4, "sepal_width": 3.2, "petal_length": 4.5, "petal_width": 1.5}
  ]
}

body = gzip.compress(json.dumps(payload, separators=(",", ":")).encode(), mtime=0)
print(len(body))                                          # 233
print("sha256:" + hashlib.sha256(body).hexdigest())
# sha256:c723441a82ce9ac87b02dce28d34ffb5d31994d9231bf4cb46c8bb5081b486e2
```

The hash is sha256 over the gzipped bytes — the exact bytes you are about to PUT, not the
JSON text.

`trainingFeatures` and `rows` are row records, one object per row keyed by column name.
`trainingTarget` is a flat list, row-aligned with `trainingFeatures`.

The payload keys are documented under [Payload shapes](#payload-shapes) below. Compact
separators and `mtime=0` are not required; they only make the address reproducible across
runs, which is what lets an identical dataset dedupe.

### 2. Ask for somewhere to put it

```sh theme={null}
curl -X POST https://hollerith.monarcha.ai/v1/uploads \
  -H "Authorization: Bearer hk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"contentHash":"sha256:c723441a82ce9ac87b02dce28d34ffb5d31994d9231bf4cb46c8bb5081b486e2","bytes":233}'
```

```json theme={null}
{
  "kind": "single",
  "key": "staging/c723441a82ce9ac87b02dce28d34ffb5d31994d9231bf4cb46c8bb5081b486e2",
  "url": "https://<bucket>.s3.<region>.amazonaws.com/staging/c7234...?X-Amz-Signature=..."
}
```

The key is derived from your content hash, so two byte-identical payloads address the same
object. That is deliberate, and it has a consequence — see [Idempotency](#idempotency).

### 3. PUT the bytes

```sh theme={null}
curl -X PUT --data-binary @payload.json.gz "<url from step 2>"
```

No `Authorization` header. The URL is already signed, and it expires 1 hour after it is
issued.

Object storage answers 200 with an `ETag`. You need that ETag only for multipart uploads.

### 4. Submit the job

```sh theme={null}
curl -X POST https://hollerith.monarcha.ai/v1/predictions \
  -H "Authorization: Bearer hk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "objectKey": "staging/c723441a82ce9ac87b02dce28d34ffb5d31994d9231bf4cb46c8bb5081b486e2",
    "contentHash": "sha256:c723441a82ce9ac87b02dce28d34ffb5d31994d9231bf4cb46c8bb5081b486e2",
    "task": "classification",
    "targetColumn": "species",
    "featureColumns": ["sepal_length","sepal_width","petal_length","petal_width"],
    "trainRowCount": 3,
    "outputRowCount": 2,
    "classCount": 3,
    "idempotencyKey": "predict:iris-v1:2026-08-07"
  }'
```

```json theme={null}
{
  "id": "jd7f2m9k4x1c8b3n5q0v6t2w",
  "status": "queued",
  "kind": "predict",
  "task": "classification",
  "trainRows": 3,
  "outputRows": 2,
  "cols": 4,
  "targetColumn": "species",
  "engineVersion": "eng_pending",
  "createdAt": "2026-08-07T11:04:22.913Z",
  "durationMs": null,
  "inputHash": "sha256:c723441a82ce9ac87b02dce28d34ffb5d31994d9231bf4cb46c8bb5081b486e2",
  "error": null
}
```

The counts you declare are checked against the payload. If `trainRowCount` or
`outputRowCount` disagrees with what the uploaded object actually contains, the worker fails
the job with `schema_mismatch` — after it has been queued, not at submit.

### 5. Poll the job

```sh theme={null}
curl https://hollerith.monarcha.ai/v1/predictions/jd7f2m9k4x1c8b3n5q0v6t2w \
  -H "Authorization: Bearer hk_live_..."
```

```json theme={null}
{
  "id": "jd7f2m9k4x1c8b3n5q0v6t2w",
  "status": "succeeded",
  "kind": "predict",
  "task": "classification",
  "trainRows": 3,
  "outputRows": 2,
  "cols": 4,
  "targetColumn": "species",
  "engineVersion": "eng_4b1c90fa72d5e836",
  "createdAt": "2026-08-07T11:04:22.913Z",
  "durationMs": 2841,
  "inputHash": "sha256:c723441a82ce9ac87b02dce28d34ffb5d31994d9231bf4cb46c8bb5081b486e2",
  "error": null
}
```

`status` moves through `queued` → `running` → `succeeded` or `failed`. A `failed` job carries
the typed envelope in `error`; see [Errors](/reference/errors).

`engineVersion` is stamped twice. The control plane writes its configured value at submit —
often `eng_pending` — and the worker overwrites it with the real `eng_` plus 16 hex
characters on completion. Read it from a terminal job, never from a queued one.

### 6. Read the result

```sh theme={null}
curl https://hollerith.monarcha.ai/v1/predictions/jd7f2m9k4x1c8b3n5q0v6t2w/result \
  -H "Authorization: Bearer hk_live_..."
```

```json theme={null}
{
  "predictions": ["setosa", "versicolor"],
  "classes": ["setosa", "versicolor", "virginica"],
  "probabilities": [[0.981, 0.014, 0.005], [0.008, 0.926, 0.066]],
  "quantiles": null,
  "quantileLevels": null
}
```

`predictions` is row-aligned with the `rows` you uploaded. The result is retained for
1 hour, then `/result` starts returning `training_ref_expired` (404).

## Poll /result too early and you get a 404

```json theme={null}
{"code": "job_not_found", "category": "not_found", ...}
```

`GET /v1/predictions/{id}/result` returns `job_not_found` (404) for any job that has not
succeeded. Queued, running and failed all give the same 404 as an id that does not exist.

It is not 409 and not 425. This is counterintuitive and it is stable, so branch on the
bare-id poll route for status and call `/result` only once `status` reads `succeeded`.

A `purged` job returns `training_ref_expired` (404) instead.

## Large results come back as a URL

```json theme={null}
{
  "predictions": [],
  "classes": null,
  "probabilities": null,
  "quantiles": null,
  "quantileLevels": null,
  "resultUrl": "https://<bucket>.s3.<region>.amazonaws.com/results/8ad1...?X-Amz-Signature=...",
  "resultContentHash": "sha256:8ad1f0c93b2e47a5..."
}
```

When the serialized result exceeds 512 KiB the worker stores it in object storage instead.
`/result` then returns `resultUrl` and `resultContentHash` in place of the data arrays.

The four data arrays are empty or null in that case. Do not read them — check for `resultUrl`
first, on every result read.

To finish the read yourself:

* **GET the URL.** It is presigned and expires 5 minutes after it was issued. Ask for the
  result again to get a fresh one.
* **Gunzip the body.** It is gzipped JSON with the same five data fields.
* **Verify the sha256.** Hash the gzipped bytes you received and compare to
  `resultContentHash`. A mismatch means a corrupt read, not a bad prediction.

The SDK does all four steps transparently. A direct integrator has to write them.

## Idempotency

```json theme={null}
{"idempotencyKey": "predict:iris-v1:2026-08-07"}
```

Every POST accepts an optional `idempotencyKey`. Replaying the same key with the same payload
returns the same job rather than creating a second one.

Reusing a key with a different payload returns `idempotency_conflict` (409). The fingerprint
is the job kind, task, target column, content hash, row and column counts, and engine version.

The key is validated only as a string. No length or character constraint is enforced, so the
shape of it is your convention to pick and keep.

A convention that survives contact with a retry loop:

```
<kind>:<dataset>-<version>:<run>
```

* **`fit:churn-v3:2026-08-07`** — one fit per dataset version per day.
* **`predict:churn-v3:batch-0912`** — one predict per batch identifier.
* **`eval:wine-v2:9f2c41ab`** — one evaluation per input hash.

Use a key on every submit. Without one, a network timeout you retry becomes a second billed
job, and byte-identical payloads from two concurrent runs share one staged object — the first
job to finish purges it out from under the second.

## classCount, and what omitting it costs

`classCount` is an optional admission hint on classification submits. It is the number of
distinct labels in your training target.

* **Send it** and the 160-class ceiling is checked at submit. You get `dataset_too_large`
  (422) from the POST, before the job is queued.
* **Omit it** and the check defers to the worker, which counts labels while streaming the
  payload. The job is admitted, queued, and then fails mid-flight with the same code, after
  you have waited for a GPU.

Under-reporting it does not bypass the limit. The worker re-counts either way.

***

## POST `/v1/uploads`

| Field         | Type    | Required | Notes                                                    |
| ------------- | ------- | -------- | -------------------------------------------------------- |
| `contentHash` | string  | yes      | sha256 of the gzipped bytes. `sha256:<hex>` or bare hex. |
| `bytes`       | integer | yes      | Exact gzipped length. Must be positive.                  |

Response, one of two shapes:

| Field         | Type                        | Present   | Notes                                                  |
| ------------- | --------------------------- | --------- | ------------------------------------------------------ |
| `kind`        | `"single"` \| `"multipart"` | always    | Decided by `bytes`; the split is at 64 MiB by default. |
| `key`         | string                      | always    | `staging/<hex>`, derived from `contentHash`.           |
| `url`         | string                      | single    | Presigned PUT, 1 hour expiry.                          |
| `uploadId`    | string                      | multipart | S3 multipart upload id.                                |
| `partSize`    | integer                     | multipart | Bytes per part except the last.                        |
| `partUrls`    | string\[]                   | multipart | One presigned `UploadPart` URL per part, in order.     |
| `completeUrl` | string                      | multipart | Presigned `CompleteMultipartUpload` URL.               |

For a multipart upload, PUT each slice to its URL in order, keep each response's `ETag`, then
POST the manifest to `completeUrl` with `Content-Type: application/xml`:

```xml theme={null}
<CompleteMultipartUpload>
  <Part><PartNumber>1</PartNumber><ETag>"a1b2c3..."</ETag></Part>
  <Part><PartNumber>2</PartNumber><ETag>"d4e5f6..."</ETag></Part>
</CompleteMultipartUpload>
```

Part numbers are 1-indexed and ETags are embedded with their quotes. A payload over the upload
ceiling is rejected here with `payload_too_large` (413), before anything is signed.

## POST `/v1/fits`

| Field            | Type                                 | Required | Notes                                   |
| ---------------- | ------------------------------------ | -------- | --------------------------------------- |
| `objectKey`      | string                               | yes      | The `key` from `/v1/uploads`.           |
| `contentHash`    | string                               | yes      | Must match the uploaded bytes.          |
| `task`           | `"classification"` \| `"regression"` | yes      | Any other value is a 400.               |
| `targetColumn`   | string                               | yes      | Must exist in the payload.              |
| `featureColumns` | string\[]                            | yes      | Column names, order significant.        |
| `trainRowCount`  | integer                              | yes      | Must equal the payload's training rows. |
| `classCount`     | integer \| null                      | no       | Classification only. See above.         |
| `idempotencyKey` | string \| null                       | no       |                                         |

The response is `{job, context}` — two complete objects, not an id.

```json theme={null}
{
  "job": { "id": "j...", "status": "queued", "kind": "fit", "outputRows": 0, "...": "..." },
  "context": {
    "id": "kc9x2m4v7b1n8q3t5w0r6z",
    "status": "creating",
    "task": "classification",
    "trainRows": 3,
    "cols": 4,
    "targetColumn": "species",
    "featureColumns": ["sepal_length","sepal_width","petal_length","petal_width"],
    "engineVersion": "eng_pending",
    "artifactSchemaVersion": 1,
    "trainHash": "sha256:c72344...",
    "artifactHash": null,
    "createdAt": "2026-08-07T11:04:22.913Z",
    "expiresAt": "2026-08-14T11:04:22.913Z",
    "lastUsedAt": null,
    "createdByJobId": "j...",
    "error": null
  }
}
```

The context is `creating` at submit. Poll `GET /v1/fits/{contextId}` until `status` is
`ready`, then reference `context.id` as `fittedContextId` on predicts.

## GET `/v1/fits/{contextId}`

Returns a bare `FittedContext`, not wrapped in anything. `status` is one of `creating`,
`ready`, `failed`, `expired`, `deleted`.

`expiresAt` is 7 days from creation. There is no endpoint to delete a context before then.

Any extra path segment under `/v1/fits/` returns `400 {"error":"invalid_request"}`.

## POST `/v1/predictions`

This endpoint accepts two mutually exclusive bodies. The handler tries the inline shape first
and falls back to the context shape.

Inline — training rows and rows to score in one job:

| Field            | Type                                 | Required | Notes              |
| ---------------- | ------------------------------------ | -------- | ------------------ |
| `objectKey`      | string                               | yes      |                    |
| `contentHash`    | string                               | yes      |                    |
| `task`           | `"classification"` \| `"regression"` | yes      |                    |
| `targetColumn`   | string                               | yes      |                    |
| `featureColumns` | string\[]                            | yes      |                    |
| `trainRowCount`  | integer                              | yes      | Ceiling 1,000,000. |
| `outputRowCount` | integer                              | yes      | Ceiling 200,000.   |
| `classCount`     | integer \| null                      | no       |                    |
| `idempotencyKey` | string \| null                       | no       |                    |

Context-backed — score against a ready fitted context, uploading only the rows to score:

| Field             | Type           | Required | Notes                                             |
| ----------------- | -------------- | -------- | ------------------------------------------------- |
| `fittedContextId` | string         | yes      | `context.id` from `/v1/fits`.                     |
| `objectKey`       | string         | yes      | Payload must carry `"kind": "contextPrediction"`. |
| `contentHash`     | string         | yes      |                                                   |
| `outputRowCount`  | integer        | yes      | Ceiling 200,000.                                  |
| `idempotencyKey`  | string \| null | no       |                                                   |

Both return a `PredictionJob`.

A typo in the inline shape does not report itself as a typo. The body fails the inline parser,
falls through to the context parser, fails that too, and you get a bare
`400 {"error":"invalid_request"}` with no indication of which shape was intended or which
field was wrong.

Check `fittedContextId` first when you see that 400. Its presence or absence is the only thing
that tells the two shapes apart.

## GET `/v1/predictions/{jobId}`

| Field           | Type                                                         | Notes                                        |
| --------------- | ------------------------------------------------------------ | -------------------------------------------- |
| `id`            | string                                                       |                                              |
| `status`        | `queued` \| `running` \| `succeeded` \| `failed` \| `purged` |                                              |
| `kind`          | `fit` \| `predict` \| `evaluate` \| `forecast`               |                                              |
| `task`          | `classification` \| `regression`                             |                                              |
| `trainRows`     | integer                                                      | Total rows minus scored rows.                |
| `outputRows`    | integer                                                      | 0 for `fit` and `evaluate`.                  |
| `cols`          | integer                                                      |                                              |
| `targetColumn`  | string                                                       |                                              |
| `engineVersion` | string                                                       | `eng_pending` until the worker re-stamps it. |
| `createdAt`     | string                                                       | ISO-8601.                                    |
| `durationMs`    | integer \| null                                              | Null until terminal.                         |
| `inputHash`     | string                                                       | The content hash you submitted.              |
| `error`         | envelope \| null                                             | Populated on `failed`.                       |

This is the poll route for every job kind, including evaluations and forecasts.

## GET `/v1/predictions/{jobId}/result`

| Field               | Type                 | Notes                                                                  |
| ------------------- | -------------------- | ---------------------------------------------------------------------- |
| `predictions`       | array                | Row-aligned with the rows you uploaded. Empty when `resultUrl` is set. |
| `classes`           | array \| null        | Classification only. The ordering `probabilities` uses.                |
| `probabilities`     | number\[]\[] \| null | One row per scored row, columns aligned to `classes`.                  |
| `quantiles`         | number\[]\[] \| null | Columns aligned to `quantileLevels`.                                   |
| `quantileLevels`    | number\[] \| null    | The requested probability levels.                                      |
| `resultUrl`         | string               | Present only for results over 512 KiB.                                 |
| `resultContentHash` | string \| null       | Present with `resultUrl`.                                              |

For a forecast job, `predictions` is one object per forecast step: `item_id`, `timestamp`,
`mean`, and one key per quantile level named by its stringified level.

## POST `/v1/evaluations`

Identical to `/v1/fits` except that the row count field is named `rowCount`.

| Field            | Type                                 | Required | Notes                               |
| ---------------- | ------------------------------------ | -------- | ----------------------------------- |
| `objectKey`      | string                               | yes      |                                     |
| `contentHash`    | string                               | yes      |                                     |
| `task`           | `"classification"` \| `"regression"` | yes      |                                     |
| `targetColumn`   | string                               | yes      |                                     |
| `featureColumns` | string\[]                            | yes      |                                     |
| `rowCount`       | integer                              | yes      | Labeled rows the worker will split. |
| `classCount`     | integer \| null                      | no       |                                     |
| `idempotencyKey` | string \| null                       | no       |                                     |

There is no `outputRowCount`. The worker splits the labeled set itself, and the returned job
has `kind: "evaluate"` and `outputRows: 0`.

Poll it at `GET /v1/predictions/{jobId}`. Read it at `GET /v1/evaluations/{jobId}/result`.

## GET `/v1/evaluations/{jobId}/result`

| Field    | Type                     | Notes                                                     |
| -------- | ------------------------ | --------------------------------------------------------- |
| `metric` | string                   | `"accuracy"` for classification, `"RMSE"` for regression. |
| `value`  | number                   |                                                           |
| `method` | `"holdout"` \| `"kfold"` | Chosen server-side by row count.                          |
| `folds`  | integer                  | Omitted, not null, for holdout.                           |
| `rows`   | integer                  |                                                           |

`GET /v1/evaluations/{jobId}` without the `/result` suffix is a 400, not a poll route.

## POST `/v1/forecasts`

| Field              | Type           | Required | Notes                                                                                      |
| ------------------ | -------------- | -------- | ------------------------------------------------------------------------------------------ |
| `objectKey`        | string         | yes      | Payload must carry `"kind": "forecast"`.                                                   |
| `contentHash`      | string         | yes      |                                                                                            |
| `covariateColumns` | string\[]      | yes      | Every payload column except `timestamp`, `target`, `item_id`. Send `[]` if there are none. |
| `contextRowCount`  | integer        | yes      | Ceiling 1,000,000.                                                                         |
| `outputRowCount`   | integer        | yes      | Ceiling 200,000.                                                                           |
| `idempotencyKey`   | string \| null | no       |                                                                                            |

The returned job has `kind: "forecast"`, `task: "regression"` and `targetColumn: "target"`.

A non-empty `covariateColumns` must match the payload's covariate keys exactly, or the job
fails with `schema_mismatch`. Sending `[]` skips that comparison rather than tightening it, so
a payload that does carry covariates goes unchecked.

There is no `GET /v1/forecasts/{id}`. Poll at `GET /v1/predictions/{id}` and read at
`GET /v1/predictions/{id}/result`, like any other job.

`outputRowCount` must equal the number of `future` rows in your payload, or the horizon times
the number of distinct `item_id` values. The worker recomputes it and fails on a mismatch.

Forecasting is Beta, and it never uses a fitted context.

## GET `/v1/model-limits`

No key. Cached for 300 seconds.

```json theme={null}
{
  "defaultModelVersion": "<version>",
  "modelLimits": {
    "<version>": {
      "maxTrainRows": 1000000,
      "maxTrainCells": 100000000,
      "maxTestRows": 200000,
      "maxColumns": 2000,
      "maxClasses": 160,
      "maxUploadBytes": 5000000000,
      "forecast": {
        "maxContextRows": 1000000,
        "maxContextCells": 100000000,
        "maxOutputRows": 200000,
        "maxCovariateColumns": 2000,
        "maxUploadBytes": 5000000000
      }
    }
  }
}
```

`maxUploadBytes` here is the default. A deployment can be configured with a lower ceiling, and
that override is not reflected in this response.

Full context for each number is in [Limits and quotas](/reference/limits).

***

## Payload shapes

The uploaded object is gzipped JSON. Which keys it needs depends on the job.

| Job             | Payload keys                                                                               |
| --------------- | ------------------------------------------------------------------------------------------ |
| Fit             | `task`, `targetColumn`, `featureColumns`, `trainingFeatures`, `trainingTarget`, `rows: []` |
| Inline predict  | the same, with `rows` holding the records to score                                         |
| Context predict | `kind: "contextPrediction"`, `rows`                                                        |
| Evaluate        | `task`, `targetColumn`, `featureColumns`, `trainingFeatures`, `trainingTarget`, `rows: []` |
| Forecast        | `kind: "forecast"`, `context`, `future`, `predictionLength`, `quantiles`                   |

`trainingFeatures`, `rows`, `context` and `future` are all arrays of row records — one JSON
object per row, keyed by column name. `trainingTarget` is a flat array of labels.

A forecast payload must include all four of its keys. `future` and `predictionLength` may be
`null`, but exactly one of them has to be set, and `quantiles` must be present.

## What REST cannot do that the SDK can

Three parameters are not REST fields at all. `quantiles`, `predictionLength` and `future` ride
inside the uploaded payload, not the submit body.

For a regression predict, that means prediction intervals are requested by adding a
`quantiles` array to the payload before you hash and upload it. There is no submit-body field
for them, so an integration written from the endpoint tables alone cannot ask for intervals.

The SDK is also the only place these live:

* **Result reassembly.** Fetching, gunzipping and verifying an over-512 KiB result.
* **Multipart orchestration.** Part splitting, ETag collection and the completion manifest.
* **Poll loops.** Including retrying the three `worker_*` and `cache_*` conditions.

## Errors on this surface

Typed failures return the full envelope with `code`, `category`, `problem`, `cause`, `fix`,
`docUrl`, `retryable` and `requestId`. Branch on `code`.

Malformed input does not.

```json theme={null}
{"error": "invalid_request"}
```

A body that fails to parse returns `400 {"error":"invalid_json"}`. A body that parses but
matches no accepted shape returns `400 {"error":"invalid_request"}`.

Neither carries a `code`, a `category` or a `requestId`. So "every error is a typed envelope"
is false, and there is nothing to quote to support when you hit one.

The 26 typed codes, their HTTP statuses and which four are retryable are in
[Errors](/reference/errors).

## Checklist for a direct integration

* **Hash the gzipped bytes**, not the JSON text, and prefix with `sha256:`.
* **Send `bytes` as the exact gzipped length.** It decides single versus multipart; understate it and you get a plan that does not fit the payload.
* **Check `kind`** on the upload response before assuming a single PUT.
* **Send `classCount`** on classification so the ceiling is checked at submit.
* **Send an `idempotencyKey`** on every POST, and never reuse one with a changed payload.
* **Make counts match the payload** exactly, or the worker fails the job after queueing.
* **Poll the bare-id route**, not `/result`, until `status` is `succeeded`.
* **Check `resultUrl`** on every result read, and verify its hash after gunzipping.
* **Read results inside 1 hour.** After that `/result` returns `training_ref_expired`.

## Next

* [Python SDK](/reference/python-sdk) — everything on this page, already written
* [Errors](/reference/errors) — the 26 codes and the four that are retryable
* [Limits and quotas](/reference/limits) — every ceiling and the error you get at it
* [Authentication](/authentication) — creating and rotating keys
