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.
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:
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:
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
}
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 |
Source |
One file in the script’s source tree, identified by its |
Run |
One execution. |
Operation |
The record you poll while asynchronous work finishes. Its |
|
Two booleans on a run telling you whether the run used an up-to-date |
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:
POST /scripts/{scriptId}/execution→ script run; available before completionPOST /scripts/{scriptId}/dependencies/generation→ dependency run; available before completionPOST /scripts/{scriptId}/sources/{sourceId}/contentUpload→ uploaded sourcePOST /scripts/{scriptId}/sources/sync→ source collectionPOST /scripts/{scriptId}/sources/export→ ZIP download; available when complete
Read resourceUrl as soon as it appears, and continue polling until the operation reaches a terminal status.
{
"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#
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#
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#
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#
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 |
|---|---|
|
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. |
|
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#
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#
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#
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#
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#
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#
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#
{
"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
{
"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
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.
$filteris 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 |
|
My script ran against old packages |
Dependency generation is never automatic. Call |
I cannot find the run ID after starting a run |
Poll the operation from |
A download endpoint returned no body |
|
My |
|
|
List responses omit it. Retrieve the individual run. |
I need to rename a script |
Use |
|
Back off and honor |
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 |
|
|
n/a |
Retrieve a list of scripts |
|
|
|
Create a new script |
|
|
|
Retrieve a single script |
|
|
|
Partially update a single script |
|
|
|
Initiate a dependency bundle generation |
|
|
|
Initiate a script execution |
|
|
|
Retrieve a list of runs |
|
|
|
Retrieve a single run |
|
|
|
Cancel an in-progress script run |
|
|
|
Initiate a download of run logs |
|
|
|
Initiate a download of run output |
|
|
|
Retrieve a list of sources |
|
|
|
Create a new source |
|
|
|
Export script sources as a ZIP archive |
|
|
|
Sync script sources from a ZIP archive |
|
|
|
Retrieve a single source |
|
|
|
Partially update a single source |
|
|
|
Delete a single source |
|
|
|
Initiate a content download |
|
|
|
Initiate a content upload |
|
|
|
Retrieve permissions for a script |
|
|
|
Modify permissions on a script |
|
|
|