Skip to content

The HTTP API v0.2

All the actions of the web app, the CLI and the Python client are calls to the versioned /v1 HTTP API. This guide does the main flow with curl and jq. Thus you can see each request. The script is docs/snippets/http_tour.sh. make e2e runs it against a live stack. The outputs below come from one of those runs, and are shorter.

Where What
HTTP API reference Conventions, errors, pagination, idempotency, SSE and all the endpoints with their DTOs
OpenAPI explorer The OpenAPI 3.1 document, which you can browse, with all the schemas and examples
GET /openapi.json on a running API The same document, from the API
Error codes All the stable error codes and their HTTP status

First, start the stack and load the demo (see the quickstart):

  1. Run make dev.
  2. Run make seed.
  3. Run uv run python scripts/seed.py --export demo/.
  4. Run uv run mapi datasets upload demo/eu_media_future.zarr.zip --name eu_media_future.

From v0.3, make dev needs sign-in. Set MAPI_TOKEN to an API token. You get the token from the Tokens page of the web app, or from mapi tokens create. Set MAPI_PROJECT to the <tenant>/<project> for the token. The api function of the script then adds Authorization: Bearer … and X-Mapi-Project to each API call. It never sends them to signed storage URLs, because these URLs have their own authorization. See authentication.

Health and the catalog

API=${MAPI_API_URL:-http://127.0.0.1:8000}
api() {  # curl the API; signed in when MAPI_TOKEN is set (every multi-tenant stack, v0.3)
  if [ -n "${MAPI_TOKEN:-}" ]; then
    curl -s -H "Authorization: Bearer $MAPI_TOKEN" \
      -H "X-Mapi-Project: ${MAPI_PROJECT:-default/default}" "$@"
  else
    curl -s "$@"
  fi
}
curl -s "$API/readyz" | jq -c '{status, version, checks: .checks | map_values(.status)}'
api "$API/v1/catalog/models" \
  | jq -r '.items[].manifest | "\(.id)@\(.version)  \(.capabilities.representation)"'
{"status":"ok","version":"0.15.1","checks":{"postgres":"ok","object_store":"ok","catalog":"ok"}}
mmm.bayesian@1.0.0  draws
mmm.frequentist@1.0.0  draws
sklearn.tabular_bootstrap@1.0.0  draws
…
ts.hsgp_trend@1.0.0  draws

/healthz tells if the process is alive. /readyz checks PostgreSQL, the object store and the catalog. If one of them is not ready, it answers 503 with the same body. The catalog is what the runner image supplies. A sandboxed describe run exports it. The server validates specs against the catalog.

Upload a CSV

Data never goes through the API. Do these steps:

  1. Make a dataset.
  2. Ask for an upload session.
  3. PUT the bytes to the signed URL that the session gives.
DS=$(api "$API/v1/datasets?limit=200" | jq -r '.items[] | select(.name == "weekly_sales") | .id')
if [ -z "$DS" ]; then
  DS=$(api -X POST "$API/v1/datasets" -H 'Content-Type: application/json' \
    -d '{"name": "weekly_sales", "description": "a CSV upload from the HTTP guide"}' | jq -r .id)
fi
echo "dataset $DS"
printf 'week,sales\n2026-01-04,120\n2026-01-11,131\n2026-01-18,127\n2026-01-25,140\n' > sales.csv
SIZE=$(wc -c < sales.csv | tr -d ' ')
UP=$(api -X POST "$API/v1/datasets/$DS/uploads" -H 'Content-Type: application/json' \
  -d "{\"filename\": \"sales.csv\", \"content_type\": \"text/csv\", \"size_bytes\": $SIZE}")
HEADERS=()  # the signed URL binds these headers: send them exactly
while IFS= read -r h; do HEADERS+=(-H "$h"); done \
  < <(jq -r '.upload.headers | to_entries[] | "\(.key): \(.value)"' <<<"$UP")
curl -s -o /dev/null -w 'PUT %{http_code}\n' -X PUT "${HEADERS[@]}" \
  --data-binary @sales.csv "$(jq -r .upload.url <<<"$UP")"
dataset ds_01m4zy65cdbxtaa4vgsvpeb4rt
PUT 200

The URL is a V4-style signed URL for one object in the quarantine bucket. It is valid for one hour. Send the headers from upload.headers exactly, because they are part of the signature:

  • content-type must be the type that you declared.
  • x-goog-content-length-range limits the size to size_bytes.
  • x-goog-if-generation-match: 0 makes the object write-once. Thus the storage refuses a second PUT to the same URL.

From v0.15, an upload whose file arrived has the status uploaded. You can use it in a version also after its URL expired. If the URL expired before the file arrived, sign a new URL with POST /v1/datasets/{dataset_id}/uploads/{upload_id}/renew (see Resumable uploads and image collections).

Then change the upload into a dataset version. For CSV and Parquet, the mapping gives the column of each dim and variable. It also gives its kind, its score-time policy and its role:

MAPPING='{
  "format": "csv",
  "dims": {"week": {"column": "week", "spec": {"kind": "time", "policy": "extend", "freq": "W-SUN"}}},
  "vars": {"sales": {"column": "sales", "role": "target", "units": "units"}}
}'
DV=$(api -X POST "$API/v1/datasets/$DS/versions" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"upload_id\": $(jq .id <<<"$UP"), \"mapping\": $MAPPING}")
V=$(jq -r .version <<<"$DV")
STATUS=ingesting
while [ "$STATUS" = ingesting ]; do  # a sandboxed ingest run parses and checks the file
  sleep 1
  STATUS=$(api "$API/v1/datasets/$DS/versions/$V" | jq -r '.status // "ingesting"')
done
api "$API/v1/datasets/$DS/versions/$V" | jq '{ref, status, dims: .data_schema.dims | keys}'
{
  "ref": "dataset://default/weekly_sales@v1",
  "status": "ready",
  "dims": ["week"]
}

POST …/versions answers 202 with the version in status ingesting. The answer also has the id of the sandboxed ingest run that parses the file. The call accepts an Idempotency-Key. If you send the request again with the same key and body, you get the same version. The platform does not ingest the file two times.

Validate and fit a spec

jq -Rs '{yaml: .}' demo/eu-geo-mmm.yaml \
  | api -X POST "$API/v1/specs:validate" -H 'Content-Type: application/json' -d @- \
  | jq '{valid, issues: [.issues[] | {code, pointer}], stages: [.stages[] | .name]}'
{
  "valid": true,
  "issues": [],
  "stages": ["raw", "to_eur", "weekly", "scale_spend", "scale_y", "lags", "season", "hols"]
}

POST /v1/specs:validate answers 200, also for a spec that is not valid. The result is in valid. Each problem is in issues with its code and JSON pointer. The server uses non-2xx statuses only for requests that it cannot validate. Examples are YAML that it cannot parse, or a request that is too large.

SPEC=$(api "$API/v1/specs?limit=200" | jq -c '.items[] | select(.name == "eu-geo-mmm")')
RUN=$(api -X POST "$API/v1/runs" -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"kind\": \"fit\", \"spec_id\": $(jq .id <<<"$SPEC"), \"revision\": $(jq .latest_revision <<<"$SPEC")}" \
  | jq -r .id)
echo "run $RUN"

POST /v1/runs answers 201 with the run in state created or queued. If you send the same Idempotency-Key with the same body, the answer is 200 with the same run. If you send it with a different body, the answer is 409 IDEMPOTENCY_CONFLICT. Make one key for each user intent. Use the same key again when you try again.

Follow the run

# server-sent events: a snapshot, the persisted events, live progress, then `end`
api -N -H 'Accept: text/event-stream' "$API/v1/runs/$RUN/events" | grep '^event:' | uniq -c
api "$API/v1/runs/$RUN" | jq '{state, output_model_version_id}'
   3 event: snapshot
   1 event: state
   1 event: warning
   2 event: state
   6 event: progress
   1 event: state
   5 event: diagnostic
   3 event: metric
   1 event: state
   1 event: complete
   1 event: state
   2 event: snapshot
   1 event: end
{
  "state": "succeeded",
  "output_model_version_id": "mv_01m4zy6yc82qy97jzjmv9phv1j"
}

With Accept: text/event-stream, the events endpoint is a live stream. It sends these items:

  1. A snapshot of the run.
  2. The persisted events. Each event has an id:.
  3. Live progress and log frames. These have no id, and the server never sends them again. From v0.11, live tracking frames tell that the metrics, the params or the tags of the run changed. From v0.14, they also tell that the run logged images or tables (media).
  4. A new snapshot at each change of state.
  5. end when the run is terminal. Then the server closes the stream.

To continue exactly after the last persisted event, connect again with Last-Event-ID: <id>. Without that Accept header, the same URL gives the persisted events as a JSON page. See Run events.

Read the model version

MODEL=$(api "$API/v1/models?limit=200" | jq -c '.items[] | select(.name == "eu-geo-mmm")')
M=$(jq -r .id <<<"$MODEL")
MV=$(jq -r .latest_version <<<"$MODEL")
api "$API/v1/models/$M/versions/$MV" \
  | jq '{ref, scorecard: .scorecard.overall, metrics, signed: .manifest.signature.alg}'
{
  "ref": "model://default/eu-geo-mmm@v1",
  "scorecard": "warn",
  "metrics": {"mape": 7.59, "elpd_loo": 183.47, "coverage_90": 0.875},
  "signed": "KMS-EC-P256-SHA256"
}

GET …/artifacts/{name} gives a signed GET URL for a bundle object or a ui/ view. The server makes the link only after it verifies the signed manifest of the version. Each link is pinned to an object generation. It supports HTTP Range requests and expires after 15 minutes.

Score

RES=$(api -X POST "$API/v1/models/$M/versions/$MV:score" \
  -H 'Content-Type: application/json' -H 'Prefer: wait=60' -H "Idempotency-Key: $(uuidgen)" \
  -d '{"request": {
        "apiVersion": "mapi/v1", "kind": "ScoreRequest",
        "model": "model://default/eu-geo-mmm@latest",
        "mode": "forecast",
        "horizon": {"dim": "time", "periods": 8},
        "data": "dataset://default/eu_media_future@latest",
        "side_tables": {"fx": "dataset://default/fx_daily@latest"}}}')
jq '{done, state: .run.state, outputs: [.outputs[] | {name, size, crc32c}]}' <<<"$RES"
curl -s "$(jq -r '.outputs[] | select(.name == "result.json") | .url' <<<"$RES")" \
  | jq '{mode, representation, output_space}'
{
  "done": true,
  "state": "succeeded",
  "outputs": [
    {"name": "plots.json", "size": 4122, "crc32c": "d8PGZA=="},
    {"name": "result.json", "size": 406, "crc32c": "PonvmQ=="},
    {"name": "score.zarr.zip", "size": 9396, "crc32c": "Uwymhw=="},
    {"name": "ui/summary.arrow", "size": 27266, "crc32c": "hxiS8w=="}
  ]
}
{
  "mode": "forecast",
  "representation": "draws",
  "output_space": {"contributions": "weekly", "y_hat": "weekly"}
}

Prefer: wait=60 keeps the request open until the score run finishes, for a maximum of 60 seconds. Then it answers 200 with done: true and signed links to the outputs. If the run takes more time, the answer is 202 with done: false. In this case, do one of these steps:

  • To get the result of the same run, send the request again with the same Idempotency-Key.
  • Follow the run.

The response header Preference-Applied tells which wait the server used. Over HTTP, the ScoreRequest needs its apiVersion and kind. The Python client fills them in.

The request must give data to score: data, inline values, a scenario dataset or a horizon. If it gives none of them, the server refuses it before a run starts (SCHEMA_INVALID at /data, "the score request has no data").

ui/summary.arrow names its dim columns in the schema metadata mapi.dims (a JSON list). Use this list to find the keys of a row. A dim with numeric labels, for example a grid position in cm, has a numeric type, the same as a statistic. In summary.json, each table of a circular output (OutputDecl.circular) has the field period. Its statistics are then on the circle:

  • mean is the circular mean, and sd is the circular standard deviation.
  • Each interval and quantile is centred on the circular mean.
  • All values are in [0, period). An interval with lower more than upper crosses the seam.

Each table of summary.json also gives dims, the names of its coordinate columns (the columns before the statistics), and time_dim, the dim that is time when there is one. A server that predates these fields leaves them out. From v0.10, plots gives the ids of the plots of the run that draw the output, for example sci.area_map or core.roc_pr. The tables are in the order of their size. Thus, when the server cuts a large summary, it drops a large grid table and keeps a small table such as total.

Track metrics, params and tags v0.11

A run keeps metrics, params, tags, a name and a note. The model logs most of them while it fits. You can add more, also after the run finished. The writes need the runner role. An API token needs the runs:write scope.

  1. Log a metric point. A point without step is a scalar:

    api -X POST "$API/v1/runs/$RUN/metrics" -H 'Content-Type: application/json' \
      -d '{"metrics": [{"key": "revenue_lift", "value": 0.042, "labels": {"region": "north"}}]}'
    
    {"accepted": 1, "duplicates": 0}
    
  2. Log params. A param does not change: a different value for a stored key gets 409 CONFLICT.

    api -X POST "$API/v1/runs/$RUN/params" -H 'Content-Type: application/json' \
      -d '{"params": {"analyst": "kim", "holdout_weeks": 8}}'
    
  3. Set the name, the note and the tags. A tag with the value null is deleted:

    api -X PATCH "$API/v1/runs/$RUN" -H 'Content-Type: application/json' \
      -d '{"name": "weekly sales, adstock 4", "tags": {"stage": "production"}}'
    
  4. Read the series and the points:

    api "$API/v1/runs/$RUN/metrics" | jq -c '.items[] | {key, labels, last, count}'
    api "$API/v1/runs/$RUN/metrics/step_size?max_points=200&label=chain:0" \
      | jq '.series[0].values | length'
    
  5. Find runs by their metrics and params:

    api -G "$API/v1/runs" --data-urlencode 'filter=metric.rmse < 12' \
      --data-urlencode 'order_by=metric.rmse' --data-urlencode 'column=metric.rmse' \
      | jq -c '.items[] | {id, name, columns}'
    

In these examples, api is the helper function of http_tour.sh. For all the endpoints and the search grammar, see Run tracking. For the logging API inside a model, see Track metrics, params and tags.

Media, experiments and comparison v0.14

  1. Log an image to a run. data is the base64 of a PNG or a JPEG file (a maximum of 4 MiB):

    api -X POST "$API/v1/runs/$RUN/media" -H 'Content-Type: application/json' \
      -d "{\"key\": \"holdout_map\", \"kind\": \"image\", \"content_type\": \"image/png\",
           \"data\": \"$(base64 < map.png | tr -d '\n')\"}"
    
  2. List the media of the run. Each item has a signed url:

    api "$API/v1/runs/$RUN/media-keys" | jq -c '.items[] | {key, labels, count, last_step}'
    api "$API/v1/runs/$RUN/media?key=holdout_map" | jq -r '.items[0].url'
    
  3. Make an experiment and move two runs into it:

    EXP=$(api -X POST "$API/v1/experiments" -H 'Content-Type: application/json' \
      -d '{"name": "adstock length study"}' | jq -r .id)
    api -X POST "$API/v1/experiments/$EXP/runs" -H 'Content-Type: application/json' \
      -d "{\"add\": [\"$RUN\", \"$RUN2\"]}"
    
    {"added": 2, "removed": 0, "missing": []}
    
  4. Compare the two runs:

    api -G "$API/v1/run-compare" --data-urlencode "run=$RUN" --data-urlencode "run=$RUN2" \
      | jq -c '.params[] | select(.differs) | {key, values}'
    
  5. Save a search of the runs table:

    api -X POST "$API/v1/run-views" -H 'Content-Type: application/json' \
      -d "{\"name\": \"best fits\", \"experiment_id\": \"$EXP\",
           \"query\": {\"filters\": [\"metric.rmse < 12\"], \"order_by\": \"metric.rmse\"}}"
    

For all the endpoints, see Media, comparison, experiments and saved views.

Resumable uploads and image collections v0.15

An image collection is a dataset version that keeps each image as a file. The CLI and the Python client do these steps for you (see Image data: collections). With HTTP, do these steps:

  1. Calculate the SHA-256 of each image. Ask which images the dataset does not have. A request has a maximum of 5000 values:

    api -X POST "$API/v1/datasets/$DATASET_ID/blobs/missing" -H 'Content-Type: application/json' \
      -d '{"sha256": ["<64 hex characters>", "<64 hex characters>"]}'
    
    {"base_version": 4, "missing": ["<64 hex characters>"], "present": 1}
    
  2. Put the missing images in zip parts. Name each image <sha256>.<ext>, for example 9f86d0….png. Upload the manifest table and each part with an upload session, as for a CSV file. The manifest has one row for each image, with the SHA-256 in the column sha256 (CollectionOptions.sha256_column).

  3. Make the version from the uploads:

    api -X POST "$API/v1/datasets/$DATASET_ID/versions" -H 'Content-Type: application/json' \
      -d "{\"collection\": {\"manifest_upload_id\": \"$MANIFEST\",
           \"part_upload_ids\": [\"$PART0\"], \"base_version\": null}}"
    

    base_version: null uses the latest ready collection version for the images that the parts do not have. 0 uses no base version.

  4. To continue an upload that stopped, read each upload. received tells you if its file arrived. Renew the URL of an upload whose file did not arrive:

    api "$API/v1/datasets/$DATASET_ID/uploads/$PART0" | jq '{status, received}'
    api -X POST "$API/v1/datasets/$DATASET_ID/uploads/$PART0/renew" | jq -r .upload.url
    

get_upload and renew_upload need the update permission on the dataset, because their answer has a signed PUT URL. For the details of the parts, the manifest and the errors, see Resumable uploads and image collections.

Errors

api -X POST "$API/v1/models/$M/versions/$MV:score" -H 'Content-Type: application/json' \
  -d '{"request": {"apiVersion": "mapi/v1", "kind": "ScoreRequest",
       "model": "model://default/eu-geo-mmm@latest", "mode": "filter",
       "data": "dataset://default/eu_media_future@latest"}}' | jq '{status, code, detail}'
{
  "status": 422,
  "code": "MODE_NOT_SUPPORTED",
  "detail": "validation failed with 2 errors; first at /mode: model 'mmm.bayesian@1.0.0' does not support mode 'filter'"
}

Each error is an RFC 7807 problem document (application/problem+json). It has a stable code, an HTTP status, and each individual problem in errors[] with a JSON pointer. Tracebacks and plugin messages show only in an untrusted_text field. Render them as text. The server refused the score request above statically. It did not make a run.

Other clients

The server makes the OpenAPI document itself. apps/web/scripts/gen_openapi.py writes apps/web/openapi.json, and CI fails if the file and the server do not agree. Thus all OpenAPI generators can make a typed client. The TypeScript client of the web app comes from this method (openapi-typescript and openapi-fetch). For Python, use mapi_client.