diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs
new file mode 100644
index 00000000..e23b823e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/GenericWebhookTests.cs
@@ -0,0 +1,167 @@
+using System.Text.Json;
+using AutopilotMonitor.Functions.Functions.Config;
+using AutopilotMonitor.Functions.Services.Notifications;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Models.Notifications;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Tests for the generic webhook provider: payload rendering, custom-header parsing/denylist,
+/// and the save-time header validation.
+///
+public class GenericWebhookTests
+{
+ // ── GenericJsonRenderer ───────────────────────────────────────────────
+
+ [Fact]
+ public void GenericJsonRenderer_RendersStableSchema()
+ {
+ var alert = new NotificationAlert
+ {
+ EventType = "enrollment_succeeded",
+ Title = "✅ Enrollment Succeeded",
+ Summary = "Enrollment Succeeded: DESK-001",
+ Severity = NotificationSeverity.Success,
+ ThemeColor = "00B050",
+ Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
+ Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
+ };
+
+ var json = new GenericJsonRenderer().RenderToJson(alert);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ Assert.Equal("1.0", root.GetProperty("schemaVersion").GetString());
+ Assert.Equal("enrollment_succeeded", root.GetProperty("eventType").GetString());
+ Assert.Equal("Success", root.GetProperty("severity").GetString());
+ Assert.Equal("Enrollment Succeeded: DESK-001", root.GetProperty("summary").GetString());
+ Assert.Equal("https://portal.example.com/sessions/abc", root.GetProperty("primaryUrl").GetString());
+
+ var facts = root.GetProperty("facts");
+ Assert.Equal(1, facts.GetArrayLength());
+ Assert.Equal("Device", facts[0].GetProperty("name").GetString());
+ Assert.Equal("DESK-001", facts[0].GetProperty("value").GetString());
+ }
+
+ [Fact]
+ public void GenericJsonRenderer_OmitsEventTypeAndSessionUrl_WhenAbsent()
+ {
+ var alert = new NotificationAlert
+ {
+ Title = "Test",
+ Summary = "Test",
+ Severity = NotificationSeverity.Info,
+ };
+
+ var json = new GenericJsonRenderer().RenderToJson(alert);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ Assert.False(root.TryGetProperty("eventType", out _));
+ Assert.False(root.TryGetProperty("primaryUrl", out _));
+ Assert.Equal(WebhookProviderType.GenericJson, new GenericJsonRenderer().ProviderType);
+ }
+
+ [Theory]
+ [InlineData(true, "enrollment_succeeded")]
+ [InlineData(false, "enrollment_failed")]
+ public void BuildEnrollmentAlert_SetsEventType(bool success, string expected)
+ {
+ var alert = NotificationAlertBuilder.BuildEnrollmentAlert(
+ "DESK", "SN", "Contoso", "Model", success, failureReason: "x", duration: null);
+
+ Assert.Equal(expected, alert.EventType);
+ }
+
+ [Fact]
+ public void BuildHardwareRejectedAlert_SetsEventType()
+ {
+ var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert("Contoso", "Model", "SN");
+ Assert.Equal("hardware_rejected", alert.EventType);
+ }
+
+ // ── TenantConfiguration.GetGenericWebhookHeaders ──────────────────────
+
+ [Fact]
+ public void GetGenericWebhookHeaders_ReturnsEmpty_ForNonGenericProvider()
+ {
+ var config = new TenantConfiguration
+ {
+ WebhookUrl = "https://hooks.slack.com/x",
+ WebhookProviderType = (int)WebhookProviderType.Slack,
+ WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer x\"}",
+ };
+
+ Assert.Empty(config.GetGenericWebhookHeaders());
+ }
+
+ [Fact]
+ public void GetGenericWebhookHeaders_ParsesHeaders_ForGenericProvider()
+ {
+ var config = new TenantConfiguration
+ {
+ WebhookUrl = "https://tickets.example.com/in",
+ WebhookProviderType = (int)WebhookProviderType.GenericJson,
+ WebhookCustomHeadersJson = "{\"Authorization\":\"Bearer abc\",\"X-Api-Key\":\"k1\"}",
+ };
+
+ var headers = config.GetGenericWebhookHeaders();
+ Assert.Equal("Bearer abc", headers["Authorization"]);
+ Assert.Equal("k1", headers["X-Api-Key"]);
+ }
+
+ [Fact]
+ public void GetGenericWebhookHeaders_DropsRestrictedHeaders()
+ {
+ var config = new TenantConfiguration
+ {
+ WebhookUrl = "https://tickets.example.com/in",
+ WebhookProviderType = (int)WebhookProviderType.GenericJson,
+ WebhookCustomHeadersJson = "{\"Host\":\"evil.example.com\",\"Content-Length\":\"0\",\"X-Ok\":\"v\"}",
+ };
+
+ var headers = config.GetGenericWebhookHeaders();
+ Assert.False(headers.ContainsKey("Host"));
+ Assert.False(headers.ContainsKey("Content-Length"));
+ Assert.True(headers.ContainsKey("X-Ok"));
+ }
+
+ [Fact]
+ public void GetGenericWebhookHeaders_ReturnsEmpty_ForMalformedJson()
+ {
+ var config = new TenantConfiguration
+ {
+ WebhookUrl = "https://tickets.example.com/in",
+ WebhookProviderType = (int)WebhookProviderType.GenericJson,
+ WebhookCustomHeadersJson = "{not valid",
+ };
+
+ Assert.Empty(config.GetGenericWebhookHeaders());
+ }
+
+ // ── UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders ────
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("{\"Authorization\":\"Bearer abc\"}")]
+ public void ValidateWebhookCustomHeaders_Accepts_EmptyOrValid(string? json)
+ {
+ Assert.Null(UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json));
+ }
+
+ [Theory]
+ [InlineData("{not json", "not valid JSON.")]
+ [InlineData("[\"a\",\"b\"]", "must be a JSON object")]
+ [InlineData("{\"X-Key\":123}", "must have a string value")]
+ [InlineData("{\"Bad Header\":\"v\"}", "not a valid HTTP header name")]
+ [InlineData("{\"X-Key\":\"line1\\nline2\"}", "must not contain line breaks")]
+ public void ValidateWebhookCustomHeaders_Rejects_Invalid(string json, string expectedFragment)
+ {
+ var error = UpdateTenantConfigurationFunction.ValidateWebhookCustomHeaders(json);
+ Assert.NotNull(error);
+ Assert.Contains(expectedFragment, error);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
index f646a9ee..bd0624a5 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
@@ -374,6 +374,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "WebhookNotifyOnFailure", config.WebhookNotifyOnFailure },
{ "WebhookNotifyOnHardwareRejection", config.WebhookNotifyOnHardwareRejection },
{ "WebhookNotifyOnStart", config.WebhookNotifyOnStart },
+ { "WebhookCustomHeadersJson", config.WebhookCustomHeadersJson },
{ "ShowScriptOutput", config.ShowScriptOutput },
{ "ShowEnrollmentSummary", config.ShowEnrollmentSummary },
{ "EnrollmentSummaryTimeoutSeconds", config.EnrollmentSummaryTimeoutSeconds },
@@ -464,6 +465,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
WebhookNotifyOnFailure = entity.GetBoolean("WebhookNotifyOnFailure") ?? true,
WebhookNotifyOnHardwareRejection = entity.GetBoolean("WebhookNotifyOnHardwareRejection") ?? false,
WebhookNotifyOnStart = entity.GetBoolean("WebhookNotifyOnStart") ?? false,
+ WebhookCustomHeadersJson = entity.GetString("WebhookCustomHeadersJson"),
ShowScriptOutput = entity.GetBoolean("ShowScriptOutput") ?? true,
ShowEnrollmentSummary = entity.GetBoolean("ShowEnrollmentSummary"),
EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"),
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs
index bf755b3b..e521b542 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/TestWebhookNotificationFunction.cs
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var providerType = (WebhookProviderType)providerTypeInt;
var testAlert = NotificationAlertBuilder.BuildTestAlert();
- var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert);
+ var result = await _webhookNotificationService.SendNotificationWithResultAsync(webhookUrl, providerType, testAlert, tenantConfig.GetGenericWebhookHeaders());
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
index 4d3b2c00..8f03aa78 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs
@@ -78,6 +78,13 @@ namespace AutopilotMonitor.Functions.Functions.Config
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid Teams Webhook URL: {teamsUrlError}" });
return badRequest;
}
+ var headersError = ValidateWebhookCustomHeaders(config.WebhookCustomHeadersJson);
+ if (headersError != null)
+ {
+ var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
+ await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid custom headers: {headersError}" });
+ return badRequest;
+ }
// Only validate the customer-supplied SAS URL when the tenant has actually
// selected the CustomerSas destination. In Hosted mode the field is unused at
// runtime (see GetDiagnosticsUploadUrlFunction), so a stale/legacy value left
@@ -176,5 +183,76 @@ namespace AutopilotMonitor.Functions.Functions.Config
return response;
}
}
+
+ private const int MaxCustomHeadersJsonLength = 8192;
+ private const int MaxCustomHeaderCount = 25;
+
+ ///
+ /// Validates the generic-webhook custom-headers JSON. Returns an error message, or null when
+ /// valid/empty. Enforces a JSON object of string values, valid HTTP token names, no CR/LF
+ /// header-injection, and size caps. Restricted (framing/host/content) headers are not rejected
+ /// here — they are silently ignored at dispatch by TenantConfiguration.GetGenericWebhookHeaders().
+ ///
+ internal static string? ValidateWebhookCustomHeaders(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return null;
+
+ if (json.Length > MaxCustomHeadersJsonLength)
+ return $"too large (max {MaxCustomHeadersJsonLength} characters).";
+
+ System.Text.Json.JsonDocument doc;
+ try
+ {
+ doc = System.Text.Json.JsonDocument.Parse(json);
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return "not valid JSON.";
+ }
+
+ using (doc)
+ {
+ if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object)
+ return "must be a JSON object of header name/value pairs.";
+
+ var count = 0;
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (++count > MaxCustomHeaderCount)
+ return $"too many headers (max {MaxCustomHeaderCount}).";
+
+ if (prop.Value.ValueKind != System.Text.Json.JsonValueKind.String)
+ return $"header \"{prop.Name}\" must have a string value.";
+
+ if (!IsValidHeaderName(prop.Name))
+ return $"\"{prop.Name}\" is not a valid HTTP header name.";
+
+ var value = prop.Value.GetString();
+ if (value != null && (value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0))
+ return $"value for \"{prop.Name}\" must not contain line breaks.";
+ }
+ }
+
+ return null;
+ }
+
+ /// Validates an HTTP header name as an RFC 7230 token (no whitespace, controls, or separators).
+ private static bool IsValidHeaderName(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ return false;
+
+ foreach (var ch in name)
+ {
+ var isTokenChar =
+ (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') ||
+ "!#$%&'*+-.^_`|~".IndexOf(ch) >= 0;
+ if (!isTokenChar)
+ return false;
+ }
+
+ return true;
+ }
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs
index 51639865..1bdc4ae1 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs
@@ -870,6 +870,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
return;
var providerType = (WebhookProviderType)providerTypeInt;
+ var customHeaders = tenantConfig.GetGenericWebhookHeaders();
var sessionUrl = updatedSession != null
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
: null;
@@ -905,7 +906,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
- _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
+ _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -928,7 +929,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
- _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
+ _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -950,7 +951,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
- _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
+ _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs
index 5c200342..c0a11b99 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportDistressFunction.cs
@@ -231,7 +231,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
if (!string.IsNullOrEmpty(webhookUrl) && providerTypeInt != 0)
{
var alert = NotificationAlertBuilder.BuildHardwareRejectedAlert(manufacturer, model, serialNumber);
- _ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert);
+ _ = _webhookNotification.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, config.GetGenericWebhookHeaders());
}
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs
index a9ad5fbe..afd6110a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs
@@ -260,7 +260,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
isResume: isResume,
sessionUrl: sessionUrl);
- await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert);
+ await _webhookNotificationService.SendNotificationAsync(webhookUrl, (WebhookProviderType)providerTypeInt, alert, tenantConfig.GetGenericWebhookHeaders());
}
catch (Exception ex)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs
index 5ab4c2b8..643fa034 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs
@@ -27,6 +27,7 @@ namespace AutopilotMonitor.Functions.Services
return;
var providerType = (WebhookProviderType)providerTypeInt;
+ var customHeaders = tenantConfig.GetGenericWebhookHeaders();
var sessionUrl = updatedSession != null
? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}"
: null;
@@ -59,7 +60,7 @@ namespace AutopilotMonitor.Functions.Services
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
- _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
+ _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -81,7 +82,7 @@ namespace AutopilotMonitor.Functions.Services
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
- _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
+ _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
@@ -102,7 +103,7 @@ namespace AutopilotMonitor.Functions.Services
sessionUrl: sessionUrl);
NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults);
- _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert)
+ _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted);
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs
new file mode 100644
index 00000000..c67751af
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs
@@ -0,0 +1,58 @@
+using System.Linq;
+using AutopilotMonitor.Shared.Models.Notifications;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Functions.Services.Notifications
+{
+ ///
+ /// Renders a NotificationAlert as a stable, channel-agnostic JSON payload for generic webhook
+ /// consumers (ticketing systems, automation, SMTP gateways such as Postal).
+ ///
+ /// Payload contract (schemaVersion "1.0"):
+ /// {
+ /// "schemaVersion": "1.0",
+ /// "eventType": "enrollment_succeeded", // omitted when not set
+ /// "severity": "Success", // Info | Success | Warning | Error
+ /// "title": "...", "summary": "...", "themeColor": "00B050",
+ /// "primaryUrl": "https://.../sessions/{id}", // first openUrl action (session/dashboard link), omitted when none
+ /// "facts": [ { "name": "Device", "value": "..." } ],
+ /// "sections": [ { "title": "...", "text": "..." } ],
+ /// "actions": [ { "type": "openUrl", "title": "...", "url": "..." } ]
+ /// }
+ ///
+ public class GenericJsonRenderer : INotificationRenderer
+ {
+ /// Bump only on a breaking change to the payload contract above.
+ private const string SchemaVersion = "1.0";
+
+ public WebhookProviderType ProviderType => WebhookProviderType.GenericJson;
+
+ public string RenderToJson(NotificationAlert alert)
+ {
+ // First openUrl action: a session link for enrollment alerts, a dashboard/portal link for
+ // SLA/test alerts. Named generically (primaryUrl) since it is not always a session URL;
+ // consumers can still inspect actions[] (each carries its title) to disambiguate.
+ var primaryUrl = alert.Actions
+ .FirstOrDefault(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url))?.Url;
+
+ var payload = new
+ {
+ schemaVersion = SchemaVersion,
+ eventType = string.IsNullOrEmpty(alert.EventType) ? null : alert.EventType,
+ severity = alert.Severity.ToString(),
+ title = alert.Title,
+ summary = alert.Summary,
+ themeColor = string.IsNullOrEmpty(alert.ThemeColor) ? null : alert.ThemeColor,
+ primaryUrl,
+ facts = alert.Facts.Select(f => new { name = f.Name, value = f.Value }).ToArray(),
+ sections = alert.Sections.Select(s => new { title = s.Title, text = s.Text }).ToArray(),
+ actions = alert.Actions.Select(a => new { type = a.Type, title = a.Title, url = a.Url }).ToArray(),
+ };
+
+ return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
+ {
+ NullValueHandling = NullValueHandling.Ignore,
+ });
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs
index ff384ce4..04d071e9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/NotificationAlertBuilder.cs
@@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
+ EventType = success ? "enrollment_succeeded" : "enrollment_failed",
Title = title,
Summary = summary,
Severity = severity,
@@ -84,6 +85,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
+ EventType = isResume ? "preprovisioning_resumed" : "enrollment_started",
Title = title,
Summary = summary,
Severity = NotificationSeverity.Info,
@@ -127,6 +129,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
+ EventType = success ? "preprovisioning_completed" : "preprovisioning_failed",
Title = title,
Summary = summary,
Severity = severity,
@@ -150,6 +153,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
{
return new NotificationAlert
{
+ EventType = "test",
Title = "\ud83d\udd14 Test Notification",
Summary = "This is a test notification from Autopilot Monitor.",
Severity = NotificationSeverity.Info,
@@ -175,6 +179,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
return new NotificationAlert
{
+ EventType = "hardware_rejected",
Title = "\u26a0\ufe0f Hardware Not Whitelisted",
Summary = $"Device rejected: {hardwareText} is not in your hardware whitelist",
Severity = NotificationSeverity.Warning,
@@ -298,6 +303,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
+ EventType = "sla_breach",
Title = title,
Summary = summary,
Severity = NotificationSeverity.Warning,
@@ -346,6 +352,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
+ EventType = "sla_resolved",
Title = $"✅ SLA Breach Resolved: {label}",
Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.",
Severity = NotificationSeverity.Success,
@@ -375,6 +382,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
{
var alert = new NotificationAlert
{
+ EventType = "consecutive_failures",
Title = $"\ud83d\udea8 {consecutiveFailures} Consecutive Enrollment Failures",
Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}",
Severity = NotificationSeverity.Error,
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs
index 320b1070..a17ab62d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Notifications/WebhookNotificationService.cs
@@ -28,13 +28,17 @@ namespace AutopilotMonitor.Functions.Services.Notifications
[WebhookProviderType.TeamsLegacyConnector] = new LegacyTeamsConnectorRenderer(),
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
[WebhookProviderType.Slack] = new SlackRenderer(),
+ [WebhookProviderType.GenericJson] = new GenericJsonRenderer(),
};
}
///
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
+ /// (generic webhooks only) are attached to the request,
+ /// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
///
- public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
+ public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
+ IReadOnlyDictionary? customHeaders = null)
{
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
return;
@@ -50,8 +54,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
var json = renderer.RenderToJson(alert);
- var content = new StringContent(json, Encoding.UTF8, "application/json");
- var response = await _http.PostAsync(webhookUrl, content);
+ var response = await PostAsync(webhookUrl, json, customHeaders);
if (response.IsSuccessStatusCode)
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
@@ -69,8 +72,10 @@ namespace AutopilotMonitor.Functions.Services.Notifications
///
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
+ /// (generic webhooks only) are attached to the request.
///
- public async Task SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert)
+ public async Task SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
+ IReadOnlyDictionary? customHeaders = null)
{
if (string.IsNullOrEmpty(webhookUrl))
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
@@ -93,8 +98,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
try
{
var json = renderer.RenderToJson(alert);
- var content = new StringContent(json, Encoding.UTF8, "application/json");
- var response = await _http.PostAsync(webhookUrl, content);
+ var response = await PostAsync(webhookUrl, json, customHeaders);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
@@ -116,6 +120,25 @@ namespace AutopilotMonitor.Functions.Services.Notifications
}
}
+ ///
+ /// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted
+ /// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders().
+ ///
+ private Task PostAsync(string webhookUrl, string json, IReadOnlyDictionary? customHeaders)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json"),
+ };
+
+ if (customHeaders != null)
+ {
+ foreach (var header in customHeaders)
+ request.Headers.TryAddWithoutValidation(header.Key, header.Value);
+ }
+
+ return _http.SendAsync(request);
+ }
}
public class WebhookTestResult
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs
index 7229d6fe..9bee75df 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs
@@ -551,7 +551,7 @@ namespace AutopilotMonitor.Functions.Services
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
{
- await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
+ await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
}
await _tenantNotificationService.CreateNotificationAsync(
@@ -594,7 +594,7 @@ namespace AutopilotMonitor.Functions.Services
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
{
- await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
+ await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
}
await _tenantNotificationService.CreateNotificationAsync(
@@ -682,7 +682,7 @@ namespace AutopilotMonitor.Functions.Services
var (webhookUrl, providerType) = config.GetEffectiveWebhookConfig();
if (!string.IsNullOrEmpty(webhookUrl) && providerType != 0)
{
- await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert);
+ await _webhookService.SendNotificationAsync(webhookUrl, (Shared.Models.Notifications.WebhookProviderType)providerType, alert, config.GetGenericWebhookHeaders());
}
await _tenantNotificationService.CreateNotificationAsync(
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
index 39d9d712..3bf3b5de 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
@@ -548,6 +548,15 @@ namespace AutopilotMonitor.Shared.Models
///
public bool WebhookNotifyOnStart { get; set; } = false;
+ ///
+ /// Custom HTTP request headers (JSON object: { "Header-Name": "value", ... }) sent with every
+ /// generic-webhook POST. Used for API-key authentication against ticketing systems / SMTP gateways.
+ /// Only applied when the effective provider is .
+ /// Restricted headers (Host, Content-Length, Content-Type, etc.) are ignored — see
+ /// .
+ ///
+ public string WebhookCustomHeadersJson { get; set; } = default!;
+
// ===== SLA TARGETS =====
///
@@ -640,6 +649,64 @@ namespace AutopilotMonitor.Shared.Models
public bool GetEffectiveNotifyOnStart()
=> !string.IsNullOrEmpty(WebhookUrl) ? WebhookNotifyOnStart : TeamsNotifyOnStart;
+ ///
+ /// HTTP headers that must never be set via the custom-header mechanism: framing/host/content
+ /// headers are owned by the HTTP client and overriding them breaks the request or enables
+ /// request-smuggling. Compared case-insensitively.
+ ///
+ private static readonly HashSet RestrictedWebhookHeaders = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Host", "Content-Length", "Content-Type", "Transfer-Encoding", "Connection",
+ "Keep-Alive", "Upgrade", "TE", "Trailer", "Expect", "Proxy-Connection",
+ };
+
+ ///
+ /// Parses into header name/value pairs for the generic
+ /// webhook dispatcher. Returns an empty dictionary unless the effective provider is
+ /// , the JSON is a parseable object,
+ /// and after dropping restricted headers and blank names/values. Never throws.
+ ///
+ public IReadOnlyDictionary GetGenericWebhookHeaders()
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ var (_, providerType) = GetEffectiveWebhookConfig();
+ if (providerType != (int)Notifications.WebhookProviderType.GenericJson)
+ return result;
+
+ if (string.IsNullOrWhiteSpace(WebhookCustomHeadersJson))
+ return result;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(WebhookCustomHeadersJson);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ return result;
+
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (prop.Value.ValueKind != JsonValueKind.String)
+ continue;
+
+ var name = prop.Name.Trim();
+ var value = prop.Value.GetString();
+
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrEmpty(value))
+ continue;
+ if (RestrictedWebhookHeaders.Contains(name))
+ continue;
+
+ result[name] = value!;
+ }
+ }
+ catch (JsonException)
+ {
+ // Malformed JSON → no custom headers (fail-soft; dispatch still proceeds).
+ }
+
+ return result;
+ }
+
///
/// Checks if the tenant is currently disabled
/// Takes into account DisabledUntil if set
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs
index 9aa16c49..9ae697d0 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/NotificationAlert.cs
@@ -9,6 +9,13 @@ namespace AutopilotMonitor.Shared.Models.Notifications
///
public class NotificationAlert
{
+ ///
+ /// Machine-readable event type for routing/filtering by generic webhook consumers
+ /// (e.g. "enrollment_succeeded", "enrollment_failed", "hardware_rejected", "sla_breach").
+ /// Only serialized by the generic JSON renderer; ignored by Teams/Slack renderers.
+ ///
+ public string? EventType { get; set; }
+
/// Main title, e.g. "Enrollment Succeeded".
public string Title { get; set; } = default!;
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs
index 0b05a50b..75dd78d8 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Notifications/WebhookProviderType.cs
@@ -16,5 +16,12 @@ namespace AutopilotMonitor.Shared.Models.Notifications
/// Slack Incoming Webhook (Block Kit format).
Slack = 10,
+
+ ///
+ /// Generic JSON webhook. Posts a stable, channel-agnostic JSON payload (schemaVersion + eventType)
+ /// to any HTTP endpoint — for ticketing systems, automation, or SMTP gateways (e.g. Postal).
+ /// Supports per-tenant custom request headers for API-key authentication.
+ ///
+ GenericJson = 20,
}
}
diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx
index 826646ee..b32999c8 100644
--- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx
@@ -197,6 +197,7 @@ const WEBHOOK_PROVIDERS: Record = {
1: 'Teams (Legacy Connector)',
2: 'Teams (Workflow Webhook)',
10: 'Slack',
+ 20: 'Generic JSON',
};
function formatValue(val: unknown): string {
diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
index fdf0b6a9..4483347c 100644
--- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx
@@ -153,6 +153,8 @@ interface TenantConfigContextValue {
setWebhookNotifyOnFailure: (v: boolean) => void;
webhookNotifyOnStart: boolean;
setWebhookNotifyOnStart: (v: boolean) => void;
+ webhookCustomHeaders: string;
+ setWebhookCustomHeaders: (v: string) => void;
testingWebhook: boolean;
testWebhookResult: { success: boolean; message: string } | null;
handleTestWebhook: () => Promise;
@@ -334,6 +336,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [webhookNotifyOnSuccess, setWebhookNotifyOnSuccess] = useState(true);
const [webhookNotifyOnFailure, setWebhookNotifyOnFailure] = useState(true);
const [webhookNotifyOnStart, setWebhookNotifyOnStart] = useState(false);
+ const [webhookCustomHeaders, setWebhookCustomHeaders] = useState("");
const [testingWebhook, setTestingWebhook] = useState(false);
const [testWebhookResult, setTestWebhookResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -439,18 +442,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setWebhookNotifyOnSuccess(data.webhookNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(data.webhookNotifyOnFailure ?? true);
setWebhookNotifyOnStart(data.webhookNotifyOnStart ?? false);
+ setWebhookCustomHeaders(data.webhookCustomHeadersJson ?? "");
} else if (data.teamsWebhookUrl) {
setWebhookProviderType(1); // TeamsLegacyConnector
setWebhookUrl(data.teamsWebhookUrl);
setWebhookNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
setWebhookNotifyOnStart(data.teamsNotifyOnStart ?? false);
+ setWebhookCustomHeaders("");
} else {
setWebhookProviderType(0);
setWebhookUrl("");
setWebhookNotifyOnSuccess(true);
setWebhookNotifyOnFailure(true);
setWebhookNotifyOnStart(false);
+ setWebhookCustomHeaders("");
}
// SLA Targets
setSlaTargetSuccessRate(data.slaTargetSuccessRate ?? null);
@@ -650,6 +656,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
webhookNotifyOnSuccess,
webhookNotifyOnFailure,
webhookNotifyOnStart,
+ // Custom headers only apply to the generic provider; clear them otherwise so a
+ // provider switch does not leave stale auth headers persisted.
+ webhookCustomHeadersJson: webhookProviderType === 20 ? (webhookCustomHeaders || undefined) : undefined,
// Legacy compat: mirror to old fields during transition
teamsWebhookUrl: webhookProviderType === 1 ? (webhookUrl || undefined) : undefined,
teamsNotifyOnSuccess: webhookNotifyOnSuccess,
@@ -721,6 +730,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, logLevel, showScriptOutput, showEnrollmentSummary,
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
webhookProviderType, webhookUrl, webhookNotifyOnSuccess, webhookNotifyOnFailure, webhookNotifyOnStart,
+ webhookCustomHeaders,
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
@@ -1064,18 +1074,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setWebhookNotifyOnSuccess(config.webhookNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(config.webhookNotifyOnFailure ?? true);
setWebhookNotifyOnStart(config.webhookNotifyOnStart ?? false);
+ setWebhookCustomHeaders(config.webhookCustomHeadersJson ?? "");
} else if (config.teamsWebhookUrl) {
setWebhookProviderType(1);
setWebhookUrl(config.teamsWebhookUrl);
setWebhookNotifyOnSuccess(config.teamsNotifyOnSuccess ?? true);
setWebhookNotifyOnFailure(config.teamsNotifyOnFailure ?? true);
setWebhookNotifyOnStart(config.teamsNotifyOnStart ?? false);
+ setWebhookCustomHeaders("");
} else {
setWebhookProviderType(0);
setWebhookUrl("");
setWebhookNotifyOnSuccess(true);
setWebhookNotifyOnFailure(true);
setWebhookNotifyOnStart(false);
+ setWebhookCustomHeaders("");
}
}, [config]);
@@ -1456,6 +1469,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
webhookNotifyOnSuccess, setWebhookNotifyOnSuccess,
webhookNotifyOnFailure, setWebhookNotifyOnFailure,
webhookNotifyOnStart, setWebhookNotifyOnStart,
+ webhookCustomHeaders, setWebhookCustomHeaders,
testingWebhook, testWebhookResult,
handleTestWebhook, handleSaveNotifications, handleResetNotifications,
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx
index b8d9093c..8c08859b 100644
--- a/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/components/NotificationsSection.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useEffect, useRef, useState } from "react";
import SaveResetBar from "./SaveResetBar";
interface NotificationsSectionProps {
@@ -13,6 +14,8 @@ interface NotificationsSectionProps {
setWebhookNotifyOnFailure: (v: boolean) => void;
webhookNotifyOnStart: boolean;
setWebhookNotifyOnStart: (v: boolean) => void;
+ webhookCustomHeaders: string;
+ setWebhookCustomHeaders: (v: string) => void;
onTestWebhook: () => Promise;
testingWebhook: boolean;
testWebhookResult: { success: boolean; message: string } | null;
@@ -21,13 +24,113 @@ interface NotificationsSectionProps {
saving: boolean;
}
+const GENERIC_PROVIDER = 20;
+
const PROVIDERS = [
{ value: 0, label: "None (disabled)", placeholder: "" },
{ value: 1, label: "Microsoft Teams (Legacy Connector)", placeholder: "https://your-org.webhook.office.com/webhookb2/...", badge: "Deprecated", badgeColor: "bg-amber-100 text-amber-800" },
{ value: 2, label: "Microsoft Teams (Workflow Webhook)", placeholder: "https://prod-xx.westeurope.logic.azure.com:443/workflows/...", badge: "Recommended", badgeColor: "bg-green-100 text-green-800" },
{ value: 10, label: "Slack", placeholder: "https://hooks.slack.com/services/T.../B.../..." },
+ { value: GENERIC_PROVIDER, label: "Generic JSON (ticketing / automation)", placeholder: "https://your-system.example.com/api/webhooks/autopilot" },
];
+type HeaderRow = { key: string; value: string };
+
+function parseHeaderRows(json: string): HeaderRow[] {
+ if (!json || !json.trim()) return [];
+ try {
+ const obj = JSON.parse(json);
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }));
+ }
+ } catch {
+ // Malformed JSON (e.g. legacy/hand-edited value) — start from an empty editor.
+ }
+ return [];
+}
+
+function serializeHeaderRows(rows: HeaderRow[]): string {
+ const obj: Record = {};
+ for (const { key, value } of rows) {
+ const k = key.trim();
+ if (k) obj[k] = value;
+ }
+ return Object.keys(obj).length > 0 ? JSON.stringify(obj) : "";
+}
+
+/**
+ * Key→value editor for generic-webhook custom headers. Owns row state locally and serializes
+ * to the JSON string the backend persists; re-syncs only on external changes (config load/reset).
+ */
+function WebhookHeaderEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ const [rows, setRows] = useState(() => parseHeaderRows(value));
+ const lastSerialized = useRef(value);
+
+ useEffect(() => {
+ if (value !== lastSerialized.current) {
+ setRows(parseHeaderRows(value));
+ lastSerialized.current = value;
+ }
+ }, [value]);
+
+ const commit = (next: HeaderRow[]) => {
+ setRows(next);
+ const json = serializeHeaderRows(next);
+ lastSerialized.current = json;
+ onChange(json);
+ };
+
+ return (
+
+ Custom Headers
+
+ Optional HTTP headers sent with every request — e.g. Authorization with an API key
+ for your ticketing system or SMTP gateway. Framing headers (Host, Content-Type, …) are ignored.
+