Introduction to the Scripting API#

The Scripting API lets you create Python scripts in Workiva, keep their source files in sync with your own repository, manage their Python dependencies, and run them on demand from your own systems.

Before getting started, make sure you have credentials set up.

Important

During Feature Preview, access is limited to participants who have accepted the Feature Preview Agreement. This API may change before general availability. For terms, expectations, and lifecycle details, see API Previews at Workiva.

This guide covers the following jobs:

Prerequisites & Authentication#

All requests require an OAuth 2.0 Bearer token, the X-Version: 2026-09-01.scripting.preview header, and either the file:read or file:write scope. Each operation below notes which scope it needs.

Request#
curl -X GET "https://api.app.wdesk.com/scripts" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "X-Version: 2026-09-01.scripting.preview"

Every example that follows uses these same two headers, plus Content-Type: application/json when it sends a body.

The curl examples require the jq JSON processor. The Python examples require the requests package. Set these environment variables before running either form:

Environment#
export TOKEN="your_access_token"
export API_BASE="https://api.app.wdesk.com"
export API_VERSION="2026-09-01.scripting.preview"

The examples use these helpers to poll asynchronous operations:

curl polling helper#
poll_operation() {
  local url="$1" headers response status retry_after

  while true; do
    headers=$(mktemp)
    response=$(curl --fail-with-body --silent --show-error \
      -D "${headers}" "${url}" \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "X-Version: ${API_VERSION}")
    status=$(jq -r '.status' <<<"${response}")

    case "${status}" in
      completed|failed|cancelled)
        rm -f "${headers}"
        printf '%s\n' "${response}"
        return
        ;;
    esac

    retry_after=$(awk 'tolower($1) == "retry-after:" {gsub("\r", "", $2); print $2}' "${headers}")
    rm -f "${headers}"
    sleep "${retry_after:-5}"
  done
}
Python request and polling helpers#
import os
import time

import requests

API_BASE = os.environ.get("API_BASE", "https://api.app.wdesk.com")
API_VERSION = os.environ.get("API_VERSION", "2026-09-01.scripting.preview")
HEADERS = {
    "Authorization": f"Bearer {os.environ['TOKEN']}",
    "X-Version": API_VERSION,
}
TERMINAL_STATUSES = {"completed", "failed", "cancelled"}


def api(method, path, **kwargs):
    url = path if path.startswith("http") else f"{API_BASE}{path}"
    request_headers = {**HEADERS, **kwargs.pop("headers", {})}
    response = requests.request(method, url, headers=request_headers, timeout=30, **kwargs)
    response.raise_for_status()
    return response


def wait_for_operation(url):
    while True:
        response = api("GET", url)
        operation = response.json()
        if operation["status"] in TERMINAL_STATUSES:
            return operation
        time.sleep(int(response.headers.get("Retry-After", "5")))

Important

Bearer tokens expire after 10 minutes. Request a new one from POST /oauth2/token rather than holding one for the length of a long run.

Key Concepts#

Concept

Description

Script

The container for your automation. Holds a name, a runtime, and a source tree. Only python3.13 is accepted when creating or updating a script; python3.9 is deprecated and appears only in responses for scripts created before the deprecation.

Source

One file in the script’s source tree, identified by its path. Metadata and content are managed separately.

Run

One execution. type is script for a script execution and dependency for a dependency bundle generation. Both use the same endpoints and the same status values.

Operation

The record you poll while asynchronous work finishes. Its resourceUrl points at the resulting resource when that resource is available.

dependencyState

Two booleans on a run telling you whether the run used an up-to-date dependencies.zip. requirementsInSync indicates that the requirements.txt used to build the bundle matches the script’s current requirements.txt, and runtimeInSync indicates that the runtime used to build the bundle matches the script’s current runtime.

Working with Long-Running Operations#

Starting a run, generating dependencies, syncing sources, exporting sources, and uploading source content all immediately return 202 Accepted and a URL to poll if the request is valid. None of these actions return a resource.

Scripting uses the standard Workiva GET /operations/{operationId} endpoint for polling. It is not repeated in the Scripting preview reference. Poll the URL returned by the Scripting response and continue sending X-Version: 2026-09-01.scripting.preview.

Poll operationLocation, also returned in the Location header, until status reaches completed, failed, or cancelled. The meaning and timing of resourceUrl depend on the initiating endpoint:

Read resourceUrl as soon as it appears, and continue polling until the operation reaches a terminal status.

Response (started)#
{
  "id": "128f274395254cf17eda6b3eb3d021b9",
  "status": "started",
  "resourceUrl": "/scripts/ae82b647-8e43-44c3-a4e7-2aa3294c87ac/runs/8e1f2c90-3d4b-4a11-9c77-1b2a3c4d5e6f"
}

An operation status is one of acknowledged, queued, started, completed, cancelled, or failed. Continue polling after receiving resourceUrl until the operation reaches a terminal status.

Important

Poll the URL you were given. Do not build a polling URL from an ID. Honor the Retry-After header between attempts; the operations endpoint is rate limited and can be as slow as one request per second.

Note

contentDownload, logsDownload, and outputDownload are named “Initiate a download” but are synchronous. They return 200 OK with a downloadUrl in the body, or 204 No Content when there is nothing to download.

Creating a Script and Pushing Your Code#

Create the script, register __main__.py, upload its content, and wait for the upload operation. All three API operations require file:write.

This workflow uses POST /scripts, POST /scripts/{scriptId}/sources, and POST /scripts/{scriptId}/sources/{sourceId}/contentUpload.

curl#

Create a script and upload main.py#
set -euo pipefail

SCRIPT_ID=$(
  curl --fail-with-body --silent --show-error \
    -X POST "${API_BASE}/scripts" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}" \
    -H "Content-Type: application/json" \
    -d '{"name":"Hello from Scripting","runtime":"python3.13"}' |
    jq -r '.id'
)

SOURCE_ID=$(
  curl --fail-with-body --silent --show-error \
    -X POST "${API_BASE}/scripts/${SCRIPT_ID}/sources" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}" \
    -H "Content-Type: application/json" \
    -d '{"path":"__main__.py"}' |
    jq -r '.id'
)

UPLOAD_HEADERS=$(mktemp)
UPLOAD_RESPONSE=$(
  curl --fail-with-body --silent --show-error \
    -D "${UPLOAD_HEADERS}" \
    -X POST "${API_BASE}/scripts/${SCRIPT_ID}/sources/${SOURCE_ID}/contentUpload" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}"
)
UPLOAD_URL=$(jq -r '.uploadUrl' <<<"${UPLOAD_RESPONSE}")
OPERATION_URL=$(awk 'tolower($1) == "location:" {gsub("\r", "", $2); print $2}' "${UPLOAD_HEADERS}")
rm -f "${UPLOAD_HEADERS}"

curl --fail-with-body --silent --show-error \
  -X PUT "${UPLOAD_URL}" \
  -H "Authorization: Bearer ${TOKEN}" \
  --data-binary @__main__.py

UPLOAD_OPERATION=$(poll_operation "${OPERATION_URL}")
if [[ $(jq -r '.status' <<<"${UPLOAD_OPERATION}") != "completed" ]]; then
  printf '%s\n' "${UPLOAD_OPERATION}" | jq >&2
  exit 1
fi
printf 'Created script %s\n' "${SCRIPT_ID}"

Python#

Create a script and upload main.py#
script = api(
    "POST",
    "/scripts",
    json={"name": "Hello from Scripting", "runtime": "python3.13"},
).json()

source = api(
    "POST",
    f"/scripts/{script['id']}/sources",
    json={"path": "__main__.py"},
).json()

upload = api(
    "POST",
    f"/scripts/{script['id']}/sources/{source['id']}/contentUpload",
)
with open("__main__.py", "rb") as source_file:
    api("PUT", upload.json()["uploadUrl"], data=source_file)

upload_operation = wait_for_operation(upload.headers["Location"])
if upload_operation["status"] != "completed":
    raise RuntimeError(upload_operation)
print(f"Created script {script['id']}")

Use the returned ID in later examples by setting SCRIPT_ID in your shell environment.

Important

A new script has no source files. Create __main__.py, the entry point, before running it. The uploadUrl is temporary, and new content is not available to a run until the upload operation completes.

Note

Use the optional container property to create the script in a folder. It is accepted on create but is not returned when you read the script.

Keeping Your Code in Sync#

Whether you develop locally or keep your script in version control, you must sync the whole source tree as a ZIP archive instead of making one call per file.

Push your source tree#

Sync returns both an uploadUrl for the archive and an operationLocation to poll.

Required scope: file:write

POST /scripts/{scriptId}/sources/sync

curl#

Upload a ZIP and wait for source sync#
set -euo pipefail

SYNC_RESPONSE=$(
  curl --fail-with-body --silent --show-error \
    -X POST "${API_BASE}/scripts/${SCRIPT_ID}/sources/sync" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}" \
    -H "Content-Type: application/json" \
    -d '{"mode":"replace"}'
)

UPLOAD_URL=$(jq -r '.uploadUrl' <<<"${SYNC_RESPONSE}")
OPERATION_URL=$(jq -r '.operationLocation' <<<"${SYNC_RESPONSE}")

curl --fail-with-body --silent --show-error \
  -X PUT "${UPLOAD_URL}" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/zip" \
  --data-binary @sources.zip

SYNC_OPERATION=$(poll_operation "${OPERATION_URL}")
if [[ $(jq -r '.status' <<<"${SYNC_OPERATION}") != "completed" ]]; then
  printf '%s\n' "${SYNC_OPERATION}" | jq >&2
  exit 1
fi

Python#

Upload a ZIP and wait for source sync#
SCRIPT_ID = os.environ["SCRIPT_ID"]

sync = api(
    "POST",
    f"/scripts/{SCRIPT_ID}/sources/sync",
    json={"mode": "replace"},
)
with open("sources.zip", "rb") as archive:
    api(
        "PUT",
        sync.json()["uploadUrl"],
        headers={"Content-Type": "application/zip"},
        data=archive,
    )

sync_operation = wait_for_operation(sync.json()["operationLocation"])
if sync_operation["status"] != "completed":
    raise RuntimeError(sync_operation)

Mode

Behavior

replace

Creates and updates sources from the archive, and deletes any source not present in it. The archive is the full desired state of the source tree.

merge

Creates and updates sources from the archive, and leaves sources absent from it untouched.

Mode options for both operations#

Important

replace is the default mode and the request body is optional, so sending nothing in the body deletes every source that is not in your archive. Always send mode explicitly.

Pull your source tree#

Export bundles the script’s current sources as a ZIP file that preserves their paths, so you can diff it locally or use it as the basis for a later sync. It takes no body and returns 202 Accepted with an operationLocation. When the operation completes, its resourceUrl holds the download URL for the archive.

Required scope: file:read

POST /scripts/{scriptId}/sources/export

curl#

Export and download the source ZIP#
set -euo pipefail

OPERATION_URL=$(
  curl --fail-with-body --silent --show-error \
    -X POST "${API_BASE}/scripts/${SCRIPT_ID}/sources/export" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}" |
    jq -r '.operationLocation'
)

EXPORT_OPERATION=$(poll_operation "${OPERATION_URL}")
if [[ $(jq -r '.status' <<<"${EXPORT_OPERATION}") != "completed" ]]; then
  printf '%s\n' "${EXPORT_OPERATION}" | jq >&2
  exit 1
fi

DOWNLOAD_URL=$(jq -r '.resourceUrl' <<<"${EXPORT_OPERATION}")
curl --fail-with-body --silent --show-error "${DOWNLOAD_URL}" \
  -o sources.zip

Python#

Export and download the source ZIP#
SCRIPT_ID = os.environ["SCRIPT_ID"]

export = api("POST", f"/scripts/{SCRIPT_ID}/sources/export")
export_operation = wait_for_operation(export.json()["operationLocation"])
if export_operation["status"] != "completed":
    raise RuntimeError(export_operation)

download = requests.get(export_operation["resourceUrl"], timeout=30)
download.raise_for_status()
with open("sources.zip", "wb") as output:
    output.write(download.content)

A script with no sources is rejected with 400.

Managing Python Dependencies#

After you have uploaded requirements.txt, start the dependency generation and wait for it to finish, then retrieve the resulting dependency run. Generation requires file:write; reading the run requires file:read.

Important

Changing requirements.txt or a script’s runtime does not regenerate the bundle. Call dependency generation yourself afterwards, or the script runs against a stale dependencies.zip, visible as dependencyState.requirementsInSync: false.

This workflow uses POST /scripts/{scriptId}/dependencies/generation and GET /scripts/{scriptId}/runs/{runId}.

curl#

Generate dependencies and retrieve the run#
set -euo pipefail

OPERATION_URL=$(
  curl --fail-with-body --silent --show-error \
    -X POST "${API_BASE}/scripts/${SCRIPT_ID}/dependencies/generation" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}" |
    jq -r '.operationLocation'
)

OPERATION=$(poll_operation "${OPERATION_URL}")
RUN_URL=$(jq -r '.resourceUrl' <<<"${OPERATION}")
[[ "${RUN_URL}" == http* ]] || RUN_URL="${API_BASE}${RUN_URL}"

curl --fail-with-body --silent --show-error "${RUN_URL}" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "X-Version: ${API_VERSION}" |
  jq

Python#

Generate dependencies and retrieve the run#
SCRIPT_ID = os.environ["SCRIPT_ID"]

generation = api(
    "POST",
    f"/scripts/{SCRIPT_ID}/dependencies/generation",
)
operation = wait_for_operation(generation.json()["operationLocation"])
dependency_run = api("GET", operation["resourceUrl"]).json()
print(dependency_run)

Generation produces a run with type: dependency. Its URL appears in resourceUrl before completion, so you can monitor or cancel it with the standard run endpoints while generation is in progress. A 409 Conflict means a generation is already in flight.

Running a Script#

Execute the script, wait for the operation, retrieve the run, and download its output. Execution requires file:write; reading the run and output requires file:read.

This workflow uses POST /scripts/{scriptId}/execution, GET /scripts/{scriptId}/runs/{runId}, and POST /scripts/{scriptId}/runs/{runId}/outputDownload.

The execution body is required. Each property becomes an environment variable in capitalized snake case: apiKey becomes API_KEY. Values must be strings. Send {} when the script needs no variables.

Pass an optional X-Scripting-Nonce request header as a client-generated idempotency key. Replaying the same nonce returns the original run instead of starting a new one, making retries safe after a 429 or a network failure.

curl#

Execute, monitor, and retrieve output#
set -euo pipefail

OPERATION_URL=$(
  curl --fail-with-body --silent --show-error \
    -X POST "${API_BASE}/scripts/${SCRIPT_ID}/execution" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}" \
    -H "Content-Type: application/json" \
    -d '{"spreadsheetId":"a1b2c3d4e5f6","rowNumber":"27"}' |
    jq -r '.operationLocation'
)

OPERATION=$(poll_operation "${OPERATION_URL}")
RUN_URL=$(jq -r '.resourceUrl' <<<"${OPERATION}")
[[ "${RUN_URL}" == http* ]] || RUN_URL="${API_BASE}${RUN_URL}"

RUN=$(
  curl --fail-with-body --silent --show-error "${RUN_URL}" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}"
)
printf '%s\n' "${RUN}" | jq

OUTPUT_RESPONSE=$(
  curl --fail-with-body --silent --show-error \
    -X POST "${RUN_URL}/outputDownload" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "X-Version: ${API_VERSION}"
)

if [[ -n "${OUTPUT_RESPONSE}" ]]; then
  OUTPUT_URL=$(jq -r '.downloadUrl' <<<"${OUTPUT_RESPONSE}")
  curl --fail-with-body --silent --show-error "${OUTPUT_URL}"
fi

Python#

Execute, monitor, and retrieve output#
SCRIPT_ID = os.environ["SCRIPT_ID"]

execution = api(
    "POST",
    f"/scripts/{SCRIPT_ID}/execution",
    json={"spreadsheetId": "a1b2c3d4e5f6", "rowNumber": "27"},
)
operation = wait_for_operation(execution.json()["operationLocation"])
run = api("GET", operation["resourceUrl"]).json()
print(run)

output = api("POST", f"{operation['resourceUrl']}/outputDownload")
if output.status_code != 204:
    download = requests.get(output.json()["downloadUrl"], timeout=30)
    download.raise_for_status()
    print(download.text)

Anything written with print("::set-output ...") becomes run output. Use logsDownload instead of outputDownload to retrieve logs. Either endpoint returns 204 No Content when no content is available.

Run status and cancellation#

Example failed run#
{
  "status": "failed",
  "failureReason": "Timed out",
  "termination": {
    "type": "resource_limit_exceeded",
    "reason": "allocated_time_exceeded",
    "signal": "SIGTERM",
    "code": "TIMEOUT[SIGTERM]",
    "message": "Script exceeded allocation time limit.",
    "gracePeriodSeconds": 15
  },
  "dependencyState": { "requirementsInSync": true, "runtimeInSync": true }
}

status is one of acknowledged, queued, started, completed, cancelled, or failed.

Warning

Branch on the run’s status. failureReason is human-readable; use termination.type and termination.reason for program logic.

The run URL appears in the operation’s resourceUrl before completion. To cancel an acknowledged, queued, or started run, call POST /scripts/{scriptId}/runs/{runId}/cancellation; cancellation returns 204 No Content.

Finding Scripts and Runs#

Search scripts#

Required scope: file:read

GET /scripts

Response (200 OK)#
{
  "data": [
    {
      "id": "ae82b647-8e43-44c3-a4e7-2aa3294c87ac",
      "name": "Hide Rows with Value of Zero",
      "runtime": "python3.13",
      "state": "active",
      "created": { "dateTime": "2026-09-15T10:00:00Z" }
    }
  ],
  "@nextLink": "<opaque_url>"
}

Pass the encoded pagination token from @nextLink back as $next to fetch the next page. $maxpagesize accepts 1 to 1000 and defaults to 1000. When there are no more results, @nextLink is absent.

GET /scripts does not support $filter or $orderBy. Filter on name or state in your own code.

Search runs#

GET /scripts/{scriptId}/runs returns script executions and dependency generations together. Read type to tell them apart. Pagination works the same as for scripts.

Required scope: file:read

Available $filter fields: started.dateTime

started.dateTime supports the ge and le operators, which you can join with and. Values must be RFC 3339 timestamps.

Example: runs started in a date window

GET /scripts/{scriptId}/runs?$filter=started.dateTime ge 2026-09-15T00:00:00Z and started.dateTime le 2026-09-30T23:59:59Z

Request#
curl -G "https://api.app.wdesk.com/scripts/${SCRIPT_ID}/runs" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "X-Version: 2026-09-01.scripting.preview" \
  --data-urlencode '$filter=started.dateTime ge 2026-09-15T00:00:00Z and started.dateTime le 2026-09-30T23:59:59Z'

Restrictions

  • A filtered result excludes runs that never started.

  • $filter is ignored once you supply $next, because the cursor already carries the filter forward.

  • List items omit dependencyState; read an individual run to see it.

Common Issues#

Issue

Solution

Source sync deleted files I did not touch

mode defaults to replace, which deletes anything absent from the archive. Send {"mode": "merge"} to update a subset.

My script ran against old packages

Dependency generation is never automatic. Call POST /scripts/{scriptId}/dependencies/generation after changing requirements.txt or runtime.

I cannot find the run ID after starting a run

Poll the operation from operationLocation and read the run ID from resourceUrl as soon as it appears. Do not wait for completion.

A download endpoint returned no body

204 No Content means there is nothing to download yet, not an error.

My $filter had no effect

$filter is ignored when $next is present. Apply the filter on the first request only.

dependencyState is missing

List responses omit it. Retrieve the individual run.

I need to rename a script

Use PATCH /scripts/{scriptId} with one replace operation targeting /name.

429 Too Many Requests

Back off and honor Retry-After. Polling an operation can be limited to one request per second.

Request rejected for size

Request payloads are capped at 10 MB. Move source content and archives through the presigned URLs, not request bodies.

Summary of API Endpoints#

Operation

Method

Endpoint

Required Scope

Retrieve a token

POST

/oauth2/token

n/a

Retrieve a list of scripts

GET

/scripts

file:read

Create a new script

POST

/scripts

file:write

Retrieve a single script

GET

/scripts/{scriptId}

file:read

Partially update a single script

PATCH

/scripts/{scriptId}

file:write

Initiate a dependency bundle generation

POST

/scripts/{scriptId}/dependencies/generation

file:write

Initiate a script execution

POST

/scripts/{scriptId}/execution

file:write

Retrieve a list of runs

GET

/scripts/{scriptId}/runs

file:read

Retrieve a single run

GET

/scripts/{scriptId}/runs/{runId}

file:read

Cancel an in-progress script run

POST

/scripts/{scriptId}/runs/{runId}/cancellation

file:write

Initiate a download of run logs

POST

/scripts/{scriptId}/runs/{runId}/logsDownload

file:read

Initiate a download of run output

POST

/scripts/{scriptId}/runs/{runId}/outputDownload

file:read

Retrieve a list of sources

GET

/scripts/{scriptId}/sources

file:read

Create a new source

POST

/scripts/{scriptId}/sources

file:write

Export script sources as a ZIP archive

POST

/scripts/{scriptId}/sources/export

file:read

Sync script sources from a ZIP archive

POST

/scripts/{scriptId}/sources/sync

file:write

Retrieve a single source

GET

/scripts/{scriptId}/sources/{sourceId}

file:read

Partially update a single source

PATCH

/scripts/{scriptId}/sources/{sourceId}

file:write

Delete a single source

DELETE

/scripts/{scriptId}/sources/{sourceId}

file:write

Initiate a content download

POST

/scripts/{scriptId}/sources/{sourceId}/contentDownload

file:read

Initiate a content upload

POST

/scripts/{scriptId}/sources/{sourceId}/contentUpload

file:write

Retrieve permissions for a script

GET

/scripts/{scriptId}/permissions

file:read

Modify permissions on a script

POST

/scripts/{scriptId}/permissions/modification

file:write