From 5ca691e6d80eecbfca4f738c9426df572c0fce17 Mon Sep 17 00:00:00 2001
From: Warren Lee <5959690+wrn14897@users.noreply.github.com>
Date: Wed, 2 Sep 2026 22:43:21 -0700
Subject: [PATCH 1/4] feat(app): list all webhook template variables in the
webhook form
The form advertised seven variables while the Generic and incident.io
transports render nineteen, so the ten enriched variables added in #3057
were undiscoverable from the UI.
List every variable from buildWebhookTemplateVariables with a one-line
description, in a two-column grid, and note the JSON-escaping and
empty-string-fallback behaviour.
---
.changeset/webhook-form-template-variables.md | 10 +++
.../components/TeamSettings/WebhookForm.tsx | 83 ++++++++++++++++---
.../__tests__/WebhookForm.test.tsx | 73 ++++++++++++++++
3 files changed, 154 insertions(+), 12 deletions(-)
create mode 100644 .changeset/webhook-form-template-variables.md
create mode 100644 packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx
diff --git a/.changeset/webhook-form-template-variables.md b/.changeset/webhook-form-template-variables.md
new file mode 100644
index 0000000000..0f90e71506
--- /dev/null
+++ b/.changeset/webhook-form-template-variables.md
@@ -0,0 +1,10 @@
+---
+'@hyperdx/app': patch
+---
+
+List every supported template variable in the webhook form, including the
+enriched set added to Generic and incident.io bodies (`{{alertId}}`,
+`{{status}}`, `{{alertType}}`, `{{comparator}}`, `{{threshold}}`, `{{value}}`,
+`{{groupKey}}`, `{{sourceQuery}}`, `{{teamId}}`, `{{note}}` and ISO-8601
+`{{startTimeISO}}` / `{{endTimeISO}}`). Each variable now carries a one-line
+description, so a webhook body can be written without leaving the form.
diff --git a/packages/app/src/components/TeamSettings/WebhookForm.tsx b/packages/app/src/components/TeamSettings/WebhookForm.tsx
index 4bc4d3886c..b136beb7f5 100644
--- a/packages/app/src/components/TeamSettings/WebhookForm.tsx
+++ b/packages/app/src/components/TeamSettings/WebhookForm.tsx
@@ -13,8 +13,10 @@ import { isValidSlackUrl } from '@hyperdx/common-utils/dist/validation';
import {
Alert,
Button,
+ Code,
Group,
Radio,
+ SimpleGrid,
Stack,
Text,
TextInput,
@@ -42,6 +44,51 @@ const DEFAULT_GENERIC_WEBHOOK_BODY = [
const DEFAULT_GENERIC_WEBHOOK_BODY_TEMPLATE =
DEFAULT_GENERIC_WEBHOOK_BODY.join(' | ');
+// Mirrors buildWebhookTemplateVariables in
+// packages/api/src/tasks/checkAlerts/transports/generic.ts — keep in sync when
+// variables are added or removed there.
+export const getWebhookTemplateVariables = (
+ brandName: string,
+): { name: string; description: string }[] => [
+ { name: '{{title}}', description: 'Alert title' },
+ { name: '{{body}}', description: 'Rendered message body (markdown)' },
+ { name: '{{link}}', description: `Deep link back into ${brandName}` },
+ { name: '{{state}}', description: 'Raw internal alert state' },
+ {
+ name: '{{status}}',
+ description: 'firing, resolved, no_data, pending or error',
+ },
+ { name: '{{eventId}}', description: 'Unique id for this firing' },
+ {
+ name: '{{alertId}}',
+ description: 'Stable id of the alert — the key to dedupe on',
+ },
+ {
+ name: '{{alertType}}',
+ description: 'search, dashboard_chart or inline_query',
+ },
+ {
+ name: '{{comparator}}',
+ description: '>=, >, <, <=, =, !=, between or outside',
+ },
+ { name: '{{threshold}}', description: 'The configured threshold (number)' },
+ {
+ name: '{{value}}',
+ description: 'Value that triggered or resolved the alert (number)',
+ },
+ { name: '{{groupKey}}', description: 'The breaching group, if grouped' },
+ {
+ name: '{{sourceQuery}}',
+ description: 'Search expression or SQL behind the alert',
+ },
+ { name: '{{startTime}}', description: 'Window start, Unix ms (number)' },
+ { name: '{{endTime}}', description: 'Window end, Unix ms (number)' },
+ { name: '{{startTimeISO}}', description: 'Window start, ISO-8601' },
+ { name: '{{endTimeISO}}', description: 'Window end, ISO-8601' },
+ { name: '{{teamId}}', description: 'Team the alert belongs to' },
+ { name: '{{note}}', description: "Alert's note, commonly a runbook link" },
+];
+
const jsonLinterWithEmptyCheck = () => (editorView: EditorView) => {
const text = editorView.state.doc.toString().trim();
if (text === '') return [];
@@ -277,6 +324,7 @@ export function WebhookForm({
};
const service = useWatch({ control: form.control, name: 'service' });
+ const templateVariables = getWebhookTemplateVariables(brandName);
const headersText = useWatch({ control: form.control, name: 'headers' });
const hasMaskedHeaders = isEditing && !!headersText?.includes('****');
@@ -401,19 +449,30 @@ export function WebhookForm({
className="mb-4"
color="gray"
>
-
- Currently the body supports the following message template
- variables:
-
-
-
- {DEFAULT_GENERIC_WEBHOOK_BODY.map((body, index) => (
-
- {body}
- {index < DEFAULT_GENERIC_WEBHOOK_BODY.length - 1 && ', '}
-
+
+ The body supports the following template variables:
+
+
+ {templateVariables.map(({ name, description }) => (
+
+ {name}
+
+ {description}
+
+
))}
-
+
+
+ Strings are JSON-escaped, so they are safe inside quotes. Numbers
+ are emitted raw for unquoted slots. A variable the alert
+ doesn't carry renders as an empty string.
+
,
]}
diff --git a/packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx b/packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx
new file mode 100644
index 0000000000..dedd97335d
--- /dev/null
+++ b/packages/app/src/components/TeamSettings/__tests__/WebhookForm.test.tsx
@@ -0,0 +1,73 @@
+import { MantineProvider } from '@mantine/core';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {
+ getWebhookTemplateVariables,
+ WebhookForm,
+} from '@/components/TeamSettings/WebhookForm';
+
+jest.mock('@/api', () => ({
+ __esModule: true,
+ default: {
+ useSaveWebhook: () => ({ mutateAsync: jest.fn(), isPending: false }),
+ useUpdateWebhook: () => ({ mutateAsync: jest.fn(), isPending: false }),
+ useTestWebhook: () => ({ mutateAsync: jest.fn(), isPending: false }),
+ },
+}));
+
+// CodeMirror needs layout APIs jsdom doesn't provide.
+jest.mock('@uiw/react-codemirror', () => ({
+ __esModule: true,
+ default: ({
+ value,
+ onChange,
+ }: {
+ value?: string;
+ onChange?: (value: string) => void;
+ }) => (
+