Repository: kestra-io/kestra
Issue: Webhook trigger : allow to specify condition on payload to trigger execution
Labels: enhancement

### Feature description

For some APIs we might want to check a condition (key exists, value is a specific one we are interested in) to decide to trigger our flow or not.

Currently this is doable within the flow definition but add some complexity, still generate flow execution and pollute history.

=> Can be nice to have an optional parameter `payloadCondition` which would contain our condition.

Flow could look like : 

```YAML
triggers:
  - id: webhook
    type: io.kestra.core.models.triggers.types.Webhook
    key: 4wjtkzwVGBM9yKnjm3yv8r
    payloadCondition: {{ trigger.body.pull_request.comments_url ?? true : false }}
```


Issue comments:
Great idea to add event filters! We'd only need to make it a bit more flexible to allow: 
- filtering for specific header and body properties
- multiple filters e.g. filter on both header and body or multiple body filters

Some examples of how other tools approach it:
- [Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/resources-webhooks-webhook-filters-filter?view=azure-pipelines)
- [Close.com](https://developer.close.com/resources/webhook-subscriptions/webhook-filters/)

Proposed syntax:
```yaml
triggers:
  - id: github
    type: io.kestra.core.models.triggers.types.Webhook
    key: ThisIsaSuperSecretKey42
    conditions:
      - type: io.kestra.core.models.conditions.types.WebhookCondition
        body: "{{ pull_request.comments_url ?? true : false }}"
        headers:
          X-GitHub-Event: pull_request
```
> Great idea to add event filters! We'd only need to make it a bit more flexible to allow:
> 
> * filtering for specific header and body properties
> * multiple filters e.g. filter on both header and body or multiple body filters
> 
> Some examples of how other tools approach it:
> 
> * [Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/resources-webhooks-webhook-filters-filter?view=azure-pipelines)
> * [Close.com](https://developer.close.com/resources/webhook-subscriptions/webhook-filters/)
> 
> Proposed syntax:
> 
> ```yaml
> triggers:
>   - id: github
>     type: io.kestra.core.models.triggers.types.Webhook
>     key: ThisIsaSuperSecretKey42
>     conditions:
>       - type: io.kestra.core.models.conditions.types.WebhookCondition
>         body: "{{ pull_request.comments_url ?? true : false }}"
>         headers:
>           X-GitHub-Event: pull_request
> ```

Thanks for the details ! I will try to provide a PR for this 👍 
@aballiet just talked to the team and this will be a little bit more complex to handle. @loicmathieu said he will help with that

the planned syntax is to use already existing variable condition:
```yaml
id: webhook_test
namespace: prod
tasks:
  - id: hello
    type: io.kestra.core.tasks.log.Log
    message: "{{ trigger.body }}"

triggers:
  - id: eventBridge
    type: io.kestra.core.models.triggers.types.Webhook
    key: ThisIsaSuperSecretKey42
    conditions:
      - type: io.kestra.core.models.conditions.types.VariableCondition
        expression: "{{ trigger.body.github.pull_request.comments_url != null }}"
      - type: io.kestra.core.models.conditions.types.VariableCondition
        expression: "{{ trigger.headers.X-GitHub-Event.0 == 'pull_request' }}"
```

Pull request review comments:
```suggestion
        Webhook trigger allows you to create a unique URL that you can use to trigger a Kestra flow execution based on a presence of events in another application such as GitHub or Amazon EventBridge. In order to use that URL, you have to add a secret key that will secure your webhook URL. 
        
        The URL will then follow the following format: `https://{your_hostname}/api/v1/executions/webhook/{namespace}/{flowId}/{key}`. Replace the templated values accordingly to your workflow setup.
        
        The webhook URL accepts `GET`, `POST` and `PUT` requests.

        You can access the request body and headers sent by another application using the following template variables:
        - `{{ trigger.body }}`
        - `{{ trigger.headers }}`.

        The webhook response will be one of the following HTTP status codes:
        - 404 if the namespace, flow or webhook key is not found
        - 200 if the webhook triggers an execution
        - 204 if the webhook cannot trigger an execution due to a lack of matching event conditions sent by other application."""
```
```suggestion
                Add a trigger matching specific webhook event condition. The flow will be executed only if the condition is met.`.
```
```suggestion
            "Make sure to keep the webhook key secure. It's the only security mechanism to protect your endpoint from bad actors, and must be considered as a secret. You can use a random key generator to create the key.\n" +
```
```suggestion
namespace: dev
```

```suggestion
    format: "{{ trigger | json }}"
```
nitpick
We always use this namesapce in our tests, this is not something visible to users
We always use this namesapce in our tests, this is not something visible to users

Patch that resolved the issue:
diff --git a/core/src/main/java/io/kestra/core/models/triggers/types/Webhook.java b/core/src/main/java/io/kestra/core/models/triggers/types/Webhook.java
index 1c044ae58f..4440bc3c67 100644
--- a/core/src/main/java/io/kestra/core/models/triggers/types/Webhook.java
+++ b/core/src/main/java/io/kestra/core/models/triggers/types/Webhook.java
@@ -31,12 +31,21 @@
 @NoArgsConstructor
 @Schema(
     title = "Trigger a flow from a webhook",
-    description = "Webhook trigger allow you to trigger a flow from a webhook url. " +
-        "The trigger will generate a `key` that must be used on url : `/api/v1/executions/webhook/{namespace}/[flowId]/{key}`. " +
-        "Kestra accept `GET`, `POST` & `PUT` request on this url. " +
-        "The whole body & headers will be available as variable:\n " +
-        "- `trigger.body`\n" +
-        "- `trigger.headers`"
+    description = """
+        Webhook trigger allows you to create a unique URL that you can use to trigger a Kestra flow execution based on a presence of events in another application such as GitHub or Amazon EventBridge. In order to use that URL, you have to add a secret key that will secure your webhook URL. 
+        
+        The URL will then follow the following format: `https://{your_hostname}/api/v1/executions/webhook/{namespace}/{flowId}/{key}`. Replace the templated values accordingly to your workflow setup.
+        
+        The webhook URL accepts `GET`, `POST` and `PUT` requests.
+
+        You can access the request body and headers sent by another application using the following template variables:
+        - `{{ trigger.body }}`
+        - `{{ trigger.headers }}`.
+
+        The webhook response will be one of the following HTTP status codes:
+        - 404 if the namespace, flow or webhook key is not found
+        - 200 if the webhook triggers an execution
+        - 204 if the webhook cannot trigger an execution due to a lack of matching event conditions sent by other application."""
 )
 @Plugin(
     examples = {
@@ -49,6 +58,21 @@
                 "    key: 4wjtkzwVGBM9yKnjm3yv8r"
             },
             full = true
+        ),
+        @Example(
+            title = """
+                Add a trigger matching specific webhook event condition. The flow will be executed only if the condition is met.`.
+                """,
+            code = {
+                "triggers:",
+                "  - id: webhook",
+                "    type: io.kestra.core.models.triggers.types.Webhook",
+                "    key: 4wjtkzwVGBM9yKnjm3yv8r",
+                "    conditions:",
+                "      - type: io.kestra.core.models.conditions.types.VariableCondition",
+                "        expression: \"{{ trigger.body.hello == 'world' }}\""
+            },
+            full = true
         )
     }
 )
@@ -60,7 +84,7 @@ public class Webhook extends AbstractTrigger implements TriggerOutput<Webhook.Ou
         description = "The key is used for generating the url of the webhook.\n" +
             "\n" +
             "::alert{type=\"warning\"}\n" +
-            "Take care when using manual key, the key is the only security to protect your webhook and must be considered as a secret !\n" +
+            "Make sure to keep the webhook key secure. It's the only security mechanism to protect your endpoint from bad actors, and must be considered as a secret. You can use a random key generator to create the key.\n" +
             "::\n"
     )
     @PluginProperty(dynamic = true)
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
index 9a74902a99..be2d489b47 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/ExecutionController.java
@@ -15,6 +15,7 @@
 import io.kestra.core.models.storage.FileMetas;
 import io.kestra.core.models.tasks.Task;
 import io.kestra.core.models.triggers.AbstractTrigger;
+import io.kestra.core.models.triggers.multipleflows.MultipleConditionStorageInterface;
 import io.kestra.core.models.triggers.types.Webhook;
 import io.kestra.core.models.validations.ManualConstraintViolation;
 import io.kestra.core.queues.QueueFactoryInterface;
@@ -129,6 +130,9 @@ public class ExecutionController {
     @Inject
     private TenantService tenantService;
 
+    @Inject
+    private MultipleConditionStorageInterface multipleConditionStorageInterface;
+
     @ExecuteOn(TaskExecutors.IO)
     @Get(uri = "/search", produces = MediaType.TEXT_JSON)
     @Operation(tags = {"Executions"}, summary = "Search for executions")
@@ -343,7 +347,7 @@ public PagedResults<Execution> findByFlowId(
     @ExecuteOn(TaskExecutors.IO)
     @Post(uri = "/webhook/{namespace}/{id}/{key}", produces = MediaType.TEXT_JSON)
     @Operation(tags = {"Executions"}, summary = "Trigger a new execution by POST webhook trigger")
-    public Execution webhookTriggerPost(
+    public HttpResponse<Execution> webhookTriggerPost(
         @Parameter(description = "The flow namespace") @PathVariable String namespace,
         @Parameter(description = "The flow id") @PathVariable String id,
         @Parameter(description = "The webhook trigger uid") @PathVariable String key,
@@ -355,7 +359,7 @@ public Execution webhookTriggerPost(
     @ExecuteOn(TaskExecutors.IO)
     @Get(uri = "/webhook/{namespace}/{id}/{key}", produces = MediaType.TEXT_JSON)
     @Operation(tags = {"Executions"}, summary = "Trigger a new execution by GET webhook trigger")
-    public Execution webhookTriggerGet(
+    public HttpResponse<Execution> webhookTriggerGet(
         @Parameter(description = "The flow namespace") @PathVariable String namespace,
         @Parameter(description = "The flow id") @PathVariable String id,
         @Parameter(description = "The webhook trigger uid") @PathVariable String key,
@@ -367,7 +371,7 @@ public Execution webhookTriggerGet(
     @ExecuteOn(TaskExecutors.IO)
     @Put(uri = "/webhook/{namespace}/{id}/{key}", produces = MediaType.TEXT_JSON)
     @Operation(tags = {"Executions"}, summary = "Trigger a new execution by PUT webhook trigger")
-    public Execution webhookTriggerPut(
+    public HttpResponse<Execution> webhookTriggerPut(
         @Parameter(description = "The flow namespace") @PathVariable String namespace,
         @Parameter(description = "The flow id") @PathVariable String id,
         @Parameter(description = "The webhook trigger uid") @PathVariable String key,
@@ -376,7 +380,7 @@ public Execution webhookTriggerPut(
         return this.webhook(namespace, id, key, request);
     }
 
-    private Execution webhook(
+    private HttpResponse<Execution> webhook(
         String namespace,
         String id,
         String key,
@@ -384,7 +388,7 @@ private Execution webhook(
     ) {
         Optional<Flow> find = flowRepository.findById(tenantService.resolveTenant(), namespace, id);
         if (find.isEmpty()) {
-            return null;
+            return HttpResponse.notFound();
         }
 
         var flow = find.get();
@@ -414,23 +418,29 @@ private Execution webhook(
             .findFirst();
 
         if (webhook.isEmpty()) {
-            return null;
+            return HttpResponse.notFound();
         }
 
         Optional<Execution> execution = webhook.get().evaluate(request, flow);
 
         if (execution.isEmpty()) {
-            return null;
+            return HttpResponse.notFound();
         }
 
         var result = execution.get();
         if (flow.getLabels() != null) {
             result = result.withLabels(flow.getLabels());
         }
+
+        // we check conditions here as it's easier as the execution is created we have the body and headers available for the runContext
+        if (!conditionService.isValid(webhook.get(), flow, result, multipleConditionStorageInterface)) {
+            return HttpResponse.noContent();
+        }
+
         executionQueue.emit(result);
         eventPublisher.publishEvent(new CrudEvent<>(result, CrudEventType.CREATE));
 
-        return result;
+        return HttpResponse.ok(result);
     }
 
     @ExecuteOn(TaskExecutors.IO)


Test patch:
diff --git a/core/src/test/resources/flows/valids/webhook-with-condition.yaml b/core/src/test/resources/flows/valids/webhook-with-condition.yaml
new file mode 100644
index 0000000000..08de8f3668
--- /dev/null
+++ b/core/src/test/resources/flows/valids/webhook-with-condition.yaml
@@ -0,0 +1,15 @@
+id: webhook-with-condition
+namespace: io.kestra.tests
+
+tasks:
+  - id: out
+    type: io.kestra.core.tasks.debugs.Return
+    format: "{{ trigger | json }}"
+
+triggers:
+  - id: webhook
+    type: io.kestra.core.models.triggers.types.Webhook
+    key: webhookKey
+    conditions:
+      - type: io.kestra.core.models.conditions.types.VariableCondition
+        expression: "{{trigger.body.hello == 'world'}}"
\ No newline at end of file
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
index aefdd32994..43a1af7131 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/ExecutionControllerTest.java
@@ -542,6 +542,34 @@ void webhookDynamicKeyFromASecret() {
         assertThat(execution.getId(), notNullValue());
     }
 
+    @Test
+    void webhookWithCondition() {
+        record Hello(String hello) {}
+
+        Execution execution = client.toBlocking().retrieve(
+            HttpRequest
+                .POST(
+                    "/api/v1/executions/webhook/" + TESTS_FLOW_NS + "/webhook-with-condition/webhookKey",
+                    new Hello("world")
+                ),
+            Execution.class
+        );
+
+        assertThat(execution, notNullValue());
+        assertThat(execution.getId(), notNullValue());
+
+        HttpResponse<Execution> response = client.toBlocking().exchange(
+            HttpRequest
+                .POST(
+                    "/api/v1/executions/webhook/" + TESTS_FLOW_NS + "/webhook-with-condition/webhookKey",
+                    new Hello("webhook")
+                ),
+            Execution.class
+        );
+        assertThat(response.getStatus(), is(HttpStatus.NO_CONTENT));
+        assertThat(response.body(), nullValue());
+    }
+
     @Test
     void resumePaused() throws TimeoutException, InterruptedException {
         // Run execution until it is paused