Skip to content

Keep data current from your own systems

A scheduled script, an ETL tool or a partner platform can keep a project's data current without anyone opening SCMotif: upload the new file into its data source, run the workflow you already built on it, wait for the run to finish, and download the result. The workflow is not edited: its Import Data step reads the data source's latest file, which is the one just uploaded. It does that over HTTP with an integration key you create in Settings.

Workflows and dashboards are still built in the app or by your agent. A key reaches only what a system needs to refresh data: your projects, workflows and data sources to find its way, uploads, runs and their findings, and result downloads.

Create a key

  1. Open Settings and find Integration keys.
  2. Press Create key and give it a name that says which system will hold it, such as Monthly TMS export.
  3. Choose what it can do. Read only lets the system list, read and download. Read and write also lets it upload files and start and cancel runs.
  4. Choose what it can reach: your whole workspace, including projects you create later, or only the projects you pick.
  5. Press Create key, copy the key and store it where the system can read it, such as its secret store.

The key is shown once. SCMotif keeps only a fingerprint of it and cannot show it again. A key acts as you, within what you chose for it; give each system its own key, so you can end one without touching the others.

Rotate replaces a key's secret and keeps its name and access; give the system the new key. Revoke ends the key. Either way the old key is refused from its very next call.

Every call

Send the key in the Authorization header of every request, and JSON in and out:

Authorization: Bearer <your key>
Accept: application/json
Content-Type: application/json

Requests go to the address you open SCMotif at, such as https://demo.scmotif.com. The complete description of every request and answer, in OpenAPI form, is at:

https://demo.scmotif.com/api/integration/openapi.json

Load it into your integration tool to get every request with its inputs.

A project-scoped request names its project as project_id: in the query of a read, in the body of a change.

The steps

sequenceDiagram
    participant S as Your system
    participant M as SCMotif
    participant F as File storage
    S->>M: POST /data-sources/upload
    M-->>S: upload with signed part links
    S->>F: PUT each part
    S->>M: POST /upload-sessions/{id}/complete
    S->>M: GET /upload-sessions/{id} until ready
    S->>M: POST /workflows/{id}/run
    S->>M: GET /executions/{id} until finished
    S->>M: POST /scenarios/{id}/export
    M-->>S: signed download link
    S->>F: GET the file

Find the project, the workflow and the data source. GET /projects lists the projects the key reaches. GET /workflows and GET /data-sources list a project's workflows and data sources, each with its id; a data source also names its latestFile. GET /data-sources/{id}/files lists its files, newest first.

Upload the file. A file's bytes never travel in a request to SCMotif. POST /data-sources/upload takes the file's name, its exact size_bytes, the lowercase hex sha256 of its contents, and where it goes: data_source_id, the data source it joins as its latest file, or data_source_name for a new data source. It answers an upload with a partSize and a list of targets. Send each part of the file — part n is the bytes from (n − 1) × partSize — as a PUT to its target's url, with every header the target lists, including its User-Agent. The links expire within minutes; POST /upload-sessions/{id}/resume answers fresh ones and the parts still missing.

When every part is sent, POST /upload-sessions/{id}/complete. SCMotif then checks the whole file. Poll GET /upload-sessions/{id} until state is ready, which carries the new file's fileId and its data source's dataSourceId, or rejected, which carries what was wrong with the file in diagnostics.

A file name is never reused in a project: give each upload its own name, such as tms-2026-11.csv. A workflow step that reads the data source's latest file reads it from the next run; one set to a specific file keeps reading that file.

Run the workflow. POST /workflows/{id}/run answers 201 with the run's executionId. Poll GET /executions/{id} until status is completed, failed or cancelled. A completed run lists the scenarios it saved. For a run that did not complete, GET /executions/{id}/diagnostics lists what went wrong, a page at a time.

Download the result. POST /scenarios/{id}/export prepares the scenario's tables as one ZIP file and answers a download_url. Fetch it with a plain GET, without the key; the link expires within minutes, and asking for the export again answers a fresh one.

When a call is refused

Answer What it means What to do
401 The key was revoked, replaced by a rotation, or never issued. Create or rotate a key in Settings and give the system the new one.
403 The key is read only, and the request uploads or starts a run. Create a key with Read and write for this system.
404 Nothing the key reaches has that id. A project the key was not given answers exactly like one that does not exist. Check the id, and the projects the key reaches in Settings.
422 An input is missing or cannot be read, or SCMotif refused the request and says why. Read error, or each entry in diagnostics, and correct the request.

An example script

This Python script, using only the standard library, uploads a file into a data source, runs a workflow that reads it and downloads every scenario the run saved:

SCMOTIF_URL=https://demo.scmotif.com SCMOTIF_KEY=<your key> \
  python refresh.py "Cost to serve" "TMS shipments" "Monthly cost to serve" \
  export.csv tms-2026-11.csv
"""Upload a file, run a workflow on it, and download the result."""

import hashlib
import json
import os
import sys
import time
import urllib.parse
import urllib.request

BASE = os.environ["SCMOTIF_URL"]  # the address you open SCMotif at
KEY = os.environ["SCMOTIF_KEY"]  # the integration key from Settings


def call(method, path, body=None, query=None):
    url = BASE + path + ("?" + urllib.parse.urlencode(query) if query else "")
    request = urllib.request.Request(
        url,
        method=method,
        data=None if body is None else json.dumps(body).encode(),
        headers={
            "Authorization": f"Bearer {KEY}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
    )
    with urllib.request.urlopen(request) as answer:
        return json.load(answer)


def by_name(rows, name):
    return next(row for row in rows if row["name"] == name)


def upload(project_id, data_source_id, path, name):
    data = open(path, "rb").read()
    session = call(
        "POST",
        "/data-sources/upload",
        {
            "project_id": project_id,
            "data_source_id": data_source_id,
            "name": name,
            "size_bytes": len(data),
            "sha256": hashlib.sha256(data).hexdigest(),
        },
    )
    while True:
        for target in session["targets"]:
            start = (target["partNumber"] - 1) * session["partSize"]
            part = data[start : start + session["partSize"]]
            put = urllib.request.Request(
                target["url"], method="PUT", data=part, headers=target["headers"]
            )
            urllib.request.urlopen(put).close()
        session = call(
            "GET", f"/upload-sessions/{session['id']}", query={"project_id": project_id}
        )
        if session["confirmedParts"] == session["totalParts"]:
            break
        session = call(
            "POST",
            f"/upload-sessions/{session['id']}/resume",
            {"project_id": project_id},
        )
    call(
        "POST", f"/upload-sessions/{session['id']}/complete", {"project_id": project_id}
    )
    while session["state"] not in ("ready", "rejected", "expired"):
        time.sleep(2)
        session = call(
            "GET", f"/upload-sessions/{session['id']}", query={"project_id": project_id}
        )
    if session["state"] != "ready":
        sys.exit(f"the file was not accepted: {session['diagnostics']}")
    return session["fileId"]


def run(project_id, workflow_id):
    started = call("POST", f"/workflows/{workflow_id}/run", {"project_id": project_id})
    while True:
        execution = call(
            "GET",
            f"/executions/{started['executionId']}",
            query={"project_id": project_id},
        )
        if execution["status"] not in ("pending", "running"):
            break
        time.sleep(5)
    if execution["status"] != "completed":
        findings = call(
            "GET",
            f"/executions/{started['executionId']}/diagnostics",
            query={"project_id": project_id, "severity": "error"},
        )
        sys.exit(f"the run {execution['status']}: {json.dumps(findings)}")
    return execution["scenarios"]


def download(project_id, scenario_id):
    export = call(
        "POST", f"/scenarios/{scenario_id}/export", {"project_id": project_id}
    )
    with (
        urllib.request.urlopen(export["download_url"]) as file,
        open(export["filename"], "wb") as out,
    ):
        out.write(file.read())
    return export["filename"]


if __name__ == "__main__":
    project_name, source_name, workflow_name, path, name = sys.argv[1:6]
    project = by_name(call("GET", "/projects"), project_name)
    source = by_name(
        call("GET", "/data-sources", query={"project_id": project["id"]})[
            "dataSources"
        ],
        source_name,
    )
    workflow = by_name(
        call("GET", "/workflows", query={"project_id": project["id"]}),
        workflow_name,
    )
    upload(project["id"], source["id"], path, name)
    for scenario in run(project["id"], workflow["id"]):
        saved = download(project["id"], scenario["id"])
        print(f"downloaded {scenario['name']} as {saved}")

Versions

The description carries its version in info.version. Within one major version requests keep working: new requests and new optional inputs may be added, and answers may gain fields, which your system should ignore. A change that could break a working integration comes with a new major version.