Repository: featbit/featbit (language: python)

Issue:
Feature Suggestion: Limit Webhooks to Specific Feature Flags
**Is your feature request related to a problem? Please describe.**
My team uses a [Webhook](https://docs.featbit.co/integrations/webhooks) to send notifications to a Teams channel when only a _specific_ feature flag is toggled. Note that Webhooks fire for _all_ matching feature flag events and segments.

The only way I was able to implement this was by making clever use of Handlebars.js templating to only send an Adaptive Card body if `data.object.key` was the desired feature flag key, and an empty JSON body otherwise:
```
{
   {{#eq data.object.key "desired-key"}}
   // Desired Adaptive Card body goes here
   {{/eq}}
}
```

The empty JSON body is required, since this body must be valid JSON, or the Webhook form will not submit.
* [Frontend requirement](https://github.com/featbit/featbit/blob/065f9d040853fd66f16589509ef5793948acea17/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.ts#L132-L138)
* [Backend requirement](https://github.com/featbit/featbit/blob/065f9d040853fd66f16589509ef5793948acea17/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs#L40C13-L40C41)

When the empty JSON body is sent, the webhook returns a 400-level response (due to it not being a valid Adaptive Card), and no notification is sent, thus having the desired behavior.

However, due to the [impending MS Teams Connectors deprecation](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/), we have had to change how we send these notifications.
Our new in-house solution does not behave the same with the empty body and will send a "blank" notification in this case, and will otherwise send some kind of notification _no matter what body it receives_. This means that it would spam our Teams channel with a notification whenever _any_ feature flag was enabled/disabled. Due to internal requirements, this behavior _will not be changed_, meaning my previous templating-based solution will not work. 

There needs to be some way to limit Webhooks from firing for some feature flags on the FeatBit side that doesn't involve creating extra notification-only environments (which would cause clutter and confusion and has been deemed as an unacceptable solution by my team).

**Describe the solution you'd like**
I can think of a few that would be acceptable, listed in no particular order:
1. **Add an optional "Feature Flags" chips/multi-select input under the "Scopes" section of the Webhook editing pane**
- This naturally extends the existing "Project" and "Environments" that are already there.
- Downside is that the UI might not look great/clear without significant restyling, as this is already a narrow pane and each Scope takes a full line.
- Unclear if a whitelist or blacklist or both would be clear/appropriate.
- This feature seems to exist in other feature flag systems.


2. **Add an optional whitelist and/or blacklist text field for matching on the key for a Feature Flag or Segment**
- Probably should only be enabled if a Feature Flag or Segment event is selected, respectively.
- Unclear if this should accept any of the following:
   - A delimited list of hard-coded keys like `some-key; another-key`
   - A Regular Expression (which flavor?) like `prefix-.*`
   - A Glob pattern like `prefix-*`
   - A search string (to be used in the [already existing key/name filtering](https://github.com/featbit/featbit/blob/065f9d040853fd66f16589509ef5793948acea17/modules/back-end/src/Infrastructure/FeatureFlags/FeatureFlagService.cs#L25-L34%5c))


3. **Remove [the valid JSON requirement](https://github.com/featbit/featbit/blob/065f9d040853fd66f16589509ef5793948acea17/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.ts#L132-L138) for the Payload Template**
- Similar requirement on [the backend](https://github.com/featbit/featbit/blob/065f9d040853fd66f16589509ef5793948acea17/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs#L40C13-L40C41).
- This should still be okay, as Handlebars.js should still allow you to make a malformed body if you so choose, though a warning could be handy to catch mistakes where valid JSON is expected.
- Then you could produce an empty body via Handlebars.js like this for a non-desired key:

```
{{#eq data.object.key "desired-key"}}
// Desired body goes here
{{/eq}}
```

- This would also require FeatBit to not send a request if it was empty, and this would probably need to be done via an additional checkbox so users can control this behavior.
- This means that something in the Featbit Backend ([inside here ](https://github.com/featbit/featbit/blob/065f9d040853fd66f16589509ef5793948acea17/modules/back-end/src/Api/Controllers/WebhookController.cs#L66-L71) somewhere? Or [here](https://github.com/featbit/featbit/blob/065f9d040853fd66f16589509ef5793948acea17/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs#L31)?) would have to be changed too.

**Describe alternatives you've considered**
See the above discussion on different solution ideas.
I am of course open to other ideas, provided they do not require significantly altering how we organize or otherwise use FeatBit.

**Additional context**
If this cannot be put into the GitHub FeatBit repository in a reasonable time, I may be forced to implement one of these solutions in our internal fork of the repo so we can use it sooner to comply with the MS Teams deprecation deadline.

**Is this feature something you're interested in working on?**
Yes! I am available to work on this for the next couple of weeks.


Discussion / hints:
Hi @drewsteinacher , thanks for your input!

Option 1 is more complete but also more complex to implement. For selecting the scopes, we actually already have a resource finder (PR #620)  for this scenario, it's a dialog and looks like this

![image](https://github.com/user-attachments/assets/6ebe1568-ecd8-4ff1-bea4-9ed6bba9017b)

Compare option 3 with 2, I prefer 3 because it's easier to implement and more general, though a little bit hacky. But I don't think we need to remove the validation; we can just allow an empty JSON `{}` and the user can decide whether to send the payload if it's an empty JSON as you said.
Thanks @deleteLater! It looks like `resource-finder` should greatly simplify Option 1. I will investigate more deeply soon.

Regarding the other options: Would it be acceptable to implement your proposal to allow an empty JSON object via a new checkbox, and then perhaps at a later time implement Option 1 with `resource-finder`? Or would you prefer to only have Option 1 implemented long-term?

Fix patch:
diff --git a/modules/back-end/src/Application/Webhooks/CreateWebhook.cs b/modules/back-end/src/Application/Webhooks/CreateWebhook.cs
index 051a8b4ba..029104dac 100644
--- a/modules/back-end/src/Application/Webhooks/CreateWebhook.cs
+++ b/modules/back-end/src/Application/Webhooks/CreateWebhook.cs
@@ -11,7 +11,7 @@ public class CreateWebhook : WebhookBase, IRequest<WebhookVm>
 
     public Webhook AsWebhook(Guid creatorId)
     {
-        var webhook = new Webhook(OrgId, Name, Scopes, Url, Events, Headers, PayloadTemplateType, PayloadTemplate, Secret, IsActive, creatorId);
+        var webhook = new Webhook(OrgId, Name, Scopes, Url, Events, Headers, PayloadTemplateType, PayloadTemplate, Secret, IsActive, PreventEmptyPayloads, creatorId);
         return webhook;
     }
 }
diff --git a/modules/back-end/src/Application/Webhooks/SendWebhook.cs b/modules/back-end/src/Application/Webhooks/SendWebhook.cs
index d9ea435f7..0abd3f57e 100644
--- a/modules/back-end/src/Application/Webhooks/SendWebhook.cs
+++ b/modules/back-end/src/Application/Webhooks/SendWebhook.cs
@@ -1,4 +1,6 @@
+using System.Text.Json;
 using Application.Bases;
+using Domain.Utils;
 using Domain.Webhooks;
 
 namespace Application.Webhooks;
@@ -21,7 +23,19 @@ public SendWebhookValidator()
             .NotEmpty().WithErrorCode(ErrorCodes.Required("events"));
 
         RuleFor(x => x.Payload)
-            .NotEmpty().WithErrorCode(ErrorCodes.Required("payload"));
+            .NotEmpty().WithErrorCode(ErrorCodes.Required("payload"))
+            .Must(x =>
+            {
+                try
+                {
+                    JsonDocument.Parse(x);
+                    return true;
+                }
+                catch (JsonException)
+                {
+                    return false;
+                }
+            }).WithErrorCode(ErrorCodes.Invalid("payload"));
     }
 }
 
@@ -36,6 +50,12 @@ public SendWebhookHandler(IWebhookSender webhookSender)
 
     public async Task<WebhookDelivery> Handle(SendWebhook request, CancellationToken cancellationToken)
     {
+        var json = JsonDocument.Parse(request.Payload);
+        if (request.PreventEmptyPayloads && json.RootElement.IsEmptyObject())
+        {
+            return WebhookDelivery.Ignored("Not allowed to send an empty JSON object", request.Url, request.Payload);
+        }
+
         var delivery = await _webhookSender.SendAsync(request);
         return delivery;
     }
diff --git a/modules/back-end/src/Application/Webhooks/UpdateWebhook.cs b/modules/back-end/src/Application/Webhooks/UpdateWebhook.cs
index 4c630e9ee..6f81c2967 100644
--- a/modules/back-end/src/Application/Webhooks/UpdateWebhook.cs
+++ b/modules/back-end/src/Application/Webhooks/UpdateWebhook.cs
@@ -41,6 +41,7 @@ public async Task<WebhookVm> Handle(UpdateWebhook request, CancellationToken can
             request.PayloadTemplate,
             request.Secret,
             request.IsActive,
+            request.PreventEmptyPayloads,
             _currentUser.Id
         );
 
diff --git a/modules/back-end/src/Application/Webhooks/WebhookBase.cs b/modules/back-end/src/Application/Webhooks/WebhookBase.cs
index 8bc5a43e9..ebcf6ff95 100644
--- a/modules/back-end/src/Application/Webhooks/WebhookBase.cs
+++ b/modules/back-end/src/Application/Webhooks/WebhookBase.cs
@@ -22,6 +22,8 @@ public class WebhookBase
     public string Secret { get; set; }
 
     public bool IsActive { get; set; }
+
+    public bool PreventEmptyPayloads { get; set; }
 }
 
 public class WebhookBaseValidator : AbstractValidator<WebhookBase>
diff --git a/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs b/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs
index 079fc11b7..103492da4 100644
--- a/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs
+++ b/modules/back-end/src/Application/Webhooks/WebhookHandlers.cs
@@ -89,13 +89,7 @@ public async Task HandleAsync(FeatureFlag flag, DataChange dataChange, Guid oper
             .AddFeatureFlag(flag)
             .AddChanges(changes);
 
-        foreach (var webhook in availableWebhooks)
-        {
-            var delivery = await _webhookSender.SendAsync(webhook, dataObject);
-
-            webhook.LastDelivery = new LastDelivery(delivery);
-            await _webhookService.UpdateAsync(webhook);
-        }
+        await SendWebhooksAsync(availableWebhooks, dataObject);
     }
 
     public async Task HandleAsync(Segment segment, DataChange dataChange, Guid operatorId)
@@ -152,9 +146,18 @@ public async Task HandleAsync(Segment segment, DataChange dataChange, Guid opera
             .AddSegment(segment, flagReferences)
             .AddChanges(changes);
 
-        foreach (var webhook in availableWebhooks)
+        await SendWebhooksAsync(availableWebhooks, dataObject);
+    }
+
+    private async Task SendWebhooksAsync(Webhook[] webhooks, Dictionary<string, object> dataObject)
+    {
+        foreach (var webhook in webhooks)
         {
             var delivery = await _webhookSender.SendAsync(webhook, dataObject);
+            if (delivery.IsIgnored())
+            {
+                continue;
+            }
 
             webhook.LastDelivery = new LastDelivery(delivery);
             await _webhookService.UpdateAsync(webhook);
diff --git a/modules/back-end/src/Application/Webhooks/WebhookVm.cs b/modules/back-end/src/Application/Webhooks/WebhookVm.cs
index 965153acb..1cb33e42b 100644
--- a/modules/back-end/src/Application/Webhooks/WebhookVm.cs
+++ b/modules/back-end/src/Application/Webhooks/WebhookVm.cs
@@ -27,6 +27,8 @@ public class WebhookVm
 
     public bool IsActive { get; set; }
 
+    public bool PreventEmptyPayloads { get; set; }
+
     public LastDelivery LastDelivery { get; set; }
 
     public UserVm Creator { get; set; }
diff --git a/modules/back-end/src/Domain/Utils/JsonExtensions.cs b/modules/back-end/src/Domain/Utils/JsonExtensions.cs
new file mode 100644
index 000000000..aafe09b6e
--- /dev/null
+++ b/modules/back-end/src/Domain/Utils/JsonExtensions.cs
@@ -0,0 +1,9 @@
+using System.Text.Json;
+
+namespace Domain.Utils;
+
+public static class JsonExtensions
+{
+    public static bool IsEmptyObject(this JsonElement json)
+        => json.ValueKind == JsonValueKind.Object && !json.EnumerateObject().Any();
+}
\ No newline at end of file
diff --git a/modules/back-end/src/Domain/Webhooks/Webhook.cs b/modules/back-end/src/Domain/Webhooks/Webhook.cs
index 7161c1d91..2cde11f7e 100644
--- a/modules/back-end/src/Domain/Webhooks/Webhook.cs
+++ b/modules/back-end/src/Domain/Webhooks/Webhook.cs
@@ -21,6 +21,8 @@ public class Webhook : FullAuditedEntity
     public string Secret { get; set; }
 
     public bool IsActive { get; set; }
+    
+    public bool PreventEmptyPayloads { get; set; }
 
     public LastDelivery LastDelivery { get; set; }
 
@@ -35,6 +37,7 @@ public Webhook(
         string payloadTemplate,
         string secret,
         bool isActive,
+        bool preventEmptyPayloads,
         Guid creatorId) : base(creatorId)
     {
         OrgId = orgId;
@@ -50,6 +53,7 @@ public Webhook(
         Secret = secret ?? string.Empty;
 
         IsActive = isActive;
+        PreventEmptyPayloads = preventEmptyPayloads;
         LastDelivery = null;
     }
 
@@ -63,6 +67,7 @@ public void Update(
         string payloadTemplate,
         string secret,
         bool isActive,
+        bool preventEmptyPayloads,
         Guid currentUserId)
     {
         Name = name;
@@ -77,6 +82,7 @@ public void Update(
         Secret = secret ?? string.Empty;
 
         IsActive = isActive;
+        PreventEmptyPayloads = preventEmptyPayloads;
 
         MarkAsUpdated(currentUserId);
     }
diff --git a/modules/back-end/src/Domain/Webhooks/WebhookDelivery.cs b/modules/back-end/src/Domain/Webhooks/WebhookDelivery.cs
index ecb14d871..982908fc3 100644
--- a/modules/back-end/src/Domain/Webhooks/WebhookDelivery.cs
+++ b/modules/back-end/src/Domain/Webhooks/WebhookDelivery.cs
@@ -65,4 +65,19 @@ public void SetError(object error)
         Error = error;
         Success = false;
     }
+
+    public bool IsIgnored() => WebhookId == Guid.Empty;
+
+    public static WebhookDelivery Ignored(string reason, string url, string payload) =>
+        new(Guid.Empty, string.Empty)
+        {
+            Request = new
+            {
+                url,
+                headers = new { },
+                payload
+            },
+            Success = false,
+            Error = new { message = reason }
+        };
 }
\ No newline at end of file
diff --git a/modules/back-end/src/Domain/Webhooks/WebhookRequest.cs b/modules/back-end/src/Domain/Webhooks/WebhookRequest.cs
index afc9e606c..c108ec362 100644
--- a/modules/back-end/src/Domain/Webhooks/WebhookRequest.cs
+++ b/modules/back-end/src/Domain/Webhooks/WebhookRequest.cs
@@ -18,6 +18,8 @@ public class WebhookRequest
 
     public string Payload { get; set; }
 
+    public bool PreventEmptyPayloads { get; set; }
+
     public WebhookRequest()
     {
     }
@@ -36,5 +38,6 @@ public WebhookRequest(
         Headers = webhook.Headers;
         Events = events;
         Payload = payload;
+        PreventEmptyPayloads = webhook.PreventEmptyPayloads;
     }
 }
\ No newline at end of file
diff --git a/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs b/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs
index 845f59592..006545a8f 100644
--- a/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs
+++ b/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs
@@ -6,6 +6,7 @@
 using Domain.Webhooks;
 using HandlebarsDotNet;
 using Microsoft.Extensions.Logging;
+using Domain.Utils;
 
 namespace Infrastructure.Webhooks;
 
@@ -33,11 +34,12 @@ public async Task<WebhookDelivery> SendAsync(Webhook webhook, Dictionary<string,
         var events = dataObject["events"].ToString()!;
 
         string payload;
+        JsonDocument jsonDocument;
         try
         {
             var template = Handlebars.Compile(webhook.PayloadTemplate);
             payload = template(dataObject);
-            JsonDocument.Parse(payload);
+            jsonDocument = JsonDocument.Parse(payload);
         }
         catch (Exception ex)
         {
@@ -55,6 +57,11 @@ public async Task<WebhookDelivery> SendAsync(Webhook webhook, Dictionary<string,
             return delivery;
         }
 
+        if (webhook.PreventEmptyPayloads && jsonDocument.RootElement.IsEmptyObject())
+        {
+            return WebhookDelivery.Ignored("Not allowed to send an empty JSON object", webhook.Url, payload);
+        }
+
         var deliveryId = Guid.NewGuid().ToString("D");
         WebhookDelivery lastDelivery = null!;
         for (var attempt = 0; attempt < _maxAttempts; attempt++)
diff --git a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html
index 9808c30e0..9999275f6 100644
--- a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html
+++ b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.html
@@ -176,6 +176,14 @@
         </nz-form-control>
       </nz-form-item>
 
+      <!-- Prevent Empty Payloads -->
+      <nz-form-item>
+        <label nz-checkbox formControlName="preventEmptyPayloads" i18n="@@integrations.webhooks.prevent-empty-payloads">Prevent Empty Payloads</label>
+        <nz-form-control>
+          <nz-form-text i18n="@@integrations.webhooks.prevent-empty-payloads-description">Prevents sending a request if the payload is an empty JSON object.</nz-form-text>
+        </nz-form-control>
+      </nz-form-item>
+
       <!-- Secret -->
       <nz-form-item>
         <nz-form-label i18n="@@integrations.webhooks.secret">Secret</nz-form-label>
diff --git a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less
index 715847909..1e8cfb070 100644
--- a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less
+++ b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.less
@@ -77,7 +77,6 @@
 }
 
 .select-payload-template {
-  margin-left: 10px;
   margin-bottom: 8px;
 
   .title {
@@ -115,4 +114,4 @@
 
 .align-end {
   text-align: end;
-}
\ No newline at end of file
+}
diff --git a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.ts b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.ts
index 8e0b90705..08bc7efd4 100644
--- a/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.ts
+++ b/modules/front-end/src/app/core/components/webhook-drawer/webhook-drawer.component.ts
@@ -39,6 +39,7 @@ export class WebhookDrawerComponent implements OnInit {
     payloadTemplate: FormControl<string>;
     secret: FormControl<string>;
     isActive: FormControl<boolean>;
+    preventEmptyPayloads: FormControl<boolean>;
   }>;
 
   constructor(
@@ -85,7 +86,8 @@ export class WebhookDrawerComponent implements OnInit {
       payloadTemplateType: new FormControl(this._webhook?.payloadTemplateType ?? 'default'),
       payloadTemplate: new FormControl(this._webhook?.payloadTemplate ?? WebhookDefaultPayloadTemplate, [this.jsonHandlebarsTemplateValidator]),
       secret: new FormControl(this._webhook?.secret),
-      isActive: new FormControl(this._webhook?.isActive ?? true)
+      isActive: new FormControl(this._webhook?.isActive ?? true),
+      preventEmptyPayloads: new FormControl(this._webhook?.preventEmptyPayloads ?? false),
     });
 
     if (this._webhook) {
@@ -345,7 +347,7 @@ export class WebhookDrawerComponent implements OnInit {
   testWebhook: TestWebhook = null;
   testModalVisible: boolean = false;
   openTestModal() {
-    const { name, url, payloadTemplate, headers, secret } = this.form.value;
+    const { name, url, payloadTemplate, headers, secret, preventEmptyPayloads } = this.form.value;
     this.testWebhook = {
       id: uuidv4(),
       name,
@@ -354,7 +356,8 @@ export class WebhookDrawerComponent implements OnInit {
         .filter(header => header.key)
         .map(header => ({ key: header.key, value: header.value })),
       payloadTemplate,
-      secret
+      secret,
+      preventEmptyPayloads
     };
 
     this.testModalVisible = true;
@@ -366,7 +369,7 @@ export class WebhookDrawerComponent implements OnInit {
   }
 
   doSubmit() {
-    const { name, url, scopes, events, headers, payloadTemplateType, payloadTemplate, secret, isActive } = this.form.value;
+    const { name, url, scopes, events, headers, payloadTemplateType, payloadTemplate, secret, isActive, preventEmptyPayloads } = this.form.value;
     const payload = {
       name,
       url,
@@ -380,7 +383,8 @@ export class WebhookDrawerComponent implements OnInit {
       payloadTemplateType,
       payloadTemplate,
       secret,
-      isActive
+      isActive,
+      preventEmptyPayloads,
     };
 
     let responseHandler = {
diff --git a/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts b/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts
index eb417001e..3b197a10d 100644
--- a/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts
+++ b/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.ts
@@ -12,6 +12,7 @@ export interface Webhook {
   payloadTemplateType: string;
   payloadTemplate: string;
   isActive: boolean;
+  preventEmptyPayloads: boolean;
   creator: SimpleUser;
   lastDelivery?: LastDelivery;
 }
@@ -160,7 +161,7 @@ export interface PagedWebhookDelivery {
   items: WebhookDelivery[];
 }
 
-export type TestWebhook = Pick<Webhook, 'id' | 'url' | 'name' | 'secret' | 'headers' | 'payloadTemplate'>;
+export type TestWebhook = Pick<Webhook, 'id' | 'url' | 'name' | 'secret' | 'headers' | 'payloadTemplate' | 'preventEmptyPayloads'>;
 
 export interface WebhookRequest {
   id: string;
@@ -171,4 +172,5 @@ export interface WebhookRequest {
   headers: { key: string; value: string; }[];
   events: string;
   payload: string;
-}
\ No newline at end of file
+  preventEmptyPayloads: boolean;
+}
diff --git a/modules/front-end/src/locale/messages.xlf b/modules/front-end/src/locale/messages.xlf
index 7fc9bc141..d0882f970 100644
--- a/modules/front-end/src/locale/messages.xlf
+++ b/modules/front-end/src/locale/messages.xlf
@@ -455,7 +455,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">221</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/setting/setting.component.html</context>
@@ -615,11 +615,11 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts</context>
-          <context context-type="linenumber">65</context>
+          <context context-type="linenumber">66</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">388</context>
+          <context context-type="linenumber">392</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/experiments/metrics/metrics.component.ts</context>
@@ -842,11 +842,11 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts</context>
-          <context context-type="linenumber">67</context>
+          <context context-type="linenumber">68</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">391</context>
+          <context context-type="linenumber">395</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/experimentation/experimentation.component.ts</context>
@@ -908,7 +908,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/header/header.component.ts</context>
-          <context context-type="linenumber">165</context>
+          <context context-type="linenumber">171</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/prism/prism.component.ts</context>
@@ -2083,7 +2083,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/header/header.component.ts</context>
-          <context context-type="linenumber">126</context>
+          <context context-type="linenumber">132</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/metric-drawer/metric-drawer.component.ts</context>
@@ -5390,39 +5390,53 @@
           <context context-type="linenumber">169</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="integrations.webhooks.prevent-empty-payloads" datatype="html">
+        <source>Prevent Empty Payloads</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
+          <context context-type="linenumber">181</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.prevent-empty-payloads-description" datatype="html">
+        <source>Prevents sending a request if the payload is an empty JSON object.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
+          <context context-type="linenumber">183</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="integrations.webhooks.secret" datatype="html">
         <source>Secret</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">181</context>
+          <context context-type="linenumber">189</context>
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.secret-description" datatype="html">
         <source>Optionally, you can use a webhook secret to verify that a webhook delivery is from FeatBit.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">183</context>
+          <context context-type="linenumber">191</context>
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.active" datatype="html">
         <source>Active</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">191</context>
+          <context context-type="linenumber">199</context>
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.active-description" datatype="html">
         <source>We will deliver event details when this hook is triggered.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">193</context>
+          <context context-type="linenumber">201</context>
         </context-group>
       </trans-unit>
       <trans-unit id="common.live-debug" datatype="html">
         <source>Live debug</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">199</context>
+          <context context-type="linenumber">207</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
@@ -5433,28 +5447,28 @@
         <source>Optionally, you can test your webhook configuration before saving it.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">201</context>
+          <context context-type="linenumber">209</context>
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.live-debug-webhook-configuration" datatype="html">
         <source>Live debug webhook configuration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">205</context>
+          <context context-type="linenumber">213</context>
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.webhook-drawer.edit-title" datatype="html">
         <source>Edit Webhook</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">331</context>
+          <context context-type="linenumber">333</context>
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.webhook-drawer.add-title" datatype="html">
         <source>Add Webhook</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">332</context>
+          <context context-type="linenumber">334</context>
         </context-group>
       </trans-unit>
       <trans-unit id="permissions.no-permissions-to-visit-access-token-page" datatype="html">
diff --git a/modules/front-end/src/locale/messages.zh.xlf b/modules/front-end/src/locale/messages.zh.xlf
index 493614a95..cc2b09cc4 100644
--- a/modules/front-end/src/locale/messages.zh.xlf
+++ b/modules/front-end/src/locale/messages.zh.xlf
@@ -462,7 +462,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">221</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/setting/setting.component.html</context>
@@ -630,11 +630,11 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts</context>
-          <context context-type="linenumber">65</context>
+          <context context-type="linenumber">66</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">388</context>
+          <context context-type="linenumber">392</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/experiments/metrics/metrics.component.ts</context>
@@ -858,11 +858,11 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts</context>
-          <context context-type="linenumber">67</context>
+          <context context-type="linenumber">68</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">391</context>
+          <context context-type="linenumber">395</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/experimentation/experimentation.component.ts</context>
@@ -926,7 +926,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/header/header.component.ts</context>
-          <context context-type="linenumber">165</context>
+          <context context-type="linenumber">171</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/prism/prism.component.ts</context>
@@ -2206,7 +2206,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/header/header.component.ts</context>
-          <context context-type="linenumber">126</context>
+          <context context-type="linenumber">132</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/metric-drawer/metric-drawer.component.ts</context>
@@ -5813,11 +5813,27 @@
         </context-group>
         <target>用于生成 Webhook JSON 请求正文的 Handlebars 模板。</target>
       </trans-unit>
+      <trans-unit id="integrations.webhooks.prevent-empty-payloads" datatype="html">
+        <source>Prevent Empty Payloads</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
+          <context context-type="linenumber">181</context>
+        </context-group>
+        <target>阻止发送空的消息体</target>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.prevent-empty-payloads-description" datatype="html">
+        <source>Prevents sending a request if the payload is an empty JSON object.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
+          <context context-type="linenumber">183</context>
+        </context-group>
+        <target>如果消息体是空的 JSON 对象，则不发送请求。</target>
+      </trans-unit>
       <trans-unit id="integrations.webhooks.secret" datatype="html">
         <source>Secret</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">181</context>
+          <context context-type="linenumber">189</context>
         </context-group>
         <target>秘钥</target>
       </trans-unit>
@@ -5825,7 +5841,7 @@
         <source>Optionally, you can use a webhook secret to verify that a webhook delivery is from FeatBit.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">183</context>
+          <context context-type="linenumber">191</context>
         </context-group>
         <target>（可选）您可以使用改密钥来验证 Webhook 请求是否来自 FeatBit。</target>
       </trans-unit>
@@ -5833,7 +5849,7 @@
         <source>Active</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">191</context>
+          <context context-type="linenumber">199</context>
         </context-group>
         <target>是否启用</target>
       </trans-unit>
@@ -5841,7 +5857,7 @@
         <source>We will deliver event details when this hook is triggered.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">193</context>
+          <context context-type="linenumber">201</context>
         </context-group>
         <target>当触发此 Webhook 时，我们将提供事件详细信息。</target>
       </trans-unit>
@@ -5849,7 +5865,7 @@
         <source>Live debug</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">199</context>
+          <context context-type="linenumber">207</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
@@ -5861,7 +5877,7 @@
         <source>Optionally, you can test your webhook configuration before saving it.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">201</context>
+          <context context-type="linenumber">209</context>
         </context-group>
         <target>（可选）您可以在保存之前测试您的 Webhook 配置。</target>
       </trans-unit>
@@ -5869,7 +5885,7 @@
         <source>Live debug webhook configuration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
-          <context context-type="linenumber">205</context>
+          <context context-type="linenumber">213</context>
         </context-group>
         <target>实时调试 webhook 配置</target>
       </trans-unit>
@@ -5877,7 +5893,7 @@
         <source>Edit Webhook</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">331</context>
+          <context context-type="linenumber">333</context>
         </context-group>
         <target>编辑 Webhook</target>
       </trans-unit>
@@ -5885,7 +5901,7 @@
         <source>Add Webhook</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">332</context>
+          <context context-type="linenumber">334</context>
         </context-group>
         <target>添加 Webhook</target>
       </trans-unit>


Test patch:
diff --git a/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts
index fae083b8b..97bee04cb 100644
--- a/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts
+++ b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts
@@ -54,7 +54,8 @@ export class TestWebhookModalComponent {
       secret: this.webhook.secret,
       headers: this.webhook.headers,
       events: this.event,
-      payload: payload
+      payload: payload,
+      preventEmptyPayloads: this.webhook.preventEmptyPayloads
     };
 
     this.sendSubscription = this.webhookService.send(request)