Trigger Flows from External Apps via Secure Webhook URLs
For the complete documentation index, see llms.txt. For a full content snapshot, see llms-full.txt. Append.mdto anykestra.io/docs/*URL for plain Markdown.
Trigger flows automatically in response to web-based events.
A Webhook trigger generates a unique URL that lets external applications (such as GitHub, Amazon EventBridge, or any system that can send HTTP requests) start new executions in Kestra. Each webhook URL requires a secret key. Store the key value in Kestra Secrets and reference it from the trigger definition; never hardcode a key directly in the flow YAML. Kestra accepts GET, POST, and PUT requests on the webhook URL.
Example
id: triggernamespace: company.team
tasks: - id: hello type: io.kestra.plugin.core.log.Log message: "Hello World! 🚀"
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}"The key is embedded in the webhook URL: /api/v1/main/executions/webhook/{namespace}/{flowId}/{key}. To start the flow:
https://{kestra_domain}/api/v1/main/executions/webhook/{namespace}/{flowId}/{key}Replace kestra_domain, namespace, flowId, and key with your values. You can also copy the webhook URL from the Triggers tab.
Handling the request body
By default, the webhook trigger reads the request body and makes it available as trigger.body. Use the fetchType property to change this behavior.
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" fetchType: FETCH # FETCH (default) | STORE | NONEfetchType | Body handling |
|---|---|
FETCH | Body is read into memory and exposed as trigger.body. Text, JSON, XML, YAML, CSV, and form-urlencoded bodies are decoded as strings or parsed objects. Binary bodies (any other content type) are base64-encoded. This is the default. |
STORE | Body is streamed directly to internal storage without being held in memory. The flow receives a kestra:// URI on trigger.uri. Use this for large payloads or when you do not want body content written into the execution record. |
NONE | Body is read off the connection and dropped. The flow receives neither trigger.body nor trigger.uri. |
File parts in a multipart/form-data request are always stored in internal storage, regardless of fetchType.
Binary and non-text bodies
When a request body has a content type that is not text, JSON, XML, YAML, CSV, or form-urlencoded, FETCH base64-encodes it and stores the result in trigger.body. Pass trigger.body to a script task to decode and process the raw bytes.
id: webhook_binary_bodynamespace: company.team
tasks: - id: log_body type: io.kestra.plugin.core.log.Log message: "base64={{ trigger.body }}"
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" fetchType: FETCHStream large bodies to internal storage
With fetchType: STORE, the body is streamed to internal storage and the flow receives trigger.uri instead of trigger.body.
id: webhook_store_bodynamespace: company.team
tasks: - id: log_uri type: io.kestra.plugin.core.log.Log message: "uri={{ trigger.uri }}"
- id: measure_body type: io.kestra.plugin.core.storage.Size uri: "{{ trigger.uri }}"
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" fetchType: STOREWhen fetchType: STORE, the body is never deserialized. when conditions that reference trigger.body will not work; filter on headers or query parameters instead.
File uploads with multipart/form-data
When a request arrives as multipart/form-data, file parts are stored in internal storage and exposed on trigger.parts. Text fields are exposed on trigger.formFields. This works regardless of fetchType.
Each entry in trigger.parts has the following shape:
trigger: parts: - name: photo filename: result.jpg contentType: image/jpeg size: 20345 uri: kestra:///company/team/executions/5cVhZ…/webhook/0/result.jpg formFields: note: - "looks good"Flow that receives a file upload and measures its size:
id: webhook_multipartnamespace: company.team
tasks: - id: log_parts type: io.kestra.plugin.core.log.Log message: "parts={{ trigger.parts }} fields={{ trigger.formFields }}"
- id: measure_upload type: io.kestra.plugin.core.storage.Size uri: "{{ trigger.parts[0].uri }}"
- id: log_size type: io.kestra.plugin.core.log.Log message: "size={{ outputs.measure_upload.size }}"
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}"Stored bytes are scoped to the execution and purged with it. If no execution is created (for example, because a when condition vetoed the request), stored bytes are cleaned up automatically.
Pass request data to scripts through environment variables
Anyone who has the webhook URL can call it without signing in to Kestra; the key is the only check. Treat everything the caller sends (trigger.body, trigger.headers, trigger.parameters, trigger.formFields) as untrusted input.
In script tasks, Kestra renders expressions in commands, beforeCommands, script, and interpreter before the interpreter runs, so a request value written there becomes part of the code. With the shell Commands task below, a body of {"name": "$(id)"} runs the id command, even inside double quotes:
# Unsafe: the request body becomes shell code- id: greet type: io.kestra.plugin.scripts.shell.Commands commands: - echo "Hello {{ trigger.body.name }}"Pass the value through env instead. The interpreter receives it as data and never parses it as code:
- id: greet type: io.kestra.plugin.scripts.shell.Commands env: NAME: "{{ trigger.body.name }}" commands: - echo "Hello $NAME"The same applies to other languages: in a Python Script, read the value with os.environ["NAME"] rather than writing {{ trigger.body.name }} into the script. To pass a whole JSON body, set BODY: "{{ trigger.body | toJson }}" and parse it in the script. Never wrap request data in the render() function, which evaluates any expression the caller sends.
See Security hardening for how to protect the webhook endpoint itself.
Filtering webhook executions with when
Use the when property to conditionally fire the trigger based on the request body or headers. The when value is a Pebble expression evaluated against the incoming request. If the expression evaluates to a falsy value, Kestra ignores the request and no execution is created.
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" when: "{{ trigger.body.hello == 'world' }}"Filtering on headers
trigger.headers maps each header name to a list of values, so use contains rather than == when filtering:
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" when: "{{ trigger.headers['X-GitHub-Event'] contains 'push' }}"Header names are matched by the exact casing the sender used. HTTP/2 clients and many proxies canonicalize headers to lowercase, so trigger.headers['X-GitHub-Event'] finds nothing when the client sends x-github-event. Check your client’s behavior and match the case accordingly.
You can combine body and header criteria in a single expression using and / or:
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" when: "{{ trigger.body.event == 'push' and trigger.headers['X-GitHub-Event'] contains 'push' }}"Webhook response
By default, the trigger responds immediately without waiting for the flow to finish. Set wait: true to hold the HTTP connection open until the execution completes (or the trigger’s timeout is reached), and returnOutputs: true to include the flow outputs as a JSON object in the response body.
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" wait: true returnOutputs: truewait: truekeeps the HTTP connection open until the flow finishes or the trigger’s timeout is reached.returnOutputs: truereturns the flow outputs as the HTTP response body.
Return flow outputs in the webhook response
To send task outputs back to the caller in the HTTP response, configure the Webhook trigger to wait for the execution and return outputs. The flow must expose at least one outputs entry.
id: webhook_return_outputsnamespace: company.team
tasks: - id: make_payload type: io.kestra.plugin.core.output.OutputValues values: result: "Hello {{ trigger.parameters.name[0] ?? 'world' }}!"
outputs: - id: greeting type: STRING value: "{{ outputs.make_payload.values.result }}"
triggers: - id: webhook type: io.kestra.plugin.core.trigger.Webhook key: "{{ secret('WEBHOOK_KEY') }}" wait: true returnOutputs: true responseContentType: application/json # sets the Content-Type header only; body is always the JSON outputs map- Call the webhook URL with a query parameter (for example
?name=Alice). The execution runs synchronously becausewait: trueis set. - The HTTP response body contains the flow outputs as a JSON object. With the example above, the response includes
"greeting": "Hello Alice!". responseContentTypesets theContent-Typeheader on the response. It does not change how the body is serialized — the body is always the JSON-serialized outputs map whenreturnOutputs: trueis set.
Test a webhook trigger
The Triggers tab on the flow page includes a Send a test event button for testing without an external tool. The modal accepts a custom JSON payload and optional headers:

See the Webhook trigger plugin documentation for a full list of properties and outputs.
Was this page helpful?