xplo API

The xplo REST API lets you upload Excel files, analyze them asynchronously, run the resulting workbooks, and download the original workbook or a self-contained workbook bundle.

Base URL

Hosted:

https://xplo.pythia.software/api/v2

Local development (with the API server on its default port):

http://localhost:8080/api/v2

For self-hosted instances, append /api/v2 to the server origin. Do not append /api/v2 twice.

The examples below use an environment variable for the selected base URL:

export XPLO_API_URL='https://xplo.pythia.software/api/v2'
# For local development instead:
# export XPLO_API_URL='http://localhost:8080/api/v2'
export XPLO_API_URL="${XPLO_API_URL%/}"

All requests and responses are JSON (snake_case), except file transfers and binary artifacts. Browser CORS access is restricted to explicitly configured origins; scripts and other non-browser clients are unaffected.

Authentication

Every documented /api/v2 endpoint requires an Authorization: Bearer <token> header except the PKCE-bound CLI token-exchange endpoint described below. The unauthenticated health check is served separately at the host-root path /health. Two bearer-token types are supported:

API tokens (recommended for scripts and servers)

API tokens start with xplo_ and always expire. User-created tokens default to 90 days and requested expiries are capped at 90 days. You can generate one from a completed workbook's HTTP API + token card in the web app, or create one through the API while authenticated with a Firebase ID token:

curl --fail-with-body -sS -X POST "$XPLO_API_URL/tokens" \
  -H "Authorization: Bearer $FIREBASE_ID_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-api-token"}'

The response contains the plaintext token once — store it securely, it can't be retrieved later.

{
  "token": "xplo_a1b2c3...",
  "api_token": {
    "id": "atk-a1b2c3...",
    "organization_id": "org-d4e5f6...",
    "name": "my-api-token",
    "created_at": "2026-04-17T10:00:00Z",
    "expires_at": "2026-07-16T10:00:00Z"
  }
}

For a user who belongs to multiple organizations, add "organization_id":"org-..." to the create body to choose the token's scope. If omitted, the API uses the user's authorized primary organization; when the caller is already using an API token, it stays within that token's organization.

Use the token on subsequent requests:

export XPLO_TOKEN='xplo_a1b2c3...'

curl --fail-with-body -sS "$XPLO_API_URL/files" \
  -H "Authorization: Bearer $XPLO_TOKEN"

Local development tokens (no Firebase)

The two flows above ultimately require a Firebase project. To exercise the API against a server you run yourself — with no Firebase and no web app — start the API with XPLO_DEV_SEED_TOKEN set to a token of your choosing (it must start with xplo_). When no Firebase credentials are configured the server boots in API-token-only mode and seeds a development user, organization, and that exact token, so it works immediately:

# From the repository root; no Firebase credentials required.
XPLO_DEV_SEED_TOKEN='xplo_localdev' \
FILE_STORAGE_TYPE=local \
XPLO_VMRUN_PATH="$PWD/vmruntime/target/release/vmrun" \
  go run ./server/cmd/api

# In another shell:
export XPLO_API_URL='http://localhost:8080/api/v2'
export XPLO_TOKEN='xplo_localdev'
curl --fail-with-body -sS "$XPLO_API_URL/auth/me" \
  -H "Authorization: Bearer $XPLO_TOKEN"

This is a local-development convenience only. The server refuses to seed the token when ENVIRONMENT=production, and without Firebase credentials it rejects Firebase ID tokens outright, so it can never accept unverified credentials in a production deployment.

Firebase ID tokens (used by the web app)

Users signed in through the web app authenticate with Firebase ID tokens obtained via the Firebase JS SDK. The backend checks the token signature, expiry, server-side revocation/disabled-user state, and verified-email claim on every request. On first login it auto-creates a user and organization.

To register a first-time Firebase user, send the same ID token in the bearer header and JSON body:

curl --fail-with-body -sS -X POST "$XPLO_API_URL/auth/login" \
  -H "Authorization: Bearer $FIREBASE_ID_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"firebase_token\":\"$FIREBASE_ID_TOKEN\"}"

Errors

Most v2 resource endpoints return a JSON error with an appropriate HTTP status:

{ "error": "file not found", "code": "NOT_FOUND" }
Code HTTP Meaning
VALIDATION_ERROR 400, 413 Malformed request, invalid fields, or an oversized upload
UNAUTHORIZED 401 Missing or invalid credentials
FORBIDDEN 403 Authenticated, but not allowed
NOT_FOUND 404 Resource doesn't exist
CONFLICT 409 Resource state blocks the operation
NOT_SUPPORTED 501 Configured backend lacks this capability
INTERNAL 500, 503 Unexpected error or unavailable server runtime

Documented API endpoints use the JSON envelope above for errors. Always branch on the HTTP status before decoding a success response.

Pagination

The file, file-group, and action list endpoints accept limit (default 20, max 100) and offset (default 0) query parameters and return:

{ "items": [], "total": 0 }

Resources

Organizations own all data. Every File, FileGroup, and Action belongs to exactly one organization, and access is scoped to organizations the caller is a member of. Most users have a single organization created automatically on first login.

For Firebase users with multiple memberships, create endpoints that do not take an organization selector use the user's primary organization. Requests authenticated with an API token are restricted to that token's organization_id.

  • File — one uploaded spreadsheet, with a version number. Uploading the same name again within an organization bumps the version.
  • FileGroup — a named bag of files you want to analyze together. An analysis runs against a file group.
  • Action — one run of the analysis pipeline on a file group. Actions are asynchronous and composed of many backing Tasks.
  • ApiToken — a long-lived bearer token scoped to your user and one organization.

Quickstart: upload, wait, run, and download

This end-to-end example requires curl 7.76 or newer (for --fail-with-body), jq, an API token, and an .xlsx file. Set the base URL to http://localhost:8080/api/v2 to run it against a local server.

For a locally built server, the hosted run, manifest, and bundle endpoints also require the shared vmrun interpreter. From the repository root, build it once before starting the API:

cargo build --release --manifest-path vmruntime/Cargo.toml
export XPLO_VMRUN_PATH="$PWD/vmruntime/target/release/vmrun"

Without it, those three endpoints return HTTP 503.

set -euo pipefail
# Keep a local/self-hosted value selected above; otherwise use hosted xplo.
export XPLO_API_URL="${XPLO_API_URL:-https://xplo.pythia.software/api/v2}"
export XPLO_API_URL="${XPLO_API_URL%/}"
export XPLO_TOKEN='xplo_...'
export WORKBOOK='budget.xlsx'

1. Upload and start analysis

The convenience endpoint creates the file, a one-file group, and an action in one request:

CREATE_RESPONSE=$(curl --fail-with-body -sS -X POST "$XPLO_API_URL/cli/compile" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -F "file=@$WORKBOOK")

ACTION_ID=$(jq -r '.action_id' <<<"$CREATE_RESPONSE")
FILE_ID=$(jq -r '.file_id' <<<"$CREATE_RESPONSE")
FILE_GROUP_ID=$(jq -r '.file_group_id' <<<"$CREATE_RESPONSE")

printf 'action=%s\nfile=%s\nfile_group=%s\n' \
  "$ACTION_ID" "$FILE_ID" "$FILE_GROUP_ID"

A successful request returns HTTP 201:

{
  "action_id": "act-a1b2c3...",
  "file_id": "fil-d4e5f6...",
  "file_group_id": "fgr-0123ab..."
}

Despite its historical /cli/compile name, this endpoint performs analysis and emits the default VM workbook runtime; it does not run the optional slow native/WASM compilation. Add -F "enable_trace=true" to the request to enable Honeycomb tracing; a traced action also returns trace_id.

2. Poll until the action is terminal

while true; do
  STATUS_RESPONSE=$(curl --fail-with-body -sS \
    "$XPLO_API_URL/cli/status/$ACTION_ID" \
    -H "Authorization: Bearer $XPLO_TOKEN")

  STATUS=$(jq -r '.status' <<<"$STATUS_RESPONSE")
  jq '{status, percent_complete, completed_tasks, total_tasks, failed_tasks}' \
    <<<"$STATUS_RESPONSE"

  case "$STATUS" in
    COMPLETED) break ;;
    FAILED|CANCELLED)
      jq -r '.error // "action did not complete"' <<<"$STATUS_RESPONSE" >&2
      exit 1
      ;;
  esac
  sleep 2
done

An in-progress response looks like:

{
  "action_id": "act-a1b2c3...",
  "status": "RUNNING",
  "percent_complete": 87.5,
  "total_tasks": 8,
  "completed_tasks": 7,
  "failed_tasks": 0
}

percent_complete is on a 0100 scale. For example, 21 of 50 completed tasks is returned as 42, not 0.42.

3. Inspect the workbook contract and run it

The manifest contains the real input and output addresses for this workbook:

curl --fail-with-body -sS \
  "$XPLO_API_URL/actions/$ACTION_ID/vm/manifest" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -o manifest.json

jq . manifest.json

Run the workbook at its baseline inputs and return all terminal outputs:

curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/actions/$ACTION_ID/run" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"inputs":{}}' | jq .

When the manifest has a graph-derived example, this builds and runs a request with real input overrides and connected outputs:

RUN_BODY=$(jq -c '
  if .example then
    {
      inputs: (.example.inputs | map({key: .address, value: .value}) | from_entries),
      outputs: .example.outputs
    }
  else
    {inputs: {}}
  end
' manifest.json)

curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/actions/$ACTION_ID/run" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$RUN_BODY" | jq .

4. Download the workbook bundle and original workbook

The workbook bundle is a zip containing plan.xvm, its matching vmrun interpreter, and a README. The bundled interpreter targets the API server's operating system and CPU architecture; use the hosted /run endpoint when that does not match your environment.

curl --fail-with-body -sS \
  "$XPLO_API_URL/actions/$ACTION_ID/vm/bundle" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -o workbook-bundle.zip

Request a download URL for the original uploaded workbook, then consume either the hosted signed URL or local authenticated URL:

DOWNLOAD_URL=$(curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/files/$FILE_ID/download" \
  -H "Authorization: Bearer $XPLO_TOKEN" | jq -r '.download_url')

case "$DOWNLOAD_URL" in
  /*)
    # Local-storage servers return an authenticated API-relative URL.
    curl --fail-with-body -sS -L \
      "${XPLO_API_URL%/api/v2}$DOWNLOAD_URL" \
      -H "Authorization: Bearer $XPLO_TOKEN" \
      -o uploaded-budget.xlsx
    ;;
  *)
    # Signed object-storage URLs need no xplo authorization header.
    curl --fail-with-body -sS -L "$DOWNLOAD_URL" \
      -o uploaded-budget.xlsx
    ;;
esac

The uploaded file content for /cli/compile is capped at 100 MiB; multipart framing does not count against that limit. Use the presigned upload flow for larger workbooks.

Presigned upload flow

For larger files or more control, upload goes directly to Google Cloud Storage via a presigned URL — the file never transits the xplo API server. This flow requires a server configured with GCS-backed file storage; the local-filesystem storage backend returns 501 NOT_SUPPORTED because it does not issue signed URLs.

1. Request an upload URL.

WORKBOOK_SIZE=$(wc -c <"$WORKBOOK" | tr -d ' ')
UPLOAD_BODY=$(jq -nc \
  --arg name "$(basename "$WORKBOOK")" \
  --argjson size "$WORKBOOK_SIZE" \
  '{name: $name, size_bytes: $size}')

UPLOAD_RESPONSE=$(curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/files/request-upload" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$UPLOAD_BODY")

FILE_ID=$(jq -r '.file.id' <<<"$UPLOAD_RESPONSE")
UPLOAD_URL=$(jq -r '.upload_url' <<<"$UPLOAD_RESPONSE")
CONTENT_TYPE=$(jq -r '.upload_headers["Content-Type"]' <<<"$UPLOAD_RESPONSE")
{
  "file": {
    "id": "fil-a1b2c3...",
    "organization_id": "org-d4e5f6...",
    "name": "budget.xlsx",
    "extension": "xlsx",
    "version": 1,
    "size_bytes": 82451,
    "created_at": "2026-04-17T09:59:55Z",
    "updated_at": "2026-04-17T09:59:55Z"
  },
  "upload_url": "https://storage.googleapis.com/...",
  "upload_headers": { "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }
}

The upload_url is valid for 15 minutes.

2. PUT the file bytes directly to upload_url, applying every header from upload_headers.

curl --fail-with-body -sS -X PUT "$UPLOAD_URL" \
  -H "Content-Type: $CONTENT_TYPE" \
  --data-binary "@$WORKBOOK"

3. Confirm the upload to mark the file as ready and record the upload timestamp.

curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/files/$FILE_ID/confirm-upload" \
  -H "Authorization: Bearer $XPLO_TOKEN" | jq .

The confirm endpoint verifies that the object exists and returns the updated File object.

4. Create a file group containing the file.

FILE_GROUP_RESPONSE=$(curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/file-groups" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"name\":\"API upload\",\"file_ids\":[\"$FILE_ID\"]}")

FILE_GROUP_ID=$(jq -r '.id' <<<"$FILE_GROUP_RESPONSE")

5. Start an action, then poll it as shown in the quickstart.

ACTION_RESPONSE=$(curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/actions" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"file_group_id\":\"$FILE_GROUP_ID\"}")

ACTION_ID=$(jq -r '.id' <<<"$ACTION_RESPONSE")

Downloading a file

POST /files/{id}/download returns file metadata plus a download_url. GCS-backed servers return a 15-minute absolute signed URL, which needs no xplo bearer token. Local-storage servers return an API-relative /api/v2/files/{id}/content URL, which does require the bearer token. The quickstart handles both forms.

Endpoint reference

Paths in the following tables are relative to /api/v2. All require Authorization: Bearer <token>.

Auth

Method Path Description
POST /auth/login Register or load a Firebase user. Send the ID token as both the bearer token and { "firebase_token": "..." }.
GET /auth/me Return the authenticated user.

Users

Method Path Description
PATCH /users/me Update the current user. Body: { "name": "..." }

Organizations

Method Path Description
GET /organizations/{id} Get an organization.
PATCH /organizations/{id} Rename. Body: { "name": "..." }
POST /organizations/{id}/reset Destructive. Delete the organization's files, file groups, and actions.

Files

Method Path Description
GET /files List files. Query: limit, offset, name_contains.
GET /files/{id} Get one file's metadata.
DELETE /files/{id} Delete a file. Returns 409 CONFLICT while it belongs to any file group.
POST /files/request-upload Create a file record and 15-minute upload URL. Body: { "name": "budget.xlsx", "size_bytes": 82451 }; extension is optional.
POST /files/{id}/confirm-upload Verify the uploaded object exists, set uploaded_at, and return the updated file. No request body.
POST /files/{id}/download Return { file, download_url } for the original bytes. Unconfirmed uploads return 409 CONFLICT.
GET /files/{id}/content Stream original bytes with bearer auth; used by the local-storage download_url fallback.

File groups

Method Path Description
GET /file-groups List file groups. Query: limit, offset.
POST /file-groups Create. Body: { "name": "Q4 Budget", "file_ids": ["fil-..."] }; file_ids is optional. Returns HTTP 201.
GET /file-groups/{id} Get one group with its nested files array.
PATCH /file-groups/{id} Rename. Body: { "name": "..." }.
DELETE /file-groups/{id} Delete. Returns { "success": true }.
POST /file-groups/{id}/files Add files. Body: { "file_ids": ["fil-..."] }. Returns the group.
DELETE /file-groups/{id}/files Remove files. Body: { "file_ids": ["fil-..."] }. Returns the group.

Actions

Method Path Description
GET /actions List the caller's actions. Query: limit, offset, status, file_group_id.
POST /actions Start analysis. Body: { "file_group_id": "fgr-...", "enable_trace": true }; enable_trace is optional. Returns HTTP 201.
GET /actions/{id} Get the enriched action, including file group, user, tasks, progress, ETA, and build readiness.
POST /actions/{id}/cancel Stop a PENDING or RUNNING action and return the updated action. No request body.
POST /actions/{id}/retry Retry a FAILED action as a fresh attempt over the same file group. No request body.
POST /actions/{id}/compile After analysis completes, opt into native + WASM compilation. Idempotent; no request body.
GET /actions/{id}/cell-interface Get the action-owned, workbook-qualified semantic cell interface and its revision ETag.
PATCH /actions/{id}/cell-interface Create, edit, archive, or rename semantic labels. Requires the action owner and an If-Match revision ETag.
GET /actions/{id}/cell-interface/transfer-candidates List compatible prior actions owned by the same user.
POST /actions/{id}/cell-interface/transfer-preview Preview content-verified label transfer from one prior action.
POST /actions/{id}/cell-interface/transfer Copy selected preview-ready labels. Requires the action owner and an If-Match revision ETag.
GET /actions/{id}/vm/manifest Return the typed VM input/output contract as JSON.
POST /actions/{id}/run Run the workbook with raw addresses, semantic names, or a mixture of both.
GET /actions/{id}/vm/bundle Download a zip containing plan.xvm, bin/vmrun, and README.md.
GET /actions/{id}/simulation-config Get simulation UI state; returns empty objects if none has been saved.
PUT /actions/{id}/simulation-config Replace the four state fields shown below; omitted fields become {}.
PATCH /actions/{id}/simulation-config Update any subset of the four state fields shown below.

Action status begins at PENDING, normally moves to RUNNING, and ends at COMPLETED, FAILED, or CANCELLED. Poll status, not percent_complete, to decide whether an action is terminal. The progress.percent_complete value is a percentage from 0 to 100. Backing task rows are ephemeral, so terminal responses use a persisted final progress snapshot; older actions created before snapshots were recorded can have zero task counts. eta is present only for running actions when an estimate is available.

Retry returns the new action object with a new id; the failed source action remains unchanged as an audit record. Poll the returned action's id, not the ID in the retry request path.

A normal completed action is ready for /vm/manifest, /run, and /vm/bundle; wasm.wasm_ready can still be false because that flag describes the optional per-workbook WASM compile, not the default VM runtime. Calling /compile on a completed action moves that same action back to RUNNING; poll it to COMPLETED again before fetching compiled artifacts.

Semantic cell interface

Semantic labels are optional syntactic sugar over cell addresses. They do not decide which cells can be used as inputs or outputs, and every run surface retains a raw-address escape hatch.

The interface belongs to one action. Every label is also qualified by the immutable workbook evaluated by that action, so a future multi-workbook action cannot make a cell identity ambiguous. You can read an interface as soon as its durable catalog is ready, while the rest of the action continues processing:

curl --fail-with-body -sS -D interface-headers.txt \
  "$XPLO_API_URL/actions/$ACTION_ID/cell-interface" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -o interface.json

INTERFACE_ETAG=$(awk 'BEGIN { IGNORECASE=1 } /^etag:/ { gsub("\r", ""); print $2 }' \
  interface-headers.txt)
jq '{revision, catalog_status, workbooks, labels}' interface.json

catalog_status is preparing, ready, or unavailable. The response also includes setup_status, transfer-prompt state, server limits, immutable source file IDs and versions, sheet geometry, and the current labels. GET responses use Cache-Control: no-store; the quoted numeric ETag is an optimistic-concurrency token for mutations, not a cache validator.

Creating a label requires only cell and long_label. direction defaults to both for wire compatibility; the browser editor requires input or output. short_label, key, and units are optional. When key is omitted, xplo suggests a deterministic API-safe key. Renaming a key keeps the prior key as a deprecated alias when the alias limit and collision rules permit it.

curl --fail-with-body -sS -X PATCH \
  "$XPLO_API_URL/actions/$ACTION_ID/cell-interface" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: $INTERFACE_ETAG" \
  -d '{
    "upsert": [
      {
        "cell": {
          "workbook_id": "awb-...",
          "sheet": "Loan",
          "address": "B3"
        },
        "long_label": "Original loan amount",
        "short_label": "Loan amount",
        "direction": "input",
        "units": "USD"
      },
      {
        "cell": {
          "workbook_id": "awb-...",
          "sheet": "Loan",
          "address": "B12"
        },
        "long_label": "Monthly payment",
        "key": "monthly_payment",
        "direction": "output",
        "units": "USD/month"
      }
    ],
    "archive": [],
    "retire_names": [],
    "setup_status": "completed"
  }' | jq .

To update an existing label, send its id and only the fields being changed. A stale If-Match returns 412 Precondition Failed with the current revision/ETag so the caller can reload instead of silently overwriting another edit. Only the action owner may mutate labels; collaborators with action access may read them.

Label transfer is deliberately conservative. Candidate actions must have the same owner, and workbooks pair only when sheet names and dimensions match exactly. Preview compares the labeled source cell, its stored fingerprint, and the target cell; changed or unverifiable content is never offered as ready to copy.

# Discover compatible prior actions.
curl --fail-with-body -sS \
  "$XPLO_API_URL/actions/$ACTION_ID/cell-interface/transfer-candidates" \
  -H "Authorization: Bearer $XPLO_TOKEN" | jq .

# Preview one source without changing the target.
curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/actions/$ACTION_ID/cell-interface/transfer-preview" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"source_action_id":"act-prior..."}' | jq .

# Copy only label IDs whose preview status was "ready".
curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/actions/$ACTION_ID/cell-interface/transfer" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: $INTERFACE_ETAG" \
  -d '{
    "source_action_id": "act-prior...",
    "label_ids": ["acl-..."]
  }' | jq .

Running with semantic names

The original address-only request remains unchanged:

{
  "inputs": { "Loan!B3": 200000 },
  "outputs": ["Loan!B12"]
}

The semantic envelope can mix confirmed keys with explicit workbook-qualified cells:

{
  "expected_interface_revision": 3,
  "inputs": {
    "named": {
      "loan_amount": 200000
    },
    "cells": [
      {
        "cell": {
          "workbook_id": "awb-...",
          "sheet": "Loan",
          "address": "B4"
        },
        "value": 0.06
      }
    ]
  },
  "outputs": {
    "named": ["monthly_payment"],
    "all_named_outputs": false,
    "all_named_inputs": false,
    "cells": [
      {
        "workbook_id": "awb-...",
        "sheet": "Loan",
        "address": "B13"
      }
    ]
  }
}

Explicit named inputs and outputs are not restricted by a label's direction; direction is descriptive metadata used only by all_named_inputs and all_named_outputs. Omit outputs to preserve the original behavior of returning all terminal outputs as raw cells. Semantic responses separate named and raw projections:

{
  "interface_revision": 3,
  "outputs": {
    "named": {
      "monthly_payment": {
        "canonical_key": "monthly_payment",
        "address": {
          "workbook_id": "awb-...",
          "sheet": "Loan",
          "address": "B12"
        },
        "long_label": "Monthly payment",
        "units": "USD/month",
        "value": 1199.1,
        "error": null
      }
    },
    "cells": []
  },
  "warnings": []
}

Keys are matched case-insensitively. A renamed deprecated alias still resolves and returns a warning naming the canonical key. Provisional imported labels and explicitly retired names do not resolve. Supplying expected_interface_revision is optional, but it lets a generated client fail with 409 CONFLICT if its interface snapshot is stale.

API tokens

Method Path Description
GET /tokens List your tokens in the bearer-authorized organization(s) as { items, total }; plaintext values are never returned.
POST /tokens Create. Body: { "name": "my-token", "organization_id": "org-...", "expires_at": "2026-07-16T10:00:00Z" }; the last two fields are optional. Expiry defaults to and is capped at 90 days. Returns plaintext once.
DELETE /tokens/{id} Revoke and return the token metadata with revoked_at set.

CLI convenience endpoints

Method Path Description
POST /cli/auth-codes Authenticated browser step for xplo login. Accepts a PKCE code_challenge and returns a five-minute, single-use authorization code—not a bearer token.
POST /cli/token-exchange Public but PKCE-bound CLI step. Exchanges the one-time code plus the local code_verifier for a 30-day API token. Replays and expired codes return 401.
POST /cli/compile Multipart file= upload of a non-empty .xlsx or .xlsm that creates a file, group, and default VM action. File content is capped at 100 MiB; optional enable_trace=true enables tracing.
GET /cli/status/{action_id} Simplified progress response; percent_complete is 0100 and error appears for failed or cancelled actions.
GET /cli/binary/{action_id} Fetch the native binary. First complete POST /actions/{id}/compile; use curl -L for signed-storage redirects.
GET /cli/info/{action_id} Fetch cell-manifest.json. This is not the typed VM manifest; use curl -L for signed-storage redirects.

On GCS-backed servers, /cli/binary and /cli/info return HTTP 307 to a 5-minute signed URL. On local-storage servers, they stream the attachment directly with HTTP 200.

Compiled artifact URLs

The compiled-artifact resolver still uses an absolute v1 path because there is no v2 alias:

GET /api/v1/wasm/{action_id}/{filename}

It requires the same bearer authentication and returns JSON rather than redirecting:

{ "url": "https://storage.googleapis.com/...", "expires_in_seconds": 300 }

Allowed filenames are spreadsheet_wasm_bg.wasm, spreadsheet_wasm.js, cell-manifest.json, native-binary, failure-bundle.tar.gz, plan.xvm, and vm-coverage.json. Availability depends on action state and whether optional compilation was requested. This legacy resolver requires a signed-URL-capable storage backend and returns 501 NOT_SUPPORTED with local-filesystem storage. Use the v2 run, bundle, and CLI download endpoints for local deployments. The workbook-independent GET /api/v1/vm/runtime.wasm endpoint is public and returns the shared VM runtime module directly, or HTTP 503 if that module is not installed on the server.

To request the optional compile after the default action has completed:

curl --fail-with-body -sS -X POST \
  "$XPLO_API_URL/actions/$ACTION_ID/compile" \
  -H "Authorization: Bearer $XPLO_TOKEN" | jq \
  '{id, status, wasm}'

Poll the same action to COMPLETED again. wasm.compile_requested will be true, and wasm.wasm_ready will be true when compilation succeeds. Then fetch the native binary through the CLI endpoint (-L works for both a hosted redirect and a local direct stream):

curl --fail-with-body -sS -L \
  "$XPLO_API_URL/cli/binary/$ACTION_ID" \
  -H "Authorization: Bearer $XPLO_TOKEN" \
  -o workbook-native
chmod +x workbook-native

Like the interpreter in a VM bundle, the native binary targets the API server's platform.

Health

The health path is absolute from the server origin, not relative to /api/v2.

Method Path Description
GET /health Return HTTP 200 with text body OK. No auth.

Object shapes

Entity IDs use a short type prefix, a hyphen, and a UUID without dashes—for example, fil-..., fgr-..., act-..., and atk-.... Optional JSON fields are omitted when no value is available.

User and organization

{
  "id": "usr-a1b2c3...",
  "email": "ada@example.com",
  "name": "Ada Lovelace",
  "primary_org_id": "org-d4e5f6...",
  "org_ids": ["org-d4e5f6..."]
}
{
  "id": "org-d4e5f6...",
  "name": "Ada's Organization"
}

POST /auth/login wraps the user in { "user": ..., "token": "<firebase-id-token>", "is_new_user": boolean }.

File

{
  "id": "fil-a1b2c3...",
  "organization_id": "org-d4e5f6...",
  "name": "budget.xlsx",
  "extension": "xlsx",
  "version": 1,
  "size_bytes": 82451,
  "uploaded_at": "2026-04-17T10:00:00Z",
  "created_at": "2026-04-17T09:59:55Z",
  "updated_at": "2026-04-17T10:00:00Z"
}

FileGroup

{
  "id": "fgr-a1b2c3...",
  "organization_id": "org-d4e5f6...",
  "name": "Q4 Budget",
  "version": 1,
  "files": [
    {
      "id": "fil-0123ab...",
      "organization_id": "org-d4e5f6...",
      "name": "budget.xlsx",
      "extension": "xlsx",
      "version": 1,
      "size_bytes": 82451,
      "uploaded_at": "2026-04-17T10:00:00Z",
      "created_at": "2026-04-17T09:59:55Z",
      "updated_at": "2026-04-17T10:00:00Z"
    }
  ],
  "created_at": "2026-04-17T09:59:55Z",
  "updated_at": "2026-04-17T10:00:00Z"
}

The files field is omitted when the group is empty.

Action

{
  "id": "act-a1b2c3...",
  "file_group_id": "fgr-d4e5f6...",
  "user_id": "usr-0123ab...",
  "action_type": "ANALYZE_EXCEL",
  "status": "RUNNING",
  "requested_at": "2026-04-17T10:00:00Z",
  "started_at": "2026-04-17T10:00:01Z",
  "progress": {
    "total_tasks": 50,
    "completed_tasks": 21,
    "failed_tasks": 0,
    "running_tasks": 3,
    "percent_complete": 42
  },
  "eta": {
    "stage": "POST_PARSER",
    "estimated_remaining_ms": 15000,
    "lower_bound_ms": 10000,
    "upper_bound_ms": 25000,
    "confidence": 0.85,
    "estimated_completion_at": "2026-04-17T10:01:30Z",
    "tasks_remaining": 29,
    "tasks_completed": 21,
    "elapsed_ms": 15000,
    "using_historical_data": true
  },
  "wasm": {
    "action_id": "act-a1b2c3...",
    "status": "RUNNING",
    "manifest_ready": false,
    "wasm_ready": false,
    "compile_requested": false
  }
}

The enriched action may also contain file_group, user, tasks, trace_id, completed_at, error_message, last_heartbeat_at, progress_step, progress_percent, error_kind, summary, pipeline_version, and failure_bundle_path. The nested wasm object reports readiness only; it does not contain artifact URLs. Its manifest_ready field refers to the legacy cell manifest, not GET /vm/manifest; use terminal action status to gate the VM endpoints.

API token

{
  "id": "atk-a1b2c3...",
  "organization_id": "org-d4e5f6...",
  "name": "my-api-token",
  "created_at": "2026-04-17T10:00:00Z",
  "last_used_at": "2026-04-17T10:05:00Z",
  "expires_at": "2026-07-16T10:00:00Z",
  "revoked_at": "2026-05-01T12:00:00Z"
}

VM manifest

GET /actions/{id}/vm/manifest returns the contract embedded in that action's plan.xvm. The example is null if the graph has no overridable numeric or boolean input connected to an output.

{
  "schema_version": 2,
  "blob_hash": "19a687c0f74d8d4c",
  "input_count": 2,
  "output_count": 1,
  "intermediate_count": 1,
  "inputs": [
    { "address": "Loan!B1", "type": "number", "baseline": 300000 },
    { "address": "Loan!B2", "type": "number", "baseline": 0.065 }
  ],
  "outputs": [
    { "address": "Loan!B8", "type": "number" }
  ],
  "intermediates": [
    { "address": "Loan!B4" }
  ],
  "example": {
    "inputs": [
      { "address": "Loan!B1", "value": 300000 },
      { "address": "Loan!B2", "value": 0.065 }
    ],
    "outputs": ["Loan!B8"]
  }
}

VM run

Inputs are numeric overrides keyed by exact manifest address. Pass booleans as 1 or 0 and dates as Excel serial values; text overrides are not supported. Omit outputs, or send an empty array, to return every terminal output. A selected output can also be a supported intermediate address.

{
  "inputs": {
    "Loan!B1": 325000,
    "Loan!B2": 0.06
  },
  "outputs": ["Loan!B8"]
}

The response has one entry per selected address. value may be a number, boolean, string, or null; an Excel error sets value to null and error to a string such as #DIV/0!.

{
  "outputs": {
    "Loan!B8": {
      "value": 382633.4653724008,
      "error": null
    }
  }
}

Unknown, non-overridable, or unsupported addresses fail the whole request with HTTP 400; the API does not return a partial result. Run request bodies are capped at 1 MiB and each evaluation has a 30-second server timeout.

Simulation config

The values in the four state objects are application-defined JSON. A missing config is returned as:

{
  "cell_selection": {},
  "distributions": {},
  "run_config": {},
  "view_state": {},
  "updated_at": ""
}

Limits and notes

  • File content uploaded through /cli/compile is capped at 100 MiB; multipart framing is allowed separately. Use the presigned flow for larger files.
  • GCS upload and original-file download URLs expire after 15 minutes. Local original-file downloads use a non-expiring, authenticated API-relative URL. Compiled-artifact URLs expire after 5 minutes.
  • Hosted VM run requests are capped at 1 MiB and time out after 30 seconds.
  • No per-tenant rate limits are enforced today; treat the API as best-effort and add client-side backoff on 5xx responses.
  • API versioning is in the path (/api/v2). Use v2 for new integrations except for the explicitly documented compiled-artifact resolver, which currently has no v2 alias. The older v1 simulation-config routes remain compatibility aliases and are not the supported integration surface.

Getting help

File bugs and feature requests through the bug form or email contact@pythia.software.