feat: Add list Webhooks API (#1802)

Closes HDX-3450

# Summary

This PR adds a list webhooks endpoint to the external API, to better support alert creation via the API (which requires a webhook ID).

<img width="2275" height="1048" alt="Screenshot 2026-02-25 at 11 54 54 AM" src="https://github.com/user-attachments/assets/a9a4f1cb-d3e9-4d18-bb9c-6647b784adf9" />

<img width="1463" height="1057" alt="Screenshot 2026-02-25 at 11 55 45 AM" src="https://github.com/user-attachments/assets/b7864e67-b8d0-4f71-aedf-04d2d13e27f1" />
This commit is contained in:
Drew Davis 2026-02-26 07:20:45 -05:00 committed by GitHub
parent 3e8cc729d1
commit 34c9afebe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 737 additions and 0 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
---
feat: Add list webhooks API

View file

@ -75,9 +75,11 @@ yarn test:e2e:ci # Run end-to-end tests in CI
**packages/api** (integration tests only):
```bash
docker compose -f ./docker-compose.ci.yml up -d # Start the integration test docker services
cd packages/api
yarn ci:int # Run integration tests
yarn dev:int # Watch mode for integration tests
cd ../.. && docker compose -f ./docker-compose.ci.yml down # Stop the integration test docker services
```
**packages/common-utils** (both unit and integration tests):

View file

@ -2230,6 +2230,187 @@
}
}
}
},
"SlackWebhook": {
"type": "object",
"required": [
"id",
"name",
"service",
"updatedAt",
"createdAt"
],
"properties": {
"id": {
"type": "string",
"description": "Webhook ID"
},
"name": {
"type": "string",
"description": "Webhook name"
},
"service": {
"type": "string",
"enum": [
"slack"
],
"description": "Webhook service type"
},
"url": {
"type": "string",
"description": "Slack incoming webhook URL"
},
"description": {
"type": "string",
"description": "Webhook description, shown in the UI"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Last update timestamp"
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Creation timestamp"
}
}
},
"IncidentIOWebhook": {
"type": "object",
"required": [
"id",
"name",
"service",
"updatedAt",
"createdAt"
],
"properties": {
"id": {
"type": "string",
"description": "Webhook ID"
},
"name": {
"type": "string",
"description": "Webhook name"
},
"service": {
"type": "string",
"enum": [
"incidentio"
],
"description": "Webhook service type"
},
"url": {
"type": "string",
"description": "incident.io alert event HTTP source URL"
},
"description": {
"type": "string",
"description": "Webhook description, shown in the UI"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Last update timestamp"
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Creation timestamp"
}
}
},
"GenericWebhook": {
"type": "object",
"required": [
"id",
"name",
"service",
"updatedAt",
"createdAt"
],
"properties": {
"id": {
"type": "string",
"description": "Webhook ID"
},
"name": {
"type": "string",
"description": "Webhook name"
},
"service": {
"type": "string",
"enum": [
"generic"
],
"description": "Webhook service type"
},
"url": {
"type": "string",
"description": "Webhook destination URL"
},
"description": {
"type": "string",
"description": "Webhook description, shown in the UI"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Optional headers to include in the webhook request"
},
"body": {
"type": "string",
"description": "Optional request body template"
},
"updatedAt": {
"type": "string",
"format": "date-time",
"description": "Last update timestamp"
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Creation timestamp"
}
}
},
"Webhook": {
"oneOf": [
{
"$ref": "#/components/schemas/SlackWebhook"
},
{
"$ref": "#/components/schemas/IncidentIOWebhook"
},
{
"$ref": "#/components/schemas/GenericWebhook"
}
],
"discriminator": {
"propertyName": "service",
"mapping": {
"slack": "#/components/schemas/SlackWebhook",
"incidentio": "#/components/schemas/IncidentIOWebhook",
"generic": "#/components/schemas/GenericWebhook"
}
}
},
"WebhooksListResponse": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Webhook"
}
}
}
}
}
},
@ -3601,6 +3782,51 @@
}
}
}
},
"/api/v2/webhooks": {
"get": {
"summary": "List Webhooks",
"description": "Retrieves a list of all webhooks for the authenticated team",
"operationId": "listWebhooks",
"tags": [
"Webhooks"
],
"responses": {
"200": {
"description": "Successfully retrieved webhooks",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhooksListResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"example": {
"message": "Unauthorized access. API key is missing or invalid."
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}

View file

@ -68,6 +68,8 @@ const WebhookSchema = new Schema<IWebhook>(
{ timestamps: true },
);
export type WebhookDocument = mongoose.HydratedDocument<IWebhook>;
WebhookSchema.index({ team: 1, service: 1, name: 1 }, { unique: true });
export default mongoose.model<IWebhook>('Webhook', WebhookSchema);

View file

@ -0,0 +1,240 @@
import { WebhookService } from '@hyperdx/common-utils/dist/types';
import { ObjectId } from 'mongodb';
import request, { SuperAgentTest } from 'supertest';
import { getLoggedInAgent, getServer } from '../../../fixtures';
import { ITeam } from '../../../models/team';
import { IUser } from '../../../models/user';
import Webhook from '../../../models/webhook';
const WEBHOOKS_BASE_URL = '/api/v2/webhooks';
const MOCK_SLACK_WEBHOOK = {
name: 'Test Slack Webhook',
service: WebhookService.Slack,
url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX',
description: 'Test webhook for Slack',
};
const MOCK_INCIDENT_IO_WEBHOOK = {
name: 'Test IncidentIO Webhook',
service: WebhookService.IncidentIO,
url: 'https://api.incident.io/v2/alert_events/http/ZZZZZZZZ?token=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
description: 'Test webhook for incident.io',
};
const MOCK_GENERIC_WEBHOOK = {
name: 'Test Generic Webhook',
service: WebhookService.Generic,
url: 'https://example.com/webhook',
description: 'Test generic webhook',
headers: { 'X-Custom-Header': 'Header Value', Authorization: 'Bearer token' },
body: '{"text": "{{title}} | {{body}} | {{link}}"}',
};
describe('External API v2 Webhooks', () => {
const server = getServer();
let agent: SuperAgentTest;
let team: ITeam;
let user: IUser;
beforeAll(async () => {
await server.start();
});
beforeEach(async () => {
const result = await getLoggedInAgent(server);
agent = result.agent;
team = result.team;
user = result.user;
});
afterEach(async () => {
await server.clearDBs();
});
afterAll(async () => {
await server.stop();
});
const authRequest = (
method: 'get' | 'post' | 'put' | 'delete',
url: string,
) => {
return agent[method](url).set('Authorization', `Bearer ${user?.accessKey}`);
};
describe('GET /api/v2/webhooks', () => {
it('should return an empty list when no webhooks exist', async () => {
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.headers['content-type']).toMatch(/application\/json/);
expect(response.body).toEqual({ data: [] });
});
it('should list a Slack webhook with only Slack-allowed fields', async () => {
await Webhook.create({ ...MOCK_SLACK_WEBHOOK, team: team._id });
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0]).toMatchObject({
id: expect.any(String),
name: MOCK_SLACK_WEBHOOK.name,
service: WebhookService.Slack,
url: MOCK_SLACK_WEBHOOK.url,
description: MOCK_SLACK_WEBHOOK.description,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(response.body.data[0]).not.toHaveProperty('headers');
expect(response.body.data[0]).not.toHaveProperty('body');
});
it('should strip headers and body stored on a Slack webhook', async () => {
await Webhook.create({
...MOCK_SLACK_WEBHOOK,
headers: { 'X-Secret': 'secret' },
body: '{"text": "hello"}',
team: team._id,
});
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data[0]).not.toHaveProperty('headers');
expect(response.body.data[0]).not.toHaveProperty('body');
});
it('should list an IncidentIO webhook with only IncidentIO-allowed fields', async () => {
await Webhook.create({ ...MOCK_INCIDENT_IO_WEBHOOK, team: team._id });
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0]).toMatchObject({
id: expect.any(String),
name: MOCK_INCIDENT_IO_WEBHOOK.name,
service: WebhookService.IncidentIO,
url: MOCK_INCIDENT_IO_WEBHOOK.url,
description: MOCK_INCIDENT_IO_WEBHOOK.description,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(response.body.data[0]).not.toHaveProperty('headers');
expect(response.body.data[0]).not.toHaveProperty('body');
});
it('should strip headers and body stored on an IncidentIO webhook', async () => {
await Webhook.create({
...MOCK_INCIDENT_IO_WEBHOOK,
headers: { 'X-Secret': 'secret' },
body: '{"title": "{{title}}"}',
team: team._id,
});
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data[0]).not.toHaveProperty('headers');
expect(response.body.data[0]).not.toHaveProperty('body');
});
it('should list a Generic webhook with headers and body', async () => {
await Webhook.create({ ...MOCK_GENERIC_WEBHOOK, team: team._id });
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0]).toMatchObject({
id: expect.any(String),
name: MOCK_GENERIC_WEBHOOK.name,
service: WebhookService.Generic,
url: MOCK_GENERIC_WEBHOOK.url,
description: MOCK_GENERIC_WEBHOOK.description,
headers: MOCK_GENERIC_WEBHOOK.headers,
body: MOCK_GENERIC_WEBHOOK.body,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
it('should return multiple webhooks of different service types', async () => {
await Webhook.create({ ...MOCK_SLACK_WEBHOOK, team: team._id });
await Webhook.create({ ...MOCK_INCIDENT_IO_WEBHOOK, team: team._id });
await Webhook.create({ ...MOCK_GENERIC_WEBHOOK, team: team._id });
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data).toHaveLength(3);
const names = response.body.data.map((w: { name: string }) => w.name);
expect(names).toContain(MOCK_SLACK_WEBHOOK.name);
expect(names).toContain(MOCK_INCIDENT_IO_WEBHOOK.name);
expect(names).toContain(MOCK_GENERIC_WEBHOOK.name);
});
it('should not return webhooks belonging to another team', async () => {
await Webhook.create({ ...MOCK_SLACK_WEBHOOK, team: team._id });
const otherTeamId = new ObjectId();
await Webhook.create({
...MOCK_SLACK_WEBHOOK,
name: 'Other Team Webhook',
team: otherTeamId,
});
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].name).toBe(MOCK_SLACK_WEBHOOK.name);
});
it('should work with a minimal Slack webhook (no optional fields)', async () => {
await Webhook.create({
name: 'Minimal Slack Webhook',
service: WebhookService.Slack,
team: team._id,
});
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0]).toMatchObject({
id: expect.any(String),
name: 'Minimal Slack Webhook',
service: WebhookService.Slack,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(response.body.data[0]).not.toHaveProperty('url');
expect(response.body.data[0]).not.toHaveProperty('description');
expect(response.body.data[0]).not.toHaveProperty('headers');
expect(response.body.data[0]).not.toHaveProperty('body');
});
it('should work with a minimal Generic webhook (no optional fields)', async () => {
await Webhook.create({
name: 'Minimal Generic Webhook',
service: WebhookService.Generic,
team: team._id,
});
const response = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0]).toMatchObject({
id: expect.any(String),
name: 'Minimal Generic Webhook',
service: WebhookService.Generic,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
expect(response.body.data[0]).not.toHaveProperty('url');
expect(response.body.data[0]).not.toHaveProperty('description');
expect(response.body.data[0]).not.toHaveProperty('headers');
expect(response.body.data[0]).not.toHaveProperty('body');
});
it('should require authentication', async () => {
await request(server.getHttpServer()).get(WEBHOOKS_BASE_URL).expect(401);
});
});
});

View file

@ -5,6 +5,7 @@ import alertsRouter from '@/routers/external-api/v2/alerts';
import chartsRouter from '@/routers/external-api/v2/charts';
import dashboardRouter from '@/routers/external-api/v2/dashboards';
import sourcesRouter from '@/routers/external-api/v2/sources';
import webhooksRouter from '@/routers/external-api/v2/webhooks';
import rateLimiter from '@/utils/rateLimiter';
const router = express.Router();
@ -46,4 +47,11 @@ router.use(
sourcesRouter,
);
router.use(
'/webhooks',
defaultRateLimiter,
validateUserAccessKey,
webhooksRouter,
);
export default router;

View file

@ -0,0 +1,213 @@
import express from 'express';
import { WebhookDocument } from '@/models/webhook';
import Webhook from '@/models/webhook';
import logger from '@/utils/logger';
import { ExternalWebhook, externalWebhookSchema } from '@/utils/zod';
function formatExternalWebhook(
webhook: WebhookDocument,
): ExternalWebhook | undefined {
// Convert to JSON so that any ObjectIds are converted to strings ("_id" is also converted to "id")
const json = JSON.stringify(webhook.toJSON({ getters: true }));
// Parse using the externalWebhookSchema to strip out any fields not defined in the schema
const parseResult = externalWebhookSchema.safeParse(JSON.parse(json));
if (parseResult.success) {
return parseResult.data;
}
// If parsing fails, log the error and return undefined
logger.error(
{ webhook, error: parseResult.error },
'Failed to parse webhook using externalWebhookSchema:',
);
return undefined;
}
/**
* @openapi
* components:
* schemas:
* SlackWebhook:
* type: object
* required:
* - id
* - name
* - service
* - updatedAt
* - createdAt
* properties:
* id:
* type: string
* description: Webhook ID
* name:
* type: string
* description: Webhook name
* service:
* type: string
* enum: [slack]
* description: Webhook service type
* url:
* type: string
* description: Slack incoming webhook URL
* description:
* type: string
* description: Webhook description, shown in the UI
* updatedAt:
* type: string
* format: date-time
* description: Last update timestamp
* createdAt:
* type: string
* format: date-time
* description: Creation timestamp
* IncidentIOWebhook:
* type: object
* required:
* - id
* - name
* - service
* - updatedAt
* - createdAt
* properties:
* id:
* type: string
* description: Webhook ID
* name:
* type: string
* description: Webhook name
* service:
* type: string
* enum: [incidentio]
* description: Webhook service type
* url:
* type: string
* description: incident.io alert event HTTP source URL
* description:
* type: string
* description: Webhook description, shown in the UI
* updatedAt:
* type: string
* format: date-time
* description: Last update timestamp
* createdAt:
* type: string
* format: date-time
* description: Creation timestamp
* GenericWebhook:
* type: object
* required:
* - id
* - name
* - service
* - updatedAt
* - createdAt
* properties:
* id:
* type: string
* description: Webhook ID
* name:
* type: string
* description: Webhook name
* service:
* type: string
* enum: [generic]
* description: Webhook service type
* url:
* type: string
* description: Webhook destination URL
* description:
* type: string
* description: Webhook description, shown in the UI
* headers:
* type: object
* additionalProperties:
* type: string
* description: Optional headers to include in the webhook request
* body:
* type: string
* description: Optional request body template
* updatedAt:
* type: string
* format: date-time
* description: Last update timestamp
* createdAt:
* type: string
* format: date-time
* description: Creation timestamp
* Webhook:
* oneOf:
* - $ref: '#/components/schemas/SlackWebhook'
* - $ref: '#/components/schemas/IncidentIOWebhook'
* - $ref: '#/components/schemas/GenericWebhook'
* discriminator:
* propertyName: service
* mapping:
* slack: '#/components/schemas/SlackWebhook'
* incidentio: '#/components/schemas/IncidentIOWebhook'
* generic: '#/components/schemas/GenericWebhook'
* WebhooksListResponse:
* type: object
* required:
* - data
* properties:
* data:
* type: array
* items:
* $ref: '#/components/schemas/Webhook'
*/
const router = express.Router();
/**
* @openapi
* /api/v2/webhooks:
* get:
* summary: List Webhooks
* description: Retrieves a list of all webhooks for the authenticated team
* operationId: listWebhooks
* tags: [Webhooks]
* responses:
* '200':
* description: Successfully retrieved webhooks
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/WebhooksListResponse'
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* example:
* message: "Unauthorized access. API key is missing or invalid."
* '403':
* description: Forbidden
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/', async (req, res, next) => {
try {
const teamId = req.user?.team;
if (teamId == null) {
return res.sendStatus(403);
}
const webhooks: WebhookDocument[] = await Webhook.find({
team: teamId.toString(),
});
return res.json({
data: webhooks.map(formatExternalWebhook).filter(s => s !== undefined),
});
} catch (e) {
next(e);
}
});
export default router;

View file

@ -4,6 +4,7 @@ import {
MetricsDataType,
NumberFormatSchema,
SearchConditionLanguageSchema as whereLanguageSchema,
WebhookService,
} from '@hyperdx/common-utils/dist/types';
import { Types } from 'mongoose';
import { z } from 'zod';
@ -405,3 +406,41 @@ export const alertSchema = z
message: z.string().min(1).max(4096).nullish(),
})
.and(zSavedSearchAlert.or(zTileAlert));
// ==============================
// Webhooks
// ==============================
const baseWebhookSchema = {
id: z.string(),
name: z.string(),
url: z.string().optional(),
description: z.string().optional(),
updatedAt: z.string(),
createdAt: z.string(),
};
const slackWebhookSchema = z.object({
...baseWebhookSchema,
service: z.literal(WebhookService.Slack),
});
const incidentIOWebhookSchema = z.object({
...baseWebhookSchema,
service: z.literal(WebhookService.IncidentIO),
});
const genericWebhookSchema = z.object({
...baseWebhookSchema,
service: z.literal(WebhookService.Generic),
headers: z.record(z.string(), z.string()).optional(),
body: z.string().optional(),
});
export const externalWebhookSchema = z.discriminatedUnion('service', [
slackWebhookSchema,
incidentIOWebhookSchema,
genericWebhookSchema,
]);
export type ExternalWebhook = z.infer<typeof externalWebhookSchema>;

View file

@ -251,6 +251,7 @@ export enum WebhookService {
}
// Base webhook interface (matches backend IWebhook but with JSON-serialized types)
// When making changes here, consider if they need to be made to the external API schema as well (packages/api/src/utils/zod.ts).
export interface IWebhook {
_id: string;
createdAt: string;