Repository: featbit/featbit (language: python)

Issue:
✨ feat: webhooks
Allow users to subscribe to flag and segment changes


Discussion / hints:

Fix patch:
diff --git a/modules/back-end/src/Api/Controllers/WebhookController.cs b/modules/back-end/src/Api/Controllers/WebhookController.cs
index 96b3df11e..c690bcc3c 100644
--- a/modules/back-end/src/Api/Controllers/WebhookController.cs
+++ b/modules/back-end/src/Api/Controllers/WebhookController.cs
@@ -1,5 +1,6 @@
 using Application.Bases.Models;
 using Application.Webhooks;
+using Domain.Webhooks;
 
 namespace Api.Controllers;
 
@@ -61,4 +62,11 @@ public async Task<ApiResponse<bool>> DeleteAsync(Guid id)
         var success = await Mediator.Send(request);
         return Ok(success);
     }
+
+    [HttpPost("send")]
+    public async Task<ApiResponse<WebhookDelivery>> SendAsync(SendWebhook request)
+    {
+        var delivery = await Mediator.Send(request);
+        return Ok(delivery);
+    }
 }
\ No newline at end of file
diff --git a/modules/back-end/src/Domain/Webhooks/IWebhookSender.cs b/modules/back-end/src/Application/Services/IWebhookSender.cs
similarity index 52%
rename from modules/back-end/src/Domain/Webhooks/IWebhookSender.cs
rename to modules/back-end/src/Application/Services/IWebhookSender.cs
index d2e90f801..d304b8e18 100644
--- a/modules/back-end/src/Domain/Webhooks/IWebhookSender.cs
+++ b/modules/back-end/src/Application/Services/IWebhookSender.cs
@@ -1,6 +1,10 @@
-namespace Domain.Webhooks;
+using Domain.Webhooks;
+
+namespace Application.Services;
 
 public interface IWebhookSender
 {
     Task<WebhookDelivery> SendAsync(Webhook webhook, Dictionary<string, object> dataObject);
+
+    Task<WebhookDelivery> SendAsync(WebhookRequest request);
 }
\ No newline at end of file
diff --git a/modules/back-end/src/Application/Webhooks/SendWebhook.cs b/modules/back-end/src/Application/Webhooks/SendWebhook.cs
new file mode 100644
index 000000000..d9ea435f7
--- /dev/null
+++ b/modules/back-end/src/Application/Webhooks/SendWebhook.cs
@@ -0,0 +1,42 @@
+using Application.Bases;
+using Domain.Webhooks;
+
+namespace Application.Webhooks;
+
+public class SendWebhook : WebhookRequest, IRequest<WebhookDelivery>
+{
+}
+
+public class SendWebhookValidator : AbstractValidator<SendWebhook>
+{
+    public SendWebhookValidator()
+    {
+        RuleFor(x => x.DeliveryId)
+            .NotEmpty().WithErrorCode(ErrorCodes.Required("deliveryId"));
+
+        RuleFor(x => x.Url)
+            .NotEmpty().WithErrorCode(ErrorCodes.Required("url"));
+
+        RuleFor(x => x.Events)
+            .NotEmpty().WithErrorCode(ErrorCodes.Required("events"));
+
+        RuleFor(x => x.Payload)
+            .NotEmpty().WithErrorCode(ErrorCodes.Required("payload"));
+    }
+}
+
+public class SendWebhookHandler : IRequestHandler<SendWebhook, WebhookDelivery>
+{
+    private readonly IWebhookSender _webhookSender;
+
+    public SendWebhookHandler(IWebhookSender webhookSender)
+    {
+        _webhookSender = webhookSender;
+    }
+
+    public async Task<WebhookDelivery> Handle(SendWebhook request, CancellationToken cancellationToken)
+    {
+        var delivery = await _webhookSender.SendAsync(request);
+        return delivery;
+    }
+}
\ 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
new file mode 100644
index 000000000..afc9e606c
--- /dev/null
+++ b/modules/back-end/src/Domain/Webhooks/WebhookRequest.cs
@@ -0,0 +1,40 @@
+namespace Domain.Webhooks;
+
+public class WebhookRequest
+{
+    public Guid Id { get; set; }
+
+    public string DeliveryId { get; set; }
+
+    public string Url { get; set; }
+
+    public string Name { get; set; }
+
+    public string Secret { get; set; }
+
+    public KeyValuePair<string, string>[] Headers { get; set; }
+
+    public string Events { get; set; }
+
+    public string Payload { get; set; }
+
+    public WebhookRequest()
+    {
+    }
+
+    public WebhookRequest(
+        string deliveryId,
+        Webhook webhook,
+        string events,
+        string payload)
+    {
+        DeliveryId = deliveryId;
+        Id = webhook.Id;
+        Url = webhook.Url;
+        Name = webhook.Name;
+        Secret = webhook.Secret;
+        Headers = webhook.Headers;
+        Events = events;
+        Payload = payload;
+    }
+}
\ No newline at end of file
diff --git a/modules/back-end/src/Infrastructure/ConfigureServices.cs b/modules/back-end/src/Infrastructure/ConfigureServices.cs
index 7c98c502e..a60245bc2 100644
--- a/modules/back-end/src/Infrastructure/ConfigureServices.cs
+++ b/modules/back-end/src/Infrastructure/ConfigureServices.cs
@@ -2,7 +2,6 @@
 using Confluent.Kafka;
 using Domain.Messages;
 using Domain.Users;
-using Domain.Webhooks;
 using Infrastructure.AccessTokens;
 using Infrastructure.Workspaces;
 using Infrastructure.AuditLogs;
diff --git a/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs b/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs
index 287a12870..e65119c8d 100644
--- a/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs
+++ b/modules/back-end/src/Infrastructure/Webhooks/WebhookSender.cs
@@ -16,11 +16,14 @@ public class WebhookSender : IWebhookSender
     private readonly ILogger<WebhookSender> _logger;
 
     private readonly int _maxAttempts = 3;
+    private readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
     private readonly TimeSpan _retryInterval = TimeSpan.FromSeconds(2);
 
     public WebhookSender(HttpClient client, IWebhookService webhookService, ILogger<WebhookSender> logger)
     {
         _client = client;
+        _client.Timeout = _timeout;
+
         _webhookService = webhookService;
         _logger = logger;
     }
@@ -61,7 +64,12 @@ public async Task<WebhookDelivery> SendAsync(Webhook webhook, Dictionary<string,
                 await Task.Delay(_retryInterval);
             }
 
-            lastDelivery = await SendCoreAsync();
+            var request = new WebhookRequest(deliveryId, webhook, events, payload);
+            lastDelivery = await SendAsync(request);
+
+            // Add delivery log
+            await AddDeliveryAsync(lastDelivery);
+
             if (lastDelivery.Success)
             {
                 break;
@@ -69,85 +77,77 @@ public async Task<WebhookDelivery> SendAsync(Webhook webhook, Dictionary<string,
         }
 
         return lastDelivery;
-
-        async Task<WebhookDelivery> SendCoreAsync()
-        {
-            var delivery = new WebhookDelivery(webhook.Id, dataObject["events"].ToString());
-
-            var request = CreateWebhookHttpRequest(webhook, deliveryId, events, payload);
-            delivery.AddRequest(webhook.Url, request.Headers, payload);
-            delivery.Started();
-            try
-            {
-                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-                var response = await _client.SendAsync(request, cts.Token);
-                await delivery.AddResponseAsync(response);
-            }
-            catch (Exception ex)
-            {
-                _logger.LogError(ex, "Exception occurred while sending webhook '{Name}'", webhook.Name);
-
-                var error = new
-                {
-                    message = ex.Message,
-                    stackTrace = ex.StackTrace,
-                };
-                delivery.SetError(error);
-            }
-
-            delivery.Ended();
-
-            // Add delivery log
-            await AddDeliveryAsync(delivery);
-            return delivery;
-        }
     }
 
-    private static HttpRequestMessage CreateWebhookHttpRequest(
-        Webhook webhook,
-        string deliveryId,
-        string events,
-        string payload)
+    public async Task<WebhookDelivery> SendAsync(WebhookRequest request)
     {
-        var uri = string.IsNullOrEmpty(webhook.Url) ? null : new Uri(webhook.Url, UriKind.RelativeOrAbsolute);
-        var httpRequest = new HttpRequestMessage(HttpMethod.Post, uri)
+        var delivery = new WebhookDelivery(request.Id, request.Events);
+
+        var httpRequest = CreateWebhookHttpRequest();
+        delivery.AddRequest(request.Url, httpRequest.Headers, request.Payload);
+        delivery.Started();
+        try
         {
-            Version = HttpVersion.Version11,
-            VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
-        };
-
-        // add built-in headers
-        httpRequest.Headers.Add(WebhookHeaders.Delivery, deliveryId);
-        httpRequest.Headers.Add(WebhookHeaders.Event, events);
-        httpRequest.Headers.Add(WebhookHeaders.HookId, webhook.Id.ToString("D"));
-        if (!string.IsNullOrWhiteSpace(webhook.Secret))
+            var response = await _client.SendAsync(httpRequest);
+            await delivery.AddResponseAsync(response);
+        }
+        catch (Exception ex)
         {
-            var signature = ComputeSignature();
-            httpRequest.Headers.Add(WebhookHeaders.Signature, $"sha256={signature}");
+            _logger.LogError(ex, "Exception occurred while sending webhook '{Name}'", request.Name);
+
+            var error = new
+            {
+                message = ex.Message,
+                stackTrace = ex.StackTrace
+            };
+            delivery.SetError(error);
         }
 
-        // add user specified headers
-        foreach (var header in webhook.Headers)
+        delivery.Ended();
+        return delivery;
+
+        HttpRequestMessage CreateWebhookHttpRequest()
         {
-            if (httpRequest.Headers.Contains(header.Key))
+            var uri = string.IsNullOrEmpty(request.Url) ? null : new Uri(request.Url, UriKind.RelativeOrAbsolute);
+            var message = new HttpRequestMessage(HttpMethod.Post, uri)
             {
-                httpRequest.Headers.Remove(header.Key);
+                Version = HttpVersion.Version11,
+                VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
+            };
+
+            // add built-in headers
+            message.Headers.Add(WebhookHeaders.Delivery, request.DeliveryId);
+            message.Headers.Add(WebhookHeaders.Event, request.Events);
+            message.Headers.Add(WebhookHeaders.HookId, request.Id.ToString("D"));
+            if (!string.IsNullOrWhiteSpace(request.Secret))
+            {
+                var signature = ComputeSignature();
+                message.Headers.Add(WebhookHeaders.Signature, $"sha256={signature}");
             }
 
-            httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value);
-        }
+            // add user specified headers
+            foreach (var header in request.Headers)
+            {
+                if (message.Headers.Contains(header.Key))
+                {
+                    message.Headers.Remove(header.Key);
+                }
+
+                message.Headers.TryAddWithoutValidation(header.Key, header.Value);
+            }
 
-        // add content
-        var content = new StringContent(payload, Encoding.UTF8, MediaTypeNames.Application.Json);
-        httpRequest.Content = content;
+            // add content
+            var content = new StringContent(request.Payload, Encoding.UTF8, MediaTypeNames.Application.Json);
+            message.Content = content;
 
-        return httpRequest;
+            return message;
+        }
 
         string ComputeSignature()
         {
-            var key = Encoding.UTF8.GetBytes(webhook.Secret);
+            var key = Encoding.UTF8.GetBytes(request.Secret);
             using var hmac = new HMACSHA256(key);
-            var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
+            var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(request.Payload));
 
             var signature = ToHexString(hash);
             return signature;
diff --git a/modules/front-end/src/app/core/components/prism/prism.component.ts b/modules/front-end/src/app/core/components/prism/prism.component.ts
index a08febffa..f4203273c 100644
--- a/modules/front-end/src/app/core/components/prism/prism.component.ts
+++ b/modules/front-end/src/app/core/components/prism/prism.component.ts
@@ -9,6 +9,8 @@ import 'prismjs/components/prism-java';
 import 'prismjs/components/prism-csharp';
 import 'prismjs/components/prism-go';
 import 'prismjs/components/prism-bash';
+import 'prismjs/components/prism-http'
+import 'prismjs/components/prism-json'
 import 'prismjs/plugins/normalize-whitespace/prism-normalize-whitespace'
 
 @Component({
diff --git a/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.html b/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.html
new file mode 100644
index 000000000..cd719a84f
--- /dev/null
+++ b/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.html
@@ -0,0 +1,34 @@
+<nz-tabset nzSize="small" [nzTabBarExtraContent]="extra">
+  <nz-tab nzTitle="Request">
+    <span class="title">Headers</span>
+    <div class="headers">
+      <prism [code]="requestHeaders" language="http"></prism>
+    </div>
+    <span class="title">Payload</span>
+    <div class="json">
+      <prism [code]="requestPayload" language="json"></prism>
+    </div>
+  </nz-tab>
+  <nz-tab [nzTitle]="responseTitle">
+    <p class="error" *ngIf="_delivery.error">{{_delivery.error.message}}</p>
+    <span class="title">Headers</span>
+    <div class="headers">
+      <prism [code]="responseHeaders" language="http"></prism>
+    </div>
+    <span class="title">Body</span>
+    <div class="json">
+      <prism [code]="responseBody" language="json"></prism>
+    </div>
+    <ng-template #responseTitle>
+      <span>Response</span>
+      <nz-tag *ngIf="_delivery.success" nzColor="success">{{ _delivery.response?.statusCode ?? 200 }}</nz-tag>
+      <nz-tag *ngIf="!_delivery.success" nzColor="error">{{ _delivery.response?.statusCode ?? 'ERROR' }}</nz-tag>
+    </ng-template>
+  </nz-tab>
+  <ng-template #extra>
+        <span class="completed-in">
+          <i nz-icon nzType="clock-circle" nzTheme="outline"></i>
+          <span class="text">Completed in {{ completedIn }} seconds.</span>
+        </span>
+  </ng-template>
+</nz-tabset>
\ No newline at end of file
diff --git a/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.less b/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.less
new file mode 100644
index 000000000..3c7527a5f
--- /dev/null
+++ b/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.less
@@ -0,0 +1,37 @@
+@import "variables";
+
+.title {
+  font-weight: bold;
+}
+
+.headers {
+  max-height: 300px;
+  overflow: auto;
+  margin-bottom: 8px;
+}
+
+.json {
+  max-height: 300px;
+  overflow: auto;
+}
+
+nz-tabset {
+  margin-top: 8px;
+
+  .error {
+    color: @red60-color;
+  }
+}
+
+.completed-in {
+  color: #0E1116;
+
+  i {
+    font-size: 14px;
+    margin-right: 6px;
+  }
+
+  span {
+    font-size: 12px;
+  }
+}
\ No newline at end of file
diff --git a/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.ts b/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.ts
new file mode 100644
index 000000000..075d53cb6
--- /dev/null
+++ b/modules/front-end/src/app/core/components/webhook-delivery/webhook-delivery.component.ts
@@ -0,0 +1,54 @@
+import { Component, Input } from '@angular/core';
+import { WebhookDelivery } from "@features/safe/integrations/webhooks/webhooks";
+
+@Component({
+  selector: 'webhook-delivery',
+  templateUrl: './webhook-delivery.component.html',
+  styleUrls: ['./webhook-delivery.component.less']
+})
+export class WebhookDeliveryComponent {
+  _delivery: WebhookDelivery;
+  @Input()
+  set delivery(value: WebhookDelivery) {
+    this._delivery = value;
+    if (value) {
+      this.parseDelivery(value);
+    }
+  }
+
+  requestHeaders: string = '';
+  requestPayload: string = '';
+  responseHeaders: string = '';
+  responseBody: string = '';
+  completedIn: number;
+
+  parseDelivery(delivery: WebhookDelivery) {
+    let request = delivery.request;
+    if (request) {
+      this.requestHeaders = [
+        `Request URL: ${request.url}`,
+        'Request method: POST',
+        'Accept: */*',
+        'Content-Type: application/json',
+        Object.keys(request.headers).map(x => `${x}: ${request.headers[x]}`).join('\n')
+      ].join('\n');
+      this.requestPayload = this.tryFormatStringAsJson(request.payload);
+    }
+
+    let response = delivery.response;
+    if (response) {
+      this.responseHeaders = Object.keys(response.headers).map(x => `${x}: ${response.headers[x]}`).join('\n');
+      this.responseBody = this.tryFormatStringAsJson(response.body);
+    }
+
+    this.completedIn = (new Date(delivery.endedAt).getTime() - new Date(delivery.startedAt).getTime()) / 1000;
+  }
+
+  private tryFormatStringAsJson(str: string): string {
+    try {
+      return JSON.stringify(JSON.parse(str), null, 2);
+    } catch {
+      return str;
+    }
+  }
+}
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 bfab2694d..2464755ad 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
@@ -33,7 +33,7 @@
           nzErrorTip="Please provide a valid URL"
           i18n-nzErrorTip="@@integrations.webhooks.url-validation-message"
           nzExtra="The URL of a web server that receives HTTP POST JSON payloads."
-          i18n-nzExtra="@@integrations.webhook.url-description">
+          i18n-nzExtra="@@integrations.webhooks.url-description">
           <input type="text" nz-input formControlName="url" placeholder="https://example.com/post-receive"/>
         </nz-form-control>
       </nz-form-item>
@@ -46,7 +46,7 @@
           nzErrorTip="At least one environment must be selected"
           i18n-nzErrorTip="@@integrations.webhooks.scopes-cannot-be-empty"
           nzExtra="Select the environments that this webhook should monitor. When an event occurs in these environments, this webhook will be triggered."
-          i18n-nzExtra="@@integrations.webhook.scopes-description">
+          i18n-nzExtra="@@integrations.webhooks.scopes-description">
           <div class="scopes-title">
             <span class="text" i18n="@@common.project">Project</span>
             <span class="text" i18n="@@common.environments">Environments</span>
@@ -164,9 +164,9 @@
         </div>
         <nz-form-control
           class="payload-template-form-control"
-          nzErrorTip="Not a valid Handlebars template"
+          nzErrorTip="Not a valid JSON Handlebars template"
           i18n-nzErrorTip="@@integrations.webhooks.payload-template-validation-message"
-          nzExtra="The Handlebars payload template used to generate the webhook JSON request body."
+          nzExtra="The Handlebars payload template to generate the webhook JSON request body."
           i18n-nzExtra="@@integrations.webhooks.payload-template-description">
           <nz-code-editor
             formControlName="payloadTemplate"
@@ -194,6 +194,19 @@
         </nz-form-control>
       </nz-form-item>
 
+      <!-- Test -->
+      <nz-form-item>
+        <nz-form-label i18n="@@common.live-debug">Live debug</nz-form-label>
+        <nz-form-control
+          nzExtra="Optionally, you can test your webhook configuration before saving it."
+          i18n-nzExtra="@@integrations.webhooks.test-webhook-description">
+          <button [disabled]="!form.valid" class="test-webhook-button" type="button" nz-button nzType="link" (click)="openTestModal()">
+            <i nz-icon nzType="bug" nzTheme="outline"></i>
+            <span i18n="@@integrations.webhooks.live-debug-webhook-configuration">Live debug webhook configuration</span>
+          </button>
+        </nz-form-control>
+      </nz-form-item>
+
       <!-- Submit -->
       <nz-form-item>
         <nz-form-control class="align-end">
@@ -206,3 +219,5 @@
     <i (click)="onClose()" nz-icon nzType="icons:icon-close"></i>
   </ng-template>
 </nz-drawer>
+
+<test-webhook-modal [visible]="testModalVisible" [webhook]="testWebhook" (close)="closeTestModal()"></test-webhook-modal>
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 90cfb66a4..67814a364 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
@@ -109,6 +109,10 @@
   }
 }
 
+.test-webhook-button {
+  padding-left: 0;
+}
+
 .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 f621cae45..8e0b90705 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
@@ -1,7 +1,12 @@
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
-import { Webhook, WebhookDefaultPayloadTemplate, WebhookEvents } from "@features/safe/integrations/webhooks/webhooks";
+import {
+  TestWebhook,
+  Webhook,
+  WebhookDefaultPayloadTemplate,
+  WebhookEvents
+} from "@features/safe/integrations/webhooks/webhooks";
 import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from "@angular/forms";
-import { handlebarTemplateValidator, urlValidator } from "@utils/form-validators";
+import { urlValidator } from "@utils/form-validators";
 import { WebhookService } from "@services/webhook.service";
 import { catchError, debounceTime, first, map, switchMap } from "rxjs/operators";
 
@@ -11,6 +16,8 @@ import { ProjectService } from "@services/project.service";
 import { IEnvironment, IProject } from "@shared/types";
 import { of } from "rxjs";
 import { MonacoService } from "@services/monaco-service";
+import { uuidv4 } from "@utils/index";
+import { getTestPayload } from "@core/components/test-webhook-modal/test-webhook";
 
 @Component({
   selector: 'webhook-drawer',
@@ -76,7 +83,7 @@ export class WebhookDrawerComponent implements OnInit {
       events: this.constructEventsFormArray(this._webhook?.events),
       headers: this.constructHeaderFormArray(this._webhook?.headers),
       payloadTemplateType: new FormControl(this._webhook?.payloadTemplateType ?? 'default'),
-      payloadTemplate: new FormControl(this._webhook?.payloadTemplate ?? WebhookDefaultPayloadTemplate, [handlebarTemplateValidator]),
+      payloadTemplate: new FormControl(this._webhook?.payloadTemplate ?? WebhookDefaultPayloadTemplate, [this.jsonHandlebarsTemplateValidator]),
       secret: new FormControl(this._webhook?.secret),
       isActive: new FormControl(this._webhook?.isActive ?? true)
     });
@@ -119,6 +126,20 @@ export class WebhookDrawerComponent implements OnInit {
     );
   };
 
+  jsonHandlebarsTemplateValidator = (control: FormControl) => {
+    let isValid = false;
+
+    try {
+      const template = control.value;
+      const payload = getTestPayload(WebhookEvents[1].value, template);
+      JSON.parse(payload);
+      isValid = true;
+    } catch (err) {
+    }
+
+    return isValid ? null : { invalid: true };
+  }
+
   private editor: editor.IStandaloneCodeEditor;
   onEditorInit(editor: editor.IStandaloneCodeEditor): void {
     this.monacoService.init();
@@ -321,6 +342,29 @@ export class WebhookDrawerComponent implements OnInit {
     this.close.emit(false);
   }
 
+  testWebhook: TestWebhook = null;
+  testModalVisible: boolean = false;
+  openTestModal() {
+    const { name, url, payloadTemplate, headers, secret } = this.form.value;
+    this.testWebhook = {
+      id: uuidv4(),
+      name,
+      url,
+      headers: headers
+        .filter(header => header.key)
+        .map(header => ({ key: header.key, value: header.value })),
+      payloadTemplate,
+      secret
+    };
+
+    this.testModalVisible = true;
+  }
+
+  closeTestModal() {
+    this.testWebhook = null;
+    this.testModalVisible = false;
+  }
+
   doSubmit() {
     const { name, url, scopes, events, headers, payloadTemplateType, payloadTemplate, secret, isActive } = this.form.value;
     const payload = {
diff --git a/modules/front-end/src/app/core/core.module.ts b/modules/front-end/src/app/core/core.module.ts
index 2bf667f30..45e887bfc 100644
--- a/modules/front-end/src/app/core/core.module.ts
+++ b/modules/front-end/src/app/core/core.module.ts
@@ -93,6 +93,9 @@ import { PipesModule } from "@core/pipes/pipes.module";
 import { LicenseComponent } from "@core/components/license/license.component";
 import { WebhookDrawerComponent } from './components/webhook-drawer/webhook-drawer.component';
 import { HandlebarsService } from "@services/handlebars.service";
+import { TestWebhookModalComponent } from './components/test-webhook-modal/test-webhook-modal.component';
+import { WebhookDeliveryComponent } from './components/webhook-delivery/webhook-delivery.component';
+import { NzSkeletonModule } from "ng-zorro-antd/skeleton";
 
 @NgModule({
   declarations: [
@@ -129,7 +132,9 @@ import { HandlebarsService } from "@services/handlebars.service";
     RelayProxyDrawerComponent,
     PrismComponent,
     LicenseComponent,
-    WebhookDrawerComponent
+    WebhookDrawerComponent,
+    TestWebhookModalComponent,
+    WebhookDeliveryComponent
   ],
   imports: [
     CommonModule,
@@ -178,7 +183,8 @@ import { HandlebarsService } from "@services/handlebars.service";
     NzInputNumberModule,
     NzCollapseModule,
     NzSwitchModule,
-    ChangeListModule
+    ChangeListModule,
+    NzSkeletonModule
   ],
   exports: [
     SlugifyPipe,
@@ -224,7 +230,9 @@ import { HandlebarsService } from "@services/handlebars.service";
     RelayProxyDrawerComponent,
     PrismComponent,
     LicenseComponent,
-    WebhookDrawerComponent
+    WebhookDrawerComponent,
+    TestWebhookModalComponent,
+    WebhookDeliveryComponent
   ]
 })
 export class CoreModule {
diff --git a/modules/front-end/src/app/core/services/handlebars.service.ts b/modules/front-end/src/app/core/services/handlebars.service.ts
index cd2973dc2..bca62369d 100644
--- a/modules/front-end/src/app/core/services/handlebars.service.ts
+++ b/modules/front-end/src/app/core/services/handlebars.service.ts
@@ -20,13 +20,20 @@ export class HandlebarsService {
     this._initialized = true;
   }
 
+  compile(template: string, data: any) {
+    const compiled = Handlebars.compile(template);
+    return compiled(data);
+  }
+
   private registerHelpers() {
     Handlebars.registerHelper('eq', function (a, b, options) {
       return a === b ? options.fn(this) : options.inverse(this);
     });
 
     Handlebars.registerHelper('json', function (obj) {
-      return JSON.stringify(obj);
+      // disable HTML-escaping of return values
+      // ref: https://handlebarsjs.com/guide/expressions.html#prevent-html-escaping-of-helper-return-values
+      return new Handlebars.SafeString(JSON.stringify(obj, null, 2));
     });
   }
 }
\ No newline at end of file
diff --git a/modules/front-end/src/app/core/services/monaco-service.ts b/modules/front-end/src/app/core/services/monaco-service.ts
index b19c84c8b..6ce4745a3 100644
--- a/modules/front-end/src/app/core/services/monaco-service.ts
+++ b/modules/front-end/src/app/core/services/monaco-service.ts
@@ -43,7 +43,7 @@ export class MonacoService {
       ];
     }
 
-    monaco.languages.registerCompletionItemProvider("handlebars", {
+    monaco.languages.registerCompletionItemProvider("json", {
       provideCompletionItems: function (model, position) {
         // Get the text before the cursor
         const word = model.getWordUntilPosition(position);
@@ -62,4 +62,4 @@ export class MonacoService {
       triggerCharacters: ['@']
     });
   }
-}
+}
\ No newline at end of file
diff --git a/modules/front-end/src/app/core/services/webhook.service.ts b/modules/front-end/src/app/core/services/webhook.service.ts
index 4d81397d5..a5550f743 100644
--- a/modules/front-end/src/app/core/services/webhook.service.ts
+++ b/modules/front-end/src/app/core/services/webhook.service.ts
@@ -1,7 +1,13 @@
 import { Injectable } from '@angular/core';
 import { environment } from "src/environments/environment";
 import { HttpClient, HttpParams } from "@angular/common/http";
-import { PagedWebhook, Webhook, WebhookFilter } from "@features/safe/integrations/webhooks/webhooks";
+import {
+  PagedWebhook,
+  Webhook,
+  WebhookDelivery,
+  WebhookFilter,
+  WebhookRequest
+} from "@features/safe/integrations/webhooks/webhooks";
 import { Observable } from "rxjs";
 
 @Injectable({
@@ -43,4 +49,8 @@ export class WebhookService {
   delete(id: string) {
     return this.http.delete<boolean>(`${this.baseUrl}/${id}`);
   }
+
+  send(request: WebhookRequest): Observable<WebhookDelivery> {
+    return this.http.post<WebhookDelivery>(`${this.baseUrl}/send`, request);
+  }
 }
\ No newline at end of file
diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.html b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.html
index 46b5d3025..a525e9d9a 100644
--- a/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.html
+++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.html
@@ -93,7 +93,7 @@
             <a style="color: #23AD7F" (click)="showDetailDrawer(item, true)" i18n="@@common.view">View</a>
           </ng-container>
           <nz-dropdown-menu #options="nzDropdownMenu">
-            <ul nz-menu nzSelectable style="min-width: 120px;">
+            <ul nz-menu style="min-width: 120px;">
               <li nz-menu-item (click)="showDetailDrawer(item)">
                 <ng-container i18n="@@common.edit">Edit</ng-container>
               </li>
diff --git a/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.html b/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.html
index 22545ac0b..244696d6b 100644
--- a/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.html
+++ b/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.html
@@ -110,7 +110,13 @@
           </ng-template>
         </td>
         <td class="events">
-          <span nz-tooltip [nzTooltipTitle]="eventsTooltip">{{ item.events?.join('; ') }}</span>
+          <span
+            nz-tooltip
+            nzTooltipPlacement="topLeft"
+            [nzTooltipOverlayStyle]="{'max-width': '270px'}"
+            [nzTooltipTitle]="eventsTooltip">
+            {{ item.events?.join('; ') }}
+          </span>
           <ng-template #eventsTooltip>
             <ul>
               <li *ngFor="let event of item.events;">{{ event }}</li>
@@ -118,21 +124,29 @@
           </ng-template>
         </td>
         <td class="actions">
-          <button nz-button nzType="link" (click)="openDrawer(item)">
-            <i nz-icon nzType="icons:icon-edit"></i>
-            <span i18n="@@common.edit">Edit</span>
-          </button>
-          <button
-            nz-button
-            nzType="link"
-            nz-popconfirm
-            nzPopconfirmTitle="Are you sure to remove it?"
-            i18n-nzPopconfirmTitle="@@common.simple-remove-confirm"
-            nzPopconfirmPlacement="top"
-            (nzOnConfirm)="remove(item.id)">
-            <i nz-icon nzType="icons:icon-delete"></i>
-            <span i18n="@@common.remove">Remove</span>
+          <button nz-button nzType="link" (click)="openTestModal(item)">
+            <span nz-icon nzType="bug" nzTheme="outline"></span>
+            <span i18n="@@common.live-debug">Live debug</span>
           </button>
+          <a nz-dropdown nzTrigger="hover" [nzDropdownMenu]="actions">
+            <span i18n="@@common.more">More</span>&nbsp;
+            <span nz-icon nzType="down"></span>
+          </a>
+          <nz-dropdown-menu #actions="nzDropdownMenu">
+            <ul nz-menu>
+              <li nz-menu-item (click)="openDrawer(item)" i18n="@@common.edit">Edit</li>
+              <li
+                nzDanger
+                nz-menu-item
+                nz-popconfirm
+                nzPopconfirmTitle="Are you sure to remove it?"
+                i18n-nzPopconfirmTitle="@@common.simple-remove-confirm"
+                nzPopconfirmPlacement="top"
+                (nzOnConfirm)="remove(item.id)">
+                <span i18n="@@common.remove">Remove</span>
+              </li>
+            </ul>
+          </nz-dropdown-menu>
         </td>
       </tr>
       </tbody>
@@ -141,3 +155,4 @@
 </div>
 
 <webhook-drawer [webhook]="selectedWebhook" [visible]="drawerVisible" (close)="closeDrawer($event)"></webhook-drawer>
+<test-webhook-modal [visible]="testModalVisible" [webhook]="selectedWebhook" (close)="closeTestModal()"></test-webhook-modal>
diff --git a/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.less b/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.less
index 0911a4b0a..310041be0 100644
--- a/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.less
+++ b/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.less
@@ -13,6 +13,10 @@
 
 .name {
   cursor: pointer;
+
+  &:hover {
+    color: @primary-color;
+  }
 }
 
 .status {
@@ -69,8 +73,3 @@
   }
 }
 
-ul {
-  margin: 4px 0;
-  padding-inline-start: 16px;
-}
-
diff --git a/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.ts b/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.ts
index ad29f2037..ca8a123c9 100644
--- a/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.ts
+++ b/modules/front-end/src/app/features/safe/integrations/webhooks/index/index.component.ts
@@ -82,6 +82,7 @@ export class IndexComponent implements OnInit {
 
   selectedWebhook: Webhook;
   drawerVisible: boolean = false;
+  testModalVisible: boolean = false;
 
   openDrawer(webhook: Webhook) {
     this.selectedWebhook = webhook;
@@ -96,6 +97,16 @@ export class IndexComponent implements OnInit {
     }
   }
 
+  openTestModal(item: Webhook) {
+    this.selectedWebhook = item;
+    this.testModalVisible = true;
+  }
+
+  closeTestModal() {
+    this.selectedWebhook = undefined;
+    this.testModalVisible = false;
+  }
+
   remove(id: string) {
     this.webhookService.delete(id).subscribe({
       next: () => {
diff --git a/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.module.ts b/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.module.ts
index 8d5aba697..98f1aeccc 100644
--- a/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.module.ts
+++ b/modules/front-end/src/app/features/safe/integrations/webhooks/webhooks.module.ts
@@ -12,6 +12,7 @@ import { NzWaveModule } from "ng-zorro-antd/core/wave";
 import { NzToolTipModule } from "ng-zorro-antd/tooltip";
 import { NzPopconfirmModule } from "ng-zorro-antd/popconfirm";
 import { NzSelectModule } from "ng-zorro-antd/select";
+import { NzDropDownModule } from "ng-zorro-antd/dropdown";
 
 @NgModule({
   declarations: [
@@ -28,7 +29,8 @@ import { NzSelectModule } from "ng-zorro-antd/select";
     NzWaveModule,
     NzToolTipModule,
     NzPopconfirmModule,
-    NzSelectModule
+    NzSelectModule,
+    NzDropDownModule
   ]
 })
 export class WebhooksModule { }
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 7dad43236..db4d0507b 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
@@ -108,4 +108,42 @@ export class WebhookFilter {
     this.pageIndex = 1;
     this.pageSize = 10;
   }
+}
+
+export interface WebhookDelivery {
+  id: string;
+  webhookId: string;
+  success: boolean;
+  events: string;
+  request: WebhookDeliveryRequest;
+  response: WebhookDeliveryResponse;
+  error: any;
+  startedAt: Date;
+  endedAt: Date;
+}
+
+export interface WebhookDeliveryRequest {
+  url: string;
+  headers: { key: string; value: string; }[];
+  payload: string;
+}
+
+export interface WebhookDeliveryResponse {
+  statusCode: number;
+  reasonPhrase: string;
+  headers: { key: string; value: string; }[];
+  body: string;
+}
+
+export type TestWebhook = Pick<Webhook, 'id' | 'url' | 'name' | 'secret' | 'headers' | 'payloadTemplate'>;
+
+export interface WebhookRequest {
+  id: string;
+  deliveryId: string;
+  url: string;
+  name: string;
+  secret: string;
+  headers: { key: string; value: string; }[];
+  events: string;
+  payload: string;
 }
\ No newline at end of file
diff --git a/modules/front-end/src/app/shared/utils/form-validators.ts b/modules/front-end/src/app/shared/utils/form-validators.ts
index dcf52b87f..ae9ac41fc 100644
--- a/modules/front-end/src/app/shared/utils/form-validators.ts
+++ b/modules/front-end/src/app/shared/utils/form-validators.ts
@@ -1,5 +1,4 @@
 import { FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from "@angular/forms";
-import Handlebars from 'handlebars/lib/handlebars';
 
 export const PHONE_NUMBER_PATTERN =
   "^(?:\\+?86)?1(?:3\\d{3}|5[^4\\D]\\d{2}|8\\d{3}|7(?:[0-35-9]\\d{2}|4(?:0\\d|1[0-2]|9\\d))|9[0-35-9]\\d{2}|6[2567]\\d{2}|4[579]\\d{2})\\d{6}$";
@@ -60,19 +59,5 @@ export const urlValidator: ValidatorFn = (control: FormControl) => {
   } catch (err) {
   }
 
-  return isValid ? null : { invalid: true };
-}
-
-export const handlebarTemplateValidator: ValidatorFn = (control: FormControl) => {
-  let isValid = false;
-
-  try {
-    const template = control.value;
-    const compiled = Handlebars.compile(template);
-    compiled({});
-    isValid = true;
-  } catch (err) {
-  }
-
   return isValid ? null : { invalid: true };
 }
\ No newline at end of file
diff --git a/modules/front-end/src/locale/messages.xlf b/modules/front-end/src/locale/messages.xlf
index ca44cc2d7..31d6cc558 100644
--- a/modules/front-end/src/locale/messages.xlf
+++ b/modules/front-end/src/locale/messages.xlf
@@ -447,7 +447,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">200</context>
+          <context context-type="linenumber">213</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/setting/setting.component.html</context>
@@ -605,9 +605,13 @@
           <context context-type="sourcefile">src/app/core/components/relay-proxy-drawer/relay-proxy-drawer.component.ts</context>
           <context context-type="linenumber">327</context>
         </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-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">344</context>
+          <context context-type="linenumber">388</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/experiments/metrics/metrics.component.ts</context>
@@ -763,7 +767,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.ts</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">113</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.ts</context>
@@ -828,9 +832,13 @@
           <context context-type="sourcefile">src/app/core/components/target-user/target-user.component.ts</context>
           <context context-type="linenumber">162</context>
         </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-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">347</context>
+          <context context-type="linenumber">391</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/experimentation/experimentation.component.ts</context>
@@ -896,7 +904,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/prism/prism.component.ts</context>
-          <context context-type="linenumber">60</context>
+          <context context-type="linenumber">62</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/relay-proxy-drawer/relay-proxy-drawer.component.ts</context>
@@ -1173,6 +1181,10 @@
           <context context-type="sourcefile">src/app/core/components/audit-logs/audit-logs.component.html</context>
           <context context-type="linenumber">61</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
+          <context context-type="linenumber">132</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="auditlogs.idx.failed-to-load-members" datatype="html">
         <source>Failed to load team members</source>
@@ -2353,7 +2365,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.ts</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">116</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.ts</context>
@@ -3734,7 +3746,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
-          <context context-type="linenumber">129</context>
+          <context context-type="linenumber">142</context>
         </context-group>
       </trans-unit>
       <trans-unit id="common.remove" datatype="html">
@@ -3821,7 +3833,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
-          <context context-type="linenumber">134</context>
+          <context context-type="linenumber">146</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.html</context>
@@ -4655,6 +4667,34 @@
           <context context-type="linenumber">308</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="integrations.webhooks.send-test-event" datatype="html">
+        <source>Send a test event</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.test-webhook-tip" datatype="html">
+        <source>Attempt to deliver an example event to your webhook target.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.select-an-event" datatype="html">
+        <source>Select an Event</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="integration.webhooks.send-test-webhook" datatype="html">
+        <source>Send test webhook</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">23</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="upload" datatype="html">
         <source>Upload</source>
         <context-group purpose="location">
@@ -4888,7 +4928,7 @@
           <context context-type="linenumber">33</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="integrations.webhook.url-description" datatype="html">
+      <trans-unit id="integrations.webhooks.url-description" datatype="html">
         <source>The URL of a web server that receives HTTP POST JSON payloads.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
@@ -4902,7 +4942,7 @@
           <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="integrations.webhook.scopes-description" datatype="html">
+      <trans-unit id="integrations.webhooks.scopes-description" datatype="html">
         <source>Select the environments that this webhook should monitor. When an event occurs in these environments, this webhook will be triggered.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
@@ -5032,14 +5072,14 @@
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.payload-template-validation-message" datatype="html">
-        <source>Not a valid Handlebars template</source>
+        <source>Not a valid JSON Handlebars template</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
           <context context-type="linenumber">167</context>
         </context-group>
       </trans-unit>
       <trans-unit id="integrations.webhooks.payload-template-description" datatype="html">
-        <source>The Handlebars payload template used to generate the webhook JSON request body.</source>
+        <source>The Handlebars payload template to generate the webhook JSON request body.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
           <context context-type="linenumber">169</context>
@@ -5073,18 +5113,43 @@
           <context context-type="linenumber">193</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-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
+          <context context-type="linenumber">129</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.test-webhook-description" datatype="html">
+        <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-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-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">310</context>
+          <context context-type="linenumber">331</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">311</context>
+          <context context-type="linenumber">332</context>
         </context-group>
       </trans-unit>
       <trans-unit id="permissions.no-permissions-to-visit-access-token-page" datatype="html">
@@ -5636,7 +5701,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
-          <context context-type="linenumber">123</context>
+          <context context-type="linenumber">137</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.html</context>
diff --git a/modules/front-end/src/locale/messages.zh.xlf b/modules/front-end/src/locale/messages.zh.xlf
index 5622cc7bd..ea9af9157 100644
--- a/modules/front-end/src/locale/messages.zh.xlf
+++ b/modules/front-end/src/locale/messages.zh.xlf
@@ -454,7 +454,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">200</context>
+          <context context-type="linenumber">213</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/setting/setting.component.html</context>
@@ -620,9 +620,13 @@
           <context context-type="sourcefile">src/app/core/components/relay-proxy-drawer/relay-proxy-drawer.component.ts</context>
           <context context-type="linenumber">327</context>
         </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-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">344</context>
+          <context context-type="linenumber">388</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/experiments/metrics/metrics.component.ts</context>
@@ -778,7 +782,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.ts</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">113</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.ts</context>
@@ -844,9 +848,13 @@
           <context context-type="sourcefile">src/app/core/components/target-user/target-user.component.ts</context>
           <context context-type="linenumber">162</context>
         </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-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.ts</context>
-          <context context-type="linenumber">347</context>
+          <context context-type="linenumber">391</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/feature-flags/details/experimentation/experimentation.component.ts</context>
@@ -914,7 +922,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/prism/prism.component.ts</context>
-          <context context-type="linenumber">60</context>
+          <context context-type="linenumber">62</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/relay-proxy-drawer/relay-proxy-drawer.component.ts</context>
@@ -1208,6 +1216,10 @@
           <context context-type="sourcefile">src/app/core/components/audit-logs/audit-logs.component.html</context>
           <context context-type="linenumber">61</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
+          <context context-type="linenumber">132</context>
+        </context-group>
         <target>更多</target>
       </trans-unit>
       <trans-unit id="auditlogs.idx.failed-to-load-members" datatype="html">
@@ -2486,7 +2498,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.ts</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">116</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.ts</context>
@@ -4006,7 +4018,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
-          <context context-type="linenumber">129</context>
+          <context context-type="linenumber">142</context>
         </context-group>
         <target>确定要删除么？</target>
       </trans-unit>
@@ -4094,7 +4106,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
-          <context context-type="linenumber">134</context>
+          <context context-type="linenumber">146</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.html</context>
@@ -5005,6 +5017,38 @@
         </context-group>
         <target>保存与更新成功</target>
       </trans-unit>
+      <trans-unit id="integrations.webhooks.send-test-event" datatype="html">
+        <source>Send a test event</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
+        <target>发送测试事件</target>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.test-webhook-tip" datatype="html">
+        <source>Attempt to deliver an example event to your webhook target.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+        <target>尝试将示例事件发送到 Webhook。</target>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.select-an-event" datatype="html">
+        <source>Select an Event</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
+        <target>请选择一个事件</target>
+      </trans-unit>
+      <trans-unit id="integration.webhooks.send-test-webhook" datatype="html">
+        <source>Send test webhook</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/components/test-webhook-modal/test-webhook-modal.component.html</context>
+          <context context-type="linenumber">23</context>
+        </context-group>
+        <target>发送测试时间</target>
+      </trans-unit>
       <trans-unit id="upload" datatype="html">
         <source>Upload</source>
         <context-group purpose="location">
@@ -5257,7 +5301,7 @@
         </context-group>
         <target>请提供一个合法的 URL</target>
       </trans-unit>
-      <trans-unit id="integrations.webhook.url-description" datatype="html">
+      <trans-unit id="integrations.webhooks.url-description" datatype="html">
         <source>The URL of a web server that receives HTTP POST JSON payloads.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
@@ -5273,7 +5317,7 @@
         </context-group>
         <target>至少需要选择一个环境</target>
       </trans-unit>
-      <trans-unit id="integrations.webhook.scopes-description" datatype="html">
+      <trans-unit id="integrations.webhooks.scopes-description" datatype="html">
         <source>Select the environments that this webhook should monitor. When an event occurs in these environments, this webhook will be triggered.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
@@ -5418,15 +5462,15 @@
         <target>自定义</target>
       </trans-unit>
       <trans-unit id="integrations.webhooks.payload-template-validation-message" datatype="html">
-        <source>Not a valid Handlebars template</source>
+        <source>Not a valid JSON Handlebars template</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
           <context context-type="linenumber">167</context>
         </context-group>
-        <target>非法的 Handlebars 模版</target>
+        <target>非法的 JSON Handlebars 模版</target>
       </trans-unit>
       <trans-unit id="integrations.webhooks.payload-template-description" datatype="html">
-        <source>The Handlebars payload template used to generate the webhook JSON request body.</source>
+        <source>The Handlebars payload template to generate the webhook JSON request body.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/core/components/webhook-drawer/webhook-drawer.component.html</context>
           <context context-type="linenumber">169</context>
@@ -5465,11 +5509,39 @@
         </context-group>
         <target>当触发此 Webhook 时，我们将提供事件详细信息。</target>
       </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-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
+          <context context-type="linenumber">129</context>
+        </context-group>
+        <target>实时调试</target>
+      </trans-unit>
+      <trans-unit id="integrations.webhooks.test-webhook-description" datatype="html">
+        <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-group>
+        <target>（可选）您可以在保存之前测试您的 Webhook 配置。</target>
+      </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-group>
+        <target>实时调试 webhook 配置</target>
+      </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">310</context>
+          <context context-type="linenumber">331</context>
         </context-group>
         <target>编辑 Webhook</target>
       </trans-unit>
@@ -5477,7 +5549,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">311</context>
+          <context context-type="linenumber">332</context>
         </context-group>
         <target>添加 Webhook</target>
       </trans-unit>
@@ -6097,7 +6169,7 @@
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/integrations/webhooks/index/index.component.html</context>
-          <context context-type="linenumber">123</context>
+          <context context-type="linenumber">137</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/features/safe/relay-proxies/index/index.component.html</context>
diff --git a/modules/front-end/src/styles-common.less b/modules/front-end/src/styles-common.less
index c32d54966..10d635844 100644
--- a/modules/front-end/src/styles-common.less
+++ b/modules/front-end/src/styles-common.less
@@ -10,6 +10,7 @@
 
 @import "styles/nz-select.less";
 @import "styles/nz-input.less";
+@import "styles/nz-tabset.less";
 @import "variables";
 
 html,
@@ -103,6 +104,11 @@ app-root {
   border-radius: 8px;
   padding-left: 8px;
   padding-right: 8px;
+
+  ul {
+    margin: 4px;
+    padding-inline-start: 16px;
+  }
 }
 
 .ant-input-prefix {
@@ -354,15 +360,6 @@ app-root {
   margin-left: 12px;
 }
 
-// custom tab style
-.ant-tabs-tab a,
-.ant-tabs-tab-btn {
-  font-weight: 500;
-  font-size: 16px;
-  line-height: 150%;
-  color: @grey50-color;
-}
-
 // login tab style
 .login-wrapper {
   .ant-tabs-top>.ant-tabs-nav {
@@ -398,34 +395,6 @@ app-root {
   }
 }
 
-.ant-tabs-tab-active a,
-.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
-  color: @grey80-color;
-}
-
-.ant-tabs-top>.ant-tabs-nav::before {
-  bottom: 2px;
-  border-color: @divider-color;
-}
-
-.ant-tabs-ink-bar {
-  height: 2px;
-  background: transparent;
-}
-
-.ant-tabs-ink-bar::after {
-  content: " ";
-  position: absolute;
-  left: 50%;
-  right: 0;
-  bottom: 0;
-  height: 5px;
-  background: @primary-color;
-  border-radius: 5px;
-  width: 16px;
-  transform: translateX(-50%);
-}
-
 // custom card style
 .ant-descriptions-title {
   display:flex;
diff --git a/modules/front-end/src/styles/nz-tabset.less b/modules/front-end/src/styles/nz-tabset.less
new file mode 100644
index 000000000..a5383a807
--- /dev/null
+++ b/modules/front-end/src/styles/nz-tabset.less
@@ -0,0 +1,60 @@
+@import "variables";
+
+.ant-tabs-tab a,
+.ant-tabs-tab-btn {
+  color: @grey50-color;
+}
+
+.ant-tabs-tab-active a,
+.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
+  color: @grey80-color;
+}
+
+.ant-tabs-tab-disabled {
+  .ant-tabs-tab a,
+  .ant-tabs-tab-btn {
+    color: rgba(0, 0, 0, 0.25);
+  }
+}
+
+.ant-tabs-tab a,
+.ant-tabs-tab-btn {
+  font-weight: 500;
+  font-size: 16px;
+  line-height: 150%;
+}
+
+.ant-tabs-small {
+  .ant-tabs-tab a,
+  .ant-tabs-tab-btn {
+    font-size: 13px;
+  }
+
+  .ant-tabs-tab + .ant-tabs-tab {
+    margin-left: 16px;
+  }
+}
+
+
+.ant-tabs-top > .ant-tabs-nav::before {
+  bottom: 2px;
+  border-color: @divider-color;
+}
+
+.ant-tabs-ink-bar {
+  height: 2px;
+  background: transparent;
+}
+
+.ant-tabs-ink-bar::after {
+  content: " ";
+  position: absolute;
+  left: 50%;
+  right: 0;
+  bottom: 0;
+  height: 5px;
+  background: @primary-color;
+  border-radius: 5px;
+  width: 16px;
+  transform: translateX(-50%);
+}
\ No newline at end of file


Test patch:
diff --git a/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.html b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.html
new file mode 100644
index 000000000..93d204715
--- /dev/null
+++ b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.html
@@ -0,0 +1,25 @@
+<nz-modal
+  nzTitle="Send a test event"
+  i18n-nzTitle="@@integrations.webhooks.send-test-event"
+  nzMaskClosable="false"
+  [(nzVisible)]="visible"
+  (nzOnCancel)="onClose()">
+  <div *nzModalContent>
+    <p i18n="@@integrations.webhooks.test-webhook-tip">Attempt to deliver an example event to your webhook target.</p>
+    <div class="select-event">
+      <nz-form-label nzRequired i18n="@@integrations.webhooks.select-an-event">Select an Event</nz-form-label>
+      <nz-select nzShowSearch nzAllowClear [(ngModel)]="event">
+        <nz-option *ngFor="let event of events" [nzValue]="event" [nzLabel]="event"></nz-option>
+      </nz-select>
+    </div>
+    <nz-skeleton nzActive="true" [nzLoading]="isSending">
+    </nz-skeleton>
+    <ng-container *ngIf="!isSending && delivery">
+      <webhook-delivery [delivery]="delivery"></webhook-delivery>
+    </ng-container>
+  </div>
+  <div class="footer" *nzModalFooter>
+    <button nz-button nzType="text" (click)="onClose()">Cancel</button>
+    <button nz-button nzType="primary" (click)="sendTest()" [nzLoading]="isSending" i18n="@@integration.webhooks.send-test-webhook">Send test webhook</button>
+  </div>
+</nz-modal>
\ No newline at end of file
diff --git a/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.less b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.less
new file mode 100644
index 000000000..e8ed7ca13
--- /dev/null
+++ b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.less
@@ -0,0 +1,14 @@
+.select-event {
+  nz-select {
+    width: 250px;
+  }
+}
+
+.footer {
+  margin-top: 12px;
+
+  button {
+    width: unset;
+    height: unset;
+  }
+}
\ No newline at end of file
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
new file mode 100644
index 000000000..fae083b8b
--- /dev/null
+++ b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook-modal.component.ts
@@ -0,0 +1,70 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { TestWebhook, WebhookDelivery, WebhookEvents } from "@features/safe/integrations/webhooks/webhooks";
+import { NzMessageService } from "ng-zorro-antd/message";
+import { WebhookService } from "@services/webhook.service";
+import { finalize } from "rxjs/operators";
+import { uuidv4 } from "@utils/index";
+import { Subscription } from "rxjs";
+import { getTestPayload } from "./test-webhook";
+
+@Component({
+  selector: 'test-webhook-modal',
+  templateUrl: './test-webhook-modal.component.html',
+  styleUrls: ['./test-webhook-modal.component.less']
+})
+export class TestWebhookModalComponent {
+  @Input()
+  visible: boolean;
+  @Input()
+  webhook: TestWebhook;
+  @Output()
+  close: EventEmitter<void> = new EventEmitter();
+
+  events: string[] = WebhookEvents.map(x => x.value);
+  event: string = this.events[1];
+
+  constructor(
+    private message: NzMessageService,
+    private webhookService: WebhookService
+  ) { }
+
+  onClose() {
+    // reset status
+    this.delivery = null;
+    this.isSending = false;
+    this.event = this.events[1];
+    this.sendSubscription?.unsubscribe();
+
+    this.visible = false;
+    this.close.emit();
+  }
+
+  isSending: boolean = false;
+  delivery: WebhookDelivery = null;
+  sendSubscription: Subscription;
+  sendTest() {
+    this.isSending = true;
+
+    const payload = getTestPayload(this.event, this.webhook.payloadTemplate);
+    const request = {
+      id: this.webhook.id,
+      deliveryId: uuidv4(),
+      url: this.webhook.url,
+      name: this.webhook.name,
+      secret: this.webhook.secret,
+      headers: this.webhook.headers,
+      events: this.event,
+      payload: payload
+    };
+
+    this.sendSubscription = this.webhookService.send(request)
+      .pipe(finalize(() => this.isSending = false))
+      .subscribe({
+        next: (delivery) => {
+          this.delivery = delivery;
+          this.message.success($localize`:@@common.operation-success:Operation succeeded`);
+        },
+        error: () => this.message.error($localize`:@@common.operation-failed-try-again:Operation failed, please try again`)
+      });
+  }
+}
diff --git a/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook.ts b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook.ts
new file mode 100644
index 000000000..eb77141fd
--- /dev/null
+++ b/modules/front-end/src/app/core/components/test-webhook-modal/test-webhook.ts
@@ -0,0 +1,153 @@
+import { ISegment } from "@features/safe/segments/types/segments-index";
+import { IFeatureFlag, VariationTypeEnum } from "@features/safe/feature-flags/types/details";
+import Handlebars from 'handlebars/lib/handlebars';
+import { getCurrentOrganization, getCurrentProjectEnv } from "@utils/project-env";
+
+export function getTestPayload(event: string, payloadTemplate: string): string {
+  let projectEnv = getCurrentProjectEnv();
+  let organization = getCurrentOrganization();
+
+  const resourceDescriptor = {
+    organization: {
+      id: organization.id,
+      name: organization.name
+    },
+    project: {
+      id: projectEnv.projectId,
+      name: projectEnv.projectName
+    },
+    environment: {
+      id: projectEnv.envId,
+      name: projectEnv.envName
+    }
+  };
+
+  const data = Object.assign(
+    {},
+    { events: event, operator: 'webhook-tester', happenedAt: new Date().toISOString() },
+    { ...resourceDescriptor }
+  );
+
+  if (event.startsWith('feature_flag')) {
+    Object.assign(data, { data: { kind: 'feature flag', object: testFeatureFlag } });
+  } else if (event.startsWith('segment')) {
+    const segmentData = Object.assign({}, { ...testSegment }, {
+      flagReferences: [
+        {
+          id: 'eac7cb6e-9860-4d58-b1fb-82c7bf5d5025',
+          name: 'Test Feature Flag',
+          key: 'test'
+        }
+      ]
+    })
+    Object.assign(data, { data: { kind: 'segment', object: segmentData } });
+  }
+
+  const compiled = Handlebars.compile(payloadTemplate);
+  return compiled(data);
+}
+
+const testSegment: ISegment = {
+  id: '510766ab-bf7d-4a80-a601-68beced8360e',
+  name: 'Test Segment',
+  description: 'This is a test segment',
+  rules: [
+    {
+      id: '3c22622e-7b8d-4f9f-96e8-f2afb92002b3',
+      name: 'Test Rule',
+      conditions: [
+        {
+          id: '27d853f6-b057-4b55-9716-d5a79bc4e087',
+          property: 'name',
+          op: 'eq',
+          value: 'tester'
+        }
+      ],
+      dispatchKey: ''
+    }
+  ],
+  included: ['truthy-user'],
+  excluded: ['falsy-user'],
+  isArchived: false,
+  updatedAt: new Date()
+};
+
+const testFeatureFlag: IFeatureFlag = {
+  envId: 'de41a713-d02a-4e84-a87f-eab812942ab2',
+  id: 'eac7cb6e-9860-4d58-b1fb-82c7bf5d5025',
+  name: 'Test Feature Flag',
+  description: 'This is a test feature flag',
+  key: 'test',
+  variationType: VariationTypeEnum.boolean,
+  variations: [
+    {
+      id: '155b54a0-0fa1-400d-8f05-394022a66067',
+      name: 'TRUE',
+      value: 'true'
+    },
+    {
+      id: '8f8be618-540d-47d0-85a0-c42473b86590',
+      name: 'FALSE',
+      value: 'false'
+    }
+  ],
+  targetUsers: [
+    {
+      variationId: '155b54a0-0fa1-400d-8f05-394022a66067',
+      keyIds: ['truthy-user']
+    },
+    {
+      variationId: '8f8be618-540d-47d0-85a0-c42473b86590',
+      keyIds: ['falsy-user']
+    }
+  ],
+  rules: [
+    {
+      id: 'f33b0932-accb-4aff-815f-cb3985f9e526',
+      name: 'Test Rule',
+      dispatchKey: 'keyId',
+      includedInExpt: false,
+      conditions: [
+        {
+          id: '6a0db8aa-c976-4052-8f33-c462c7ab275f',
+          property: 'name',
+          op: 'eq',
+          value: 'tester',
+        }
+      ],
+      variations: [
+        {
+          id: '155b54a0-0fa1-400d-8f05-394022a66067',
+          rollout: [0, 0.2],
+          exptRollout: 1
+        },
+        {
+          id: '8f8be618-540d-47d0-85a0-c42473b86590',
+          rollout: [0.2, 0.8],
+          exptRollout: 1
+        }
+      ]
+    }
+  ],
+  isEnabled: true,
+  disabledVariationId: '8f8be618-540d-47d0-85a0-c42473b86590',
+  fallthrough: {
+    dispatchKey: null,
+    includedInExpt: true,
+    variations: [
+      {
+        id: '155b54a0-0fa1-400d-8f05-394022a66067',
+        rollout: [0, 1],
+        exptRollout: 1
+      }
+    ],
+    isNotPercentageRollout: true
+  },
+  exptIncludeAllTargets: true,
+  tags: ['test', 'demo'],
+  isArchived: false,
+  updatedAt: new Date(),
+  createdAt: new Date('2024-01-01'),
+  creatorId: '00000000-0000-0000-0000-000000000000',
+  updatorId: '00000000-0000-0000-0000-000000000000'
+};
\ No newline at end of file