Webhooks
Webhooks are a way to interact with Windmill using standard web technologies.
Snippet of a Deno/TypeScript script using only native JavaScript to trigger a webhook can be found on Windmill Hub.
Some use cases include triggering scripts and flows from Slack or Emails.
Webhooks for scripts & flows
Each script and flow created in Windmill gets autogenerated webhooks. The webhooks depend on how they are triggered, and what their return values are.
Addresses

Webhook links can be found on the Detail page of each Script and Flow, on the Details and Triggers tab. For more information about Scripts or Flows, refer to the Getting started section.
Each webhook has two URLs, one with the path to the script, i.e.
/p/u/<your_user>/<your_script_name>, which will always trigger the latest
version of the Script/Flow and the other one with just a hash, i.e. /h/<hash>,
hiding potentially sensitive information and always corresponding to that
version of the script, even with overwrites.
Version-pinned webhooks (flows)
Flows additionally expose version-pinned webhook endpoints that target a specific flow version by its numeric version ID instead of the latest deployment. They use the fv/<version> path segment and are available in the Triggers tab of the flow:
# async
/api/w/<workspace>/jobs/run/fv/<version>
# sync
/api/w/<workspace>/jobs/run_wait_result/fv/<version>
# SSE stream
/api/w/<workspace>/jobs/run_and_stream/fv/<version>
Unlike the script /h/<hash> endpoint, the flow-by-version URL is stable across redeploys and guarantees that integrations keep calling the exact flow version they were tested against, even after new versions are published. Callers still need a token with permission to run the flow.
Asynchronous
Jobs can be triggered in asynchronous mode, meaning that the webhook is triggered, and the returning value is the uuid of the job assigned to execute the underlying code
These links are available in the "UUID/Async" tab.
Synchronous
The second type of autogenerated endpoint is the synchronous webhook. This webhook triggers the execution, automatically extracts the underlying code's return value and returns it as the response.
Every script exposes an endpoint that triggers the Script but waits for its full execution before returning.
The endpoint has the following format:
https://app.windmill.dev/api/w/$WORKSPACE_ID/jobs/run_wait_result/$SCRIPT_PATH
where $SCRIPT_PATH is the path of the Script in the workspace, including the prefix u/ or f/
These links are available in the "Result/Sync" tab.
For scripts, there is an additional synchronous webhook available that accepts a GET request. The payload must be passed as the query arg payload and encoded in JSON first, then in an URL safe base64, e.g: encodeURIComponent(btoa(JSON.stringify({a: 2}))). This endpoint has the same URL as the Result/Sync POST Path URLs.
Be cautious with potentially long-running jobs in synchronous mode.
You can set a max timeout for sync endpoints at the instance-level.
Asynchronous vs. Synchronous
It's always better to use asynchronous mode as it allows your client not to wait for the response and it avoids Windmill to have to maintain a connection to your client while the job is running. However, for short-running jobs where it's easier in your code to block until you get a response, then use the synchronous mode.
When using the synchronous mode, the webhook returns the result of the script directly. If the script returns an error, the default behavior is to return a 200 status code with the error as a JSON object. You can customize the https status code if needed.
When using the asynchronous mode, the webhook returns a uuid and you can poll the get job API call to fetch the status and results once it is completed.
Content types
Webhooks accept any content type, not just application/json. When the body is not JSON, the raw body string is available via a raw_string argument. Windmill still attempts to parse the body as JSON regardless of the content type, falling back gracefully for non-JSON payloads (XML, plain text, form-urlencoded, etc.).
User token
To interact with Windmill you always need to use Bearer token authentication.
You can generate tokens for your own account in the Account Settings menu in the app. Open it by clicking your username on the side menu, then select "Account settings".
Labels are only used to allow users to easily distinguish keys. Tokens can also be created with granular scopes to restrict access to specific resources and actions.
You can only see the token once, when it's created. Make sure to store it securely!
When SMTP is configured, token owners automatically receive an email notification 7 days before a token expires, and again when the token is deleted after expiry. The tokens table in user settings also shows color-coded expiration badges for tokens expiring within 30 days. For more details, see Token expiration notifications.

Webhook specific tokens
Webhook specific tokens allow sharing tokens publicly without fear since the token will only be able to trigger a specific script/flow and not impersonate you for any other operations.
It also avoids the hassle of having to create an anonymous user and check their permissions. If you can run the script yourself, then the webhook specific token will still inherit your own permissions.

Triggering
Once you have a webhook URL and a user token, issue a request to the endpoint and you will get the appropriate return as response.
The bearer token must be passed as either an Authorization: Bearer <TOKEN>
header, or as a token query parameter:
https://<instance>/<route>?token=<TOKEN>
Due to security reasons, it is highly recommended to pass the token in the header. If it's not possible, then URL that contains the token should be treated as a secret (for more context please check OWASP ref.1 and OWASP ref.2).
Examples using cURL for POST requests:
## Request with Header
curl -X POST \
--data '{}' \
-H "Content-Type: application/json" \
-H "Authorization: Bearer supersecret" \
".../w/demo/jobs/run_wait_result/p/u/bot/hello_world_deno"
## Query parameter
curl -X POST \
--data '{}' \
-H "Content-Type: application/json" \
".../w/demo/jobs/run_wait_result/p/u/bot/hello_world_deno?token=supersecret"
Examples using cURL for synchronous GET requests:
## Request with Header".
curl -X GET \
-H "Content-Type: application/json" \
-H "Authorization: Bearer supersecret" \
".../w/demo/jobs/run_wait_result/p/u/bot/hello_world_deno?payload=<URL_SAFE_BASE64_ENCODED_JSON>"
You can find an example using only standard Deno libraries on the Windmill Hub.
You can also verify that the job has been triggered and run (or investigate any encountered issues), by checking the Runs menu on the app.

Body
The webhook endpoints accept a JSON object or url encoded form data as the body, where each key corresponds to an argument of the script/flow.
Non object payload / body
If the payload is not an object, it will be wrapped in an object with the key body and the value will be the payload/body itself. e.g:
[1,2,3] => {"body": [1,2,3]}
and your script can process it as:
def main(body: List[int]):
print(body)
Wrapping body, handling arbitrary payload
You can also force the payload to be wrapped in an object at the key body by passing the query arg wrap_body=true. This is useful when the payload is not not known in advance and you want to handle it in your script. e.g:
Python:
def main(body: Any):
print(body)
Typescript:
export async function main(body: any) {
console.log(body);
}
Raw payload / body
Similarly to request headers, if the query args contain raw=true, then an additional argument will be added: raw_string which contains the entire json payload as a string (without any parsing). This is useful to verify the signature of the payload for example (discord require the endpoints to verify the signature for instance).
Handling form data (file uploads)
The webhook endpoints also accept multipart/form-data, useful for file uploads. The payload should be a FormData object, where each key corresponds to an argument of the script/flow.
For file fields, the value will be an array containing s3 objects with the path to the uploaded files in the workspace object storage (e.g., S3).
Here's an example script for handling form data with a file field:
- TypeScript
- Python
import { S3Object } from 'windmill-client';
export async function main(mytextfield: string, myfilefield: S3Object[]) {
return myfilefield;
}
import wmill
from typing import List
def main(mytextfield: str, myfilefield: List[wmill.S3Object]):
return myfilefield
A workspace object storage must be configured to accept form data payloads.
Request headers
It is possible for jobs to take request headers as arguments. To do so, either specify in the query args the headers to process at include_header, separated with ,. e.g:
/api/w/workspace/jobs/run_wait_result/p/u/user/new_script?include_header=X-Sign,foo
or use the env variable: INCLUDE_HEADERS with the same format so that all requests to any job will include the headers.
Query args
It is possible to pass query args to the job. To do so, either specify in the query args the headers to process at include_query, separated with ,. e.g for a sync get request (works for all endpoints):
/api/w/workspace/jobs/run_wait_result/p/u/user/new_script?include_query=a,b,c&a=foo&b=bar&c=foobar
to have a: "foo", b: "bar", c: "foobar", passed as args.
Custom response code
For all sync run jobs endpoints, if the response contains a key wm_status_code (or its longer alias windmill_status_code) with a number value, that value will be used as the status code. For example, if a script or flow returns:
{
"wm_status_code": 201,
"result": {
"Hello": "World"
}
}
the synchronous endpoint will return:
{
"Hello": "World"
}
with a status code 201.
Note that if the status code is invalid (w.r.t RFC9110), the endpoint will return an error.
Custom response content type
Similarly to the above, for all sync run jobs endpoints, if the response contains a key wm_content_type (or its longer alias windmill_content_type), the associated value will be used as the content type header of the response. For example, if a script or flow returns:
{
"wm_content_type": "text/csv",
"result": "Hello;World"
}
the synchronous endpoint will return:
"Hello;World"
with the response header: "Content-Type: text/csv".