Open in ClaudeOpen in ChatGPT

Build a service for API_CALL

Goal

You will have an HTTP endpoint that the API_CALL formula function can call during a model calculation, and that returns a result Valsight turns into a node.

The examples use Python with FastAPI and pandas. Nothing about the contract is specific to either: it is one JSON POST in and one JSON body out, so any language and framework will do.

Prerequisites

  • A Valsight account with administrator permissions
  • Network access to your Valsight instance

Steps

  1. 1. Understand the contract

    A model node calls your endpoint like this:

    API_CALL("https://your-service/optimize", ['Volume', 'Capacity'], ["Country", "Site"])

    Valsight POSTs the values of Volume and Capacity, and expects a cube back on the levels Country and Site. Your service receives:

    KeyValue
    inputsThe input nodes, keyed by position in the formula as a string: "0", "1". Each value is a cube.
    result_levelsThe requested level names. A JSON array since 7.3; one string, for example "[Country, Site]", up to 7.2. See Read the requested levels.
    messageThe formula’s message argument, passed through untouched.
    versionThe formula’s version argument, passed through untouched.
    formatAlways "JSON".
    maximum_mbs_to_returnThe instance’s response size limit, as a string.

    A cube carries its columns as metadata and its values as rows:

    {
      "version": "1",
      "columnMetadata": [
        {"name": "Country", "columnType": "level",   "levelId": 118},
        {"name": "Site",    "columnType": "level",   "levelId": 101},
        {"name": "measure", "columnType": "measure"}
      ],
      "tableMetadata": {"tableType": "FACT", "tableName": "Input 0"},
      "rowData": [["DE", "S-100", "1200"], ["DE", "S-101", "900"]]
    }

    You must answer with HTTP 200–299 and this envelope:

    {
      "response_status": "SUCCESS",
      "error_message": "",
      "output": { ... a cube in the same shape ... }
    }

    Valsight reads only those three keys. See API_CALL for the full reference.

    Read the requested levels

    result_levels changed shape in 7.3: it is a JSON array from 7.3 onwards, and a single rendered string up to 7.2. Accept both and one service works against every release:

    def requested_levels(body):
        levels = body.get("result_levels")
    
        # 7.3 and later: a real JSON array
        if isinstance(levels, list):
            return [str(name) for name in levels]
    
        # up to 7.2: one rendered string, for example "[Country, Site]"
        if isinstance(levels, str) and levels.startswith("[") and levels.endswith("]"):
            inner = levels[1:-1].strip()
            return [part.strip() for part in inner.split(",")] if inner else []
    
        return []

    On 7.2 and earlier this cannot be made reliable: a level name may itself contain a comma, and the string form gives you no way to tell that comma apart from the separator. The array form in 7.3 removes the ambiguity, which is why the key changed shape.

  2. 2. Convert a cube to a DataFrame and back

    These two helpers are the whole translation layer. Everything else in your service is ordinary application code.

    import pandas as pd
    
    def cube_to_frame(cube, measure_name):
        """Turn one Valsight cube into a DataFrame with a numeric measure column."""
        columns = [column["name"] for column in cube["columnMetadata"]]
        frame = pd.DataFrame(cube["rowData"], columns=columns)
        frame = frame.rename(columns={"measure": measure_name})
        frame[measure_name] = pd.to_numeric(frame[measure_name], errors="coerce")
        return frame
    
    
    def frame_to_cube(frame, level_names, measure_column, source_cube, table_name):
        """Turn a DataFrame back into the cube Valsight expects in `output`."""
        by_name = {column["name"]: column for column in source_cube["columnMetadata"]}
        metadata = [
            by_name.get(name, {"name": name, "columnType": "level"})
            for name in level_names
        ]
        metadata.append({"name": "measure", "columnType": "measure"})
    
        ordered = frame[level_names + [measure_column]]
        return {
            "version": 1,
            "columnMetadata": metadata,
            "tableMetadata": {"tableType": "FACT", "tableName": table_name},
            "rowData": ordered.values.tolist(),
        }

    Two details in there earn their place:

    • pd.to_numeric is not optional. Every value in rowData arrives as a string, numbers included.
    • frame_to_cube copies columnMetadata from an input cube rather than writing its own. Those entries carry the levelId values Valsight uses to map values back onto its dimensions; an invented id fails the conversion.

    On the way out you may return real JSON numbers. There is no need to turn your results back into strings.

  3. 3. Start with an echo endpoint

    Wire up the simplest possible endpoint first. It proves the allow-list, the network path and the envelope are right, with no modelling questions attached.

    from fastapi import FastAPI, Request
    
    app = FastAPI()
    
    @app.post("/echo")
    async def echo(request: Request):
        body = await request.json()
        cube = body.get("inputs", {}).get("0")
        if cube is None:
            return failure("No input cube received at position 0.")
        return {"response_status": "SUCCESS", "error_message": "", "output": cube}

    Call it from a model with API_CALL("https://your-service/echo", ['Some Node'], ["Year"]). The node should show the input node’s own values.

  4. 4. Report errors so the user can see them

    Return HTTP 200 with response_status set to ERROR, not a 4xx status:

    def failure(message):
        return {
            "response_status": "ERROR",
            "error_message": message,
            "output": None,
        }

    On a status outside 200–299 Valsight discards the body and shows a generic transport error, so your text never reaches the user. With ERROR it is displayed on the node, which is where the person who wrote the formula will look.

  5. 5. Handle the four cases that actually occur

    These are the ones that turn up in real models rather than in tests.

    CaseWhat to do
    The input cube is emptyReturn an empty cube of the right shape, not an error. A modeller who filtered everything out in a scenario legitimately sends nothing, and the node should calculate to zero.
    The solver drops rowsReturn the dropped members at 0 rather than leaving them out. A missing row reads as no value, so downstream nodes fall back to a previous figure instead of registering the change.
    A requested level is not in the cubeFail with a clear error_message naming the level. Returning a cube of the wrong shape is far harder to diagnose from inside a model.
    The cube carries levels you did not ask forIgnore them. Input nodes are sent with full dimensionality, so a node on Site also arrives with its Country column. Read levels by name.
  6. 6. Get the endpoint allow-listed

    API_CALL refuses every URL until an administrator sets featureFlags.apiCall.allowedUrlsRegex on the instance — a Java regular expression that your endpoint must match. Empty is the default and allows nothing.

    Two other instance limits are worth knowing before you design the payload:

    SettingDefaultMeaning
    featureFlags.apiCall.maxRowsToSend150000Maximum rows across all input nodes combined, per request.
    featureFlags.apiCall.maxMbsToReturn24Maximum response size read, in megabytes.

    Requests can additionally be restricted to the instance’s outbound IP address. Ask your Valsight support team to set both up.

  7. 7. Keep it fast

    The default timeout is 30 seconds, set per formula. Aim far below it: the node recalculates on every user interaction, so anything past roughly two seconds makes the model feel unresponsive long before the timeout matters.

    If the underlying computation is genuinely slow, that is a sign the work belongs in an uploaded data table via DATA rather than in a live call.

Verify

Test the endpoint before pointing a model at it. The request body is plain JSON, so no Valsight instance is needed:

curl -s localhost:8000/optimize \
  -H 'Content-Type: application/json' -d '{
  "format": "JSON",
  "version": "1",
  "message": "",
  "maximum_mbs_to_return": "24",
  "result_levels": "[Country, Site]",
  "inputs": {
    "0": {
      "version": "1",
      "columnMetadata": [
        {"name": "Country", "columnType": "level", "levelId": 118},
        {"name": "Site",    "columnType": "level", "levelId": 101},
        {"name": "measure", "columnType": "measure"}
      ],
      "tableMetadata": {"tableType": "FACT", "tableName": "Input 0"},
      "rowData": [["DE","S-100","1200"],["DE","S-101","900"]]
    }
  }
}'

You are done when all four of these hold:

  • The response is HTTP 200 and response_status is SUCCESS.
  • output.columnMetadata names exactly the levels you asked for, plus measure.
  • A node using the formula calculates and shows those values.
  • Deliberately breaking the service — stopping it, or returning response_status: "ERROR" — puts a readable message on the node rather than a generic failure.

The complete service

Everything above, assembled into one file that runs as it stands. Save it as valsight_api_call_example.py, install fastapi, uvicorn and pandas, and start it with uvicorn valsight_api_call_example:app --port 8000.

Only solve() is about the problem. Replace it with a call to your own service and leave the rest — that is the Valsight-facing boilerplate this page has been building up.

"""
Valsight API_CALL — reference service (FastAPI)
==============================================

A Valsight model node can call an external HTTP service in the middle of a
calculation and use the response as a node:

    API_CALL("https://your-service/optimize", ['Volume', 'Capacity'], ["Country", "Site"])
            \\______________  ______________/  \\_________  ________/  \\________  _______/
                           \\/                           \\/                    \\/
                    endpoint (allow-listed)      input nodes -> "inputs"   levels you must
                                                                            return

Valsight POSTs one JSON body, expects one JSON body back, and turns the
`output` cube into the node's value. That is the whole contract.

This file is a runnable reference implementation. Replace `solve()` with a call
to your own service — everything around it is the Valsight-facing boilerplate.

    pip install "fastapi[standard]" pandas
    uvicorn valsight_api_call_example:app --host 0.0.0.0 --port 8000

Verified against the Valsight product code (OlapApiCallCubeDefinition.groovy)
on 2026-08-28, release 7.2.


THE REQUEST BODY Valsight sends
-------------------------------
    {
      "inputs": {                     # one entry per input node, keyed by its
        "0": {<cube>},                #   position in the formula, as a STRING
        "1": {<cube>}
      },
      "result_levels": ["Country", "Site"],    # array from 7.3 on; up to 7.2
                                              #   the string "[Country, Site]"
      "message": "free text",         # the formula's `message` argument
      "version": "1",                 # the formula's `version` argument
      "format": "JSON",
      "maximum_mbs_to_return": "24"   # a string, from an instance setting
    }

A cube looks like this. Note that **every value in `rowData` is a string**,
numbers included:

    {
      "version": "1",
      "columnMetadata": [
        {"name": "Country", "columnType": "level",   "levelId": 118},
        {"name": "Site",    "columnType": "level",   "levelId": 101},
        {"name": "measure", "columnType": "measure"}
      ],
      "tableMetadata": {"tableType": "FACT", "tableName": "Input 0"},
      "rowData": [["DE", "S-100", "1200"], ["DE", "S-101", "900"]]
    }

Three things that surprise people:

1. Values are strings. Always `pd.to_numeric(..., errors="coerce")`.
2. Cubes arrive with **full dimensionality**. If the input node is dimensioned
   on Site only, but Site sits under Country, you still receive a Country
   column. That is the only channel through which Valsight communicates
   dimensionality, so read the levels you need by name and ignore the rest.
3. `result_levels` changed shape in 7.3, from a rendered string to a JSON
   array. Up to 7.2 it cannot be parsed reliably, because a level name may
   itself contain a comma. `requested_levels()` below reads either form.


THE RESPONSE BODY Valsight expects
----------------------------------
    {
      "response_status": "SUCCESS",   # or "ERROR"
      "error_message": "",            # shown to the user when status is ERROR
      "output": {<cube>}              # same shape as an input cube
    }

Valsight reads only `response_status`, `error_message` and `output`; any other
key is ignored. The HTTP status must be in 200-299, otherwise the calculation
fails before the body is even looked at.

On the way in every value is a string; on the way out you may return real JSON
numbers. No need to stringify what you computed.

`output` must carry exactly the levels that were requested, plus the `measure`
column. Reuse the input cube's `columnMetadata` for those levels rather than
writing your own — see `level_metadata()`.


LIMITS AND TIMEOUTS
-------------------
* Default timeout is 30 s, set per formula via the `timeout` argument. A node
  is recalculated on every user interaction, so in practice aim well under
  ~2 s or the model stops feeling interactive.
* Max rows sent: 150 000 by default, counted as the **sum over all** input
  cubes.
* Max response read: 24 MB by default.
* The endpoint must match the URL allow-list on the Valsight instance
  (`featureFlags.apiCall.allowedUrlsRegex`) and can be locked to your source
  IP. Both are set by a Valsight administrator.
"""

from __future__ import annotations

import logging
from typing import Any, Dict, List

import pandas as pd
from fastapi import APIRouter, FastAPI, Request

logger = logging.getLogger("valsight.api_call")

PAGE = APIRouter()

# Levels this service knows how to work with. Keep the interface generic: read
# levels by name, pass through what you do not need.
SITE_LEVEL = "Site"
MEASURE = "measure"


# ---------------------------------------------------------------------------
# Valsight cube <-> pandas
# ---------------------------------------------------------------------------

def cube_to_frame(cube: Dict[str, Any], measure_name: str) -> pd.DataFrame:
    """Turn one Valsight cube into a DataFrame with a numeric measure column."""
    columns = [column["name"] for column in cube["columnMetadata"]]
    frame = pd.DataFrame(cube["rowData"], columns=columns)
    frame = frame.rename(columns={MEASURE: measure_name})
    frame[measure_name] = pd.to_numeric(frame[measure_name], errors="coerce")
    return frame


def level_metadata(cube: Dict[str, Any], level_names: List[str]) -> List[Dict[str, Any]]:
    """Reuse the input cube's column metadata for the levels we return.

    Copying it is deliberate: the metadata carries `levelId`, which Valsight
    uses to map values back onto its own dimensions. Inventing an id here would
    fail the conversion, so we look each level up and only fall back to a
    name-only entry when the input did not carry it.
    """
    by_name = {column["name"]: column for column in cube["columnMetadata"]}
    return [
        by_name.get(name, {"name": name, "columnType": "level"})
        for name in level_names
    ]


def frame_to_cube(
    frame: pd.DataFrame,
    level_names: List[str],
    measure_column: str,
    source_cube: Dict[str, Any],
    table_name: str,
) -> Dict[str, Any]:
    """Turn a DataFrame back into the cube Valsight expects in `output`."""
    metadata = level_metadata(source_cube, level_names)
    metadata.append({"name": MEASURE, "columnType": "measure"})

    ordered = frame[level_names + [measure_column]]
    return {
        "version": 1,
        "columnMetadata": metadata,
        "tableMetadata": {"tableType": "FACT", "tableName": table_name},
        "rowData": ordered.values.tolist(),
    }


def requested_levels(body: Dict[str, Any]) -> List[str]:
    """The levels Valsight wants back, from either payload form.

    `result_levels` changed shape in 7.3: a JSON array from 7.3 onwards, a
    single rendered string up to 7.2. Accepting both keeps one service working
    against every release.
    """
    levels = body.get("result_levels")

    # 7.3 and later: a real JSON array
    if isinstance(levels, list):
        return [str(name) for name in levels]

    # up to 7.2: one rendered string, for example "[Country, Site]"
    if isinstance(levels, str) and levels.startswith("[") and levels.endswith("]"):
        inner = levels[1:-1].strip()
        return [part.strip() for part in inner.split(",")] if inner else []

    return []


# ---------------------------------------------------------------------------
# Response envelope
# ---------------------------------------------------------------------------

def success(output: Dict[str, Any], function_name: str) -> Dict[str, Any]:
    return {
        "format": "JSON",
        "version": "1",
        "function_name": function_name,
        "response_status": "SUCCESS",
        "error_message": "",
        "output": output,
    }


def failure(message: str) -> Dict[str, Any]:
    """Report a business error.

    Return HTTP 200 with status ERROR, not a 4xx: on a non-2xx Valsight
    discards the body and shows a generic transport error, so `message` would
    never reach the user. With status ERROR it is displayed on the node.
    """
    logger.warning("returning ERROR to Valsight: %s", message)
    return {
        "format": "JSON",
        "version": "1",
        "response_status": "ERROR",
        "error_message": message,
        "output": None,
    }


# ---------------------------------------------------------------------------
# Endpoint 1 — connectivity check
# ---------------------------------------------------------------------------

@PAGE.post("/echo")
async def echo(request: Request) -> Dict[str, Any]:
    """Return input cube 0 unchanged.

    The first thing to wire up: it proves the allow-list, the network path and
    the envelope are right, without any modelling questions attached.

        API_CALL("https://your-service/echo", ['Volume'], ["Country", "Site"])
    """
    body = await request.json()
    cubes = body.get("inputs", {})
    logger.info("/echo  keys=%s  cubes=%s", list(body), list(cubes))

    cube = cubes.get("0")
    if cube is None:
        return failure("No input cube received at position 0.")
    return success(cube, "echo")


# ---------------------------------------------------------------------------
# Endpoint 2 — the optimizer call
# ---------------------------------------------------------------------------

def solve(volume: pd.DataFrame, capacity: pd.DataFrame, integer: bool) -> pd.DataFrame:
    """>>> REPLACE THIS with a call to your own service. <<<

    Everything above and below is Valsight plumbing; this is the only part that
    is actually about the problem.

    Contract: take the input frames, return a frame with the same level columns
    plus one numeric result column named `optimized`.

    The placeholder below allocates volume up to a per-country capacity limit,
    scaling proportionally when demand exceeds it. It exists so the file runs
    as-is — it is not a model of anything.
    """
    data = volume.merge(capacity, on="Country", how="left")

    limit = data["capacity"].fillna(float("inf"))
    demanded = data.groupby("Country")["volume"].transform("sum")
    scale = (limit / demanded).clip(upper=1.0).fillna(1.0)

    data["optimized"] = data["volume"] * scale
    if integer:
        data["optimized"] = data["optimized"].round()
    return data


@PAGE.post("/optimize")
async def optimize(request: Request) -> Dict[str, Any]:
    """Two input cubes in, one optimized cube out.

        API_CALL("https://your-service/optimize",
                 ['Volume per Site', 'Capacity per Country'],
                 ["Country", "Site"],
                 "JSON", "1", "continuous", 20)
                                 \\________/  \\/
                                  message    timeout, seconds

    The optional arguments are positionally nested in the formula grammar: to
    pass `timeout` you must also pass `format`, `version` and `message`.
    """
    body = await request.json()
    cubes = body.get("inputs", {})

    # `message` is passed through verbatim, which makes it a convenient mode
    # switch — you can change solver behaviour from the formula without
    # touching the API.
    message = body.get("message") or ""
    integer = "integer" in message.lower()

    levels = requested_levels(body)
    logger.info(
        "/optimize  cubes=%s  levels=%s  integer=%s", list(cubes), levels, integer
    )

    volume_cube = cubes.get("0")
    capacity_cube = cubes.get("1")
    if volume_cube is None or capacity_cube is None:
        return failure(
            "Expected two input nodes: volume per site, then capacity per country. "
            f"Received {len(cubes)}."
        )

    try:
        volume = cube_to_frame(volume_cube, "volume")
        capacity = cube_to_frame(capacity_cube, "capacity")
    except (KeyError, TypeError, ValueError) as exc:
        logger.exception("could not parse input cubes")
        return failure(f"Could not read the input cubes: {exc}")

    # Levels are requested by name. Fail loudly here rather than silently
    # returning a cube of the wrong shape, which is much harder to debug from
    # inside a model.
    missing = [level for level in levels if level not in volume.columns]
    if missing:
        return failure(
            f"Input cube 0 has no column(s) {missing}. "
            f"It carries {sorted(volume.columns)}."
        )

    # An empty input is normal, not an error: a modeller who closed every site
    # in a scenario sends nothing. Return an empty cube of the right shape so
    # the node calculates to zero instead of failing.
    if volume.empty:
        logger.info("no rows to optimize, returning an empty cube")
        empty = pd.DataFrame(columns=levels + ["optimized"])
        return success(
            frame_to_cube(empty, levels, "optimized", volume_cube, "Optimized Volume"),
            "optimize",
        )

    result = solve(volume, capacity, integer)

    # Sites the solver dropped must still appear, at zero. Leaving them out
    # makes them missing rather than closed, and downstream nodes then read the
    # previous value instead of nothing.
    if SITE_LEVEL in levels:
        result = (
            volume[levels]
            .merge(result[levels + ["optimized"]], on=levels, how="left")
            .fillna({"optimized": 0.0})
        )

    output = frame_to_cube(
        result, levels, "optimized", volume_cube, "Optimized Volume"
    )
    logger.info("/optimize returning %s rows", len(output["rowData"]))
    return success(output, "optimize")


# ---------------------------------------------------------------------------

app = FastAPI(
    title="Valsight API_CALL reference service",
    description=__doc__,
    version="1.0.0",
)
app.include_router(PAGE, prefix="/functions")


@app.get("/health")
async def health() -> Dict[str, str]:
    return {"status": "ok"}


if __name__ == "__main__":
    import uvicorn

    logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(name)s: %(message)s")
    uvicorn.run(app, host="0.0.0.0", port=8000)


# ---------------------------------------------------------------------------
# Try it without Valsight
# ---------------------------------------------------------------------------
#
# curl -s localhost:8000/functions/optimize \
#   -H 'Content-Type: application/json' -d '{
#   "format": "JSON",
#   "version": "1",
#   "message": "continuous",
#   "maximum_mbs_to_return": "24",
#   "result_levels": ["Country", "Site"],
#   "inputs": {
#     "0": {
#       "version": "1",
#       "columnMetadata": [
#         {"name": "Country", "columnType": "level", "levelId": 118},
#         {"name": "Site",    "columnType": "level", "levelId": 101},
#         {"name": "measure", "columnType": "measure"}
#       ],
#       "tableMetadata": {"tableType": "FACT", "tableName": "Input 0"},
#       "rowData": [["DE","S-100","1200"],["DE","S-101","900"],["JP","S-200","400"]]
#     },
#     "1": {
#       "version": "1",
#       "columnMetadata": [
#         {"name": "Country", "columnType": "level", "levelId": 118},
#         {"name": "measure", "columnType": "measure"}
#       ],
#       "tableMetadata": {"tableType": "FACT", "tableName": "Input 1"},
#       "rowData": [["DE","1500"],["JP","800"]]
#     }
#   }
# }' | python3 -m json.tool
#
# DE is capped: 1200 + 900 = 2100 demanded against 1500 available, so both
# sites scale by 1500/2100. JP stays at 400, well under its 800.
PageWhat it covers
API_CALLThe formula function itself: syntax, parameters, request and response reference, error messages.
DATAReferencing data that is already uploaded, instead of calling a live service.
Introduction to Valsight APIReading data out of Valsight, rather than being called by it.
Was this page helpful?