Skip to main content
Most people integrating with Hollerith should use the 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

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

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.
GET /v1/model-limits and the wheel download take no key. Every other endpoint requires one.
  • No Authorization headermissing_api_key, 401.
  • Unrecognized keyinvalid_api_key, 401.
  • Revoked keyrevoked_api_key, 401.
  • A key from another organization’s resourceorg_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

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

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 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

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.

3. PUT the bytes

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

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

status moves through queuedrunningsucceeded or failed. A failed job carries the typed envelope in error; see 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

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

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

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

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:
  • 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

Response, one of two shapes: 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:
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

The response is {job, context} — two complete objects, not an id.
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: Context-backed — score against a ready fitted context, uploading only the rows to score: 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}

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

GET /v1/predictions/{jobId}/result

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. 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

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

POST /v1/forecasts

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.
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.

Payload shapes

The uploaded object is gzipped JSON. Which keys it needs depends on the job. 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.
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.

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