diff --git a/README.md b/README.md
index bcbd557d4..f055f6f39 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,9 @@ Usage instructions can be found [here](https://checkmate.so/docs).
## Installation
-The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB:
+The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service.
+
+> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance.
```bash
curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml
@@ -151,7 +153,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
- Selective disk monitoring with mountpoint selection
- Incidents at a glance
- Status pages with 4 beautiful themes
-- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
+- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications
- Scheduled maintenance
- JSON query monitoring
- Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese
diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts
index fdd8204d3..112853a59 100644
--- a/client/src/Hooks/useNotificationForm.ts
+++ b/client/src/Hooks/useNotificationForm.ts
@@ -8,8 +8,6 @@ interface UseNotificationFormOptions {
}
function buildDefaults(data: Notification | null): NotificationFormData {
- // Every channel field defaults to "" (not undefined) so untouched required
- // fields fail zod with their custom messages instead of a type error.
return {
type: data?.type ?? "email",
notificationName: data?.notificationName || "",
diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts
index 0e389f9e2..a118c4a3d 100644
--- a/client/src/Types/Notification.ts
+++ b/client/src/Types/Notification.ts
@@ -3,6 +3,7 @@ export const NotificationChannels = [
"slack",
"discord",
"webhook",
+ "rocket_chat",
"pager_duty",
"matrix",
"teams",
diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts
index 6526fa0dd..f7490d526 100644
--- a/client/src/Validation/notifications.ts
+++ b/client/src/Validation/notifications.ts
@@ -30,6 +30,14 @@ const webhookSchema = baseSchema.extend({
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
});
+const rocketChatSchema = baseSchema.extend({
+ type: z.literal("rocket_chat"),
+ address: z
+ .string()
+ .min(1, "Webhook URL is required")
+ .url({ protocol: /^https?$/, message: "Please enter a valid URL" }),
+});
+
const pagerDutySchema = baseSchema.extend({
type: z.literal("pager_duty"),
address: z.string().min(1, "Integration key is required"),
@@ -81,6 +89,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
slackSchema,
discordSchema,
webhookSchema,
+ rocketChatSchema,
pagerDutySchema,
matrixSchema,
teamsSchema,
diff --git a/server/openapi.json b/server/openapi.json
index 28a22f394..627354c55 100644
--- a/server/openapi.json
+++ b/server/openapi.json
@@ -34,7 +34,7 @@
},
{
"name": "notifications",
- "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts."
+ "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts."
},
{
"name": "maintenance-window",
@@ -879,6 +879,9 @@
{
"$ref": "#/components/schemas/WebhookNotification"
},
+ {
+ "$ref": "#/components/schemas/RocketChatNotification"
+ },
{
"$ref": "#/components/schemas/SlackNotification"
},
@@ -912,6 +915,7 @@
"mapping": {
"email": "#/components/schemas/EmailNotification",
"webhook": "#/components/schemas/WebhookNotification",
+ "rocket_chat": "#/components/schemas/RocketChatNotification",
"slack": "#/components/schemas/SlackNotification",
"discord": "#/components/schemas/DiscordNotification",
"pager_duty": "#/components/schemas/PagerDutyNotification",
@@ -1036,6 +1040,29 @@
"address": "https://example.com/hooks/checkmate"
}
},
+ "RocketChatNotification": {
+ "type": "object",
+ "properties": {
+ "notificationName": {
+ "type": "string",
+ "minLength": 1
+ },
+ "type": {
+ "type": "string",
+ "enum": ["rocket_chat"]
+ },
+ "address": {
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "required": ["notificationName", "type", "address"],
+ "example": {
+ "notificationName": "Rocket.Chat alerts",
+ "type": "rocket_chat",
+ "address": "https://chat.example.com/hooks/integration-id/token"
+ }
+ },
"SlackNotification": {
"type": "object",
"properties": {
diff --git a/server/openapi/index.ts b/server/openapi/index.ts
index ef3e0b1d0..c96cec233 100644
--- a/server/openapi/index.ts
+++ b/server/openapi/index.ts
@@ -39,7 +39,8 @@ export function getOpenApiSpec(): JsonObject {
{ name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." },
{
name: "notifications",
- description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.",
+ description:
+ "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts.",
},
{ name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." },
{ name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." },
diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts
index f1c70ca6d..7da45adfb 100644
--- a/server/openapi/routes/notification.ts
+++ b/server/openapi/routes/notification.ts
@@ -23,6 +23,14 @@ const notificationVariantMeta: Record(
"slack",
"discord",
"webhook",
+ "rocket_chat",
"pager_duty",
"matrix",
"teams",
diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts
index 8ae617a88..7c4c9f75d 100644
--- a/server/src/domain/notifications/notification.type.ts
+++ b/server/src/domain/notifications/notification.type.ts
@@ -3,6 +3,7 @@ export const NotificationChannels = [
"slack",
"discord",
"webhook",
+ "rocket_chat",
"pager_duty",
"matrix",
"teams",
diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts
new file mode 100644
index 000000000..345eec8c3
--- /dev/null
+++ b/server/src/domain/notifications/providers/rocketChat.ts
@@ -0,0 +1,90 @@
+const SERVICE_NAME = "RocketChatProvider";
+import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js";
+import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js";
+import { getTestMessage } from "@/domain/notifications/providers/utils.js";
+import got from "got";
+
+export class RocketChatProvider extends NotificationProvider {
+ async sendTestAlert(notification: Partial): Promise {
+ if (!notification.address) {
+ return false;
+ }
+
+ try {
+ await got.post(notification.address, {
+ json: { text: getTestMessage() },
+ headers: {
+ "Content-Type": "application/json",
+ },
+ ...this.gotRequestOptions(),
+ });
+ return true;
+ } catch (error) {
+ const err = error as Error;
+ this.logger.warn({
+ message: "Rocket.Chat test alert failed",
+ service: SERVICE_NAME,
+ method: "sendTestAlert",
+ stack: err?.stack,
+ });
+ return false;
+ }
+ }
+
+ async sendMessage(notification: Notification, message: NotificationMessage): Promise {
+ if (!notification.address) {
+ return false;
+ }
+
+ try {
+ await got.post(notification.address, {
+ json: { text: this.buildText(message) },
+ headers: {
+ "Content-Type": "application/json",
+ },
+ ...this.gotRequestOptions(),
+ });
+ return true;
+ } catch (error) {
+ const err = error as Error;
+ this.logger.warn({
+ message: "Rocket.Chat notification failed",
+ service: SERVICE_NAME,
+ method: "sendMessage",
+ stack: err?.stack,
+ });
+ return false;
+ }
+ }
+
+ private buildText(message: NotificationMessage): string {
+ const lines = [
+ message.content.title,
+ message.content.summary,
+ "",
+ `Monitor: ${message.monitor.name}`,
+ `Type: ${message.monitor.type}`,
+ `Status: ${message.monitor.status}`,
+ `URL: ${message.monitor.url}`,
+ ];
+
+ if (message.content.details?.length) {
+ lines.push("", "Details:", ...message.content.details.map((detail) => `- ${detail}`));
+ }
+ if (message.content.thresholds?.length) {
+ lines.push(
+ "",
+ "Thresholds:",
+ ...message.content.thresholds.map(
+ (threshold) => `- ${threshold.metric.toUpperCase()}: ${threshold.formattedValue} (threshold: ${threshold.threshold}${threshold.unit})`
+ )
+ );
+ }
+
+ if (message.content.incident) {
+ lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`);
+ }
+
+ return lines.join("\n");
+ }
+}
diff --git a/server/test/unit/notifications/notificationType.test.ts b/server/test/unit/notifications/notificationType.test.ts
new file mode 100644
index 000000000..0029fbd46
--- /dev/null
+++ b/server/test/unit/notifications/notificationType.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "@jest/globals";
+import { NotificationModel } from "@/domain/notifications/notification.model.js";
+import { NotificationChannels } from "@/domain/notifications/notification.type.js";
+
+describe("NotificationChannels", () => {
+ it("includes Rocket.Chat", () => {
+ expect(NotificationChannels).toContain("rocket_chat");
+ });
+
+ it("allows Rocket.Chat to be persisted", () => {
+ const typePath = NotificationModel.schema.path("type") as unknown as { enumValues: string[] };
+
+ expect(typePath.enumValues).toContain("rocket_chat");
+ });
+});
diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts
new file mode 100644
index 000000000..ac9ffc4c1
--- /dev/null
+++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it, jest } from "@jest/globals";
+import { createMockLogger } from "../../../helpers/createMockLogger.ts";
+import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts";
+import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts";
+
+const mockGotPost = jest.fn().mockResolvedValue({});
+jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } }));
+
+const { RocketChatProvider } = await import("../../../../src/domain/notifications/providers/rocketChat.ts");
+
+const createProvider = () => {
+ const logger = createMockLogger();
+ const provider = new RocketChatProvider(logger as any);
+ return { provider, logger };
+};
+
+testNotificationProviderContract("RocketChatProvider", {
+ create: () => {
+ mockGotPost.mockResolvedValue({});
+ return createProvider().provider;
+ },
+ makeNotification: () => makeNotification({ type: "rocket_chat" }),
+});
+
+describe("RocketChatProvider", () => {
+ beforeEach(() => mockGotPost.mockReset().mockResolvedValue({}));
+
+ it("sends a text-only test alert", async () => {
+ const { provider } = createProvider();
+
+ const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" }));
+
+ expect(result).toBe(true);
+ expect(mockGotPost).toHaveBeenCalledWith(
+ "https://hooks.example.com/webhook",
+ expect.objectContaining({
+ json: { text: "This is a test notification from Checkmate" },
+ timeout: { request: 30000 },
+ retry: { limit: 0 },
+ })
+ );
+ });
+
+ it("returns false when the test webhook URL is missing", async () => {
+ const { provider } = createProvider();
+
+ const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat", address: "" }));
+
+ expect(result).toBe(false);
+ expect(mockGotPost).not.toHaveBeenCalled();
+ });
+
+ it("returns false and logs when test delivery fails", async () => {
+ mockGotPost.mockRejectedValue(new Error("network failure"));
+ const { provider, logger } = createProvider();
+
+ const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" }));
+
+ expect(result).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: "Rocket.Chat test alert failed",
+ service: "RocketChatProvider",
+ method: "sendTestAlert",
+ })
+ );
+ });
+
+ it("sends monitor details as a text-only message", async () => {
+ const { provider } = createProvider();
+
+ const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage());
+
+ expect(result).toBe(true);
+ expect(mockGotPost).toHaveBeenCalledWith(
+ "https://hooks.example.com/webhook",
+ expect.objectContaining({
+ timeout: { request: 30000 },
+ retry: { limit: 0 },
+ json: {
+ text: [
+ "Monitor Down: Test Monitor",
+ 'Monitor "Test Monitor" is currently down.',
+ "",
+ "Monitor: Test Monitor",
+ "Type: http",
+ "Status: down",
+ "URL: https://example.com",
+ "",
+ "Details:",
+ "- URL: https://example.com",
+ "- Status: Down",
+ ].join("\n"),
+ },
+ })
+ );
+ });
+
+ it("returns false when the message webhook URL is missing", async () => {
+ const { provider } = createProvider();
+
+ const result = await provider.sendMessage(makeNotification({ type: "rocket_chat", address: "" }), makeMessage());
+
+ expect(result).toBe(false);
+ expect(mockGotPost).not.toHaveBeenCalled();
+ });
+
+ it("includes threshold breaches in the text", async () => {
+ const { provider } = createProvider();
+
+ await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds());
+
+ const payload = mockGotPost.mock.calls[0][1].json;
+ expect(payload.text).toContain("Thresholds:\n- CPU: 90.0% (threshold: 80%)\n- MEMORY: 85.0% (threshold: 70%)");
+ });
+
+ it("includes a monitor-scoped link when incident data is present", async () => {
+ const { provider } = createProvider();
+
+ await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithIncident());
+
+ const payload = mockGotPost.mock.calls[0][1].json;
+ expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1");
+ });
+
+ it("omits the incident link when incident data is absent", async () => {
+ const { provider } = createProvider();
+
+ await provider.sendMessage(
+ makeNotification({ type: "rocket_chat" }),
+ makeMessage({
+ type: "monitor_up",
+ severity: "success",
+ monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "up" },
+ })
+ );
+
+ const payload = mockGotPost.mock.calls[0][1].json;
+ expect(payload.text).not.toContain("Incident:");
+ });
+
+ it("returns false and logs when message delivery fails", async () => {
+ mockGotPost.mockRejectedValue(new Error("network failure"));
+ const { provider, logger } = createProvider();
+
+ const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage());
+
+ expect(result).toBe(false);
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: "Rocket.Chat notification failed",
+ service: "RocketChatProvider",
+ method: "sendMessage",
+ })
+ );
+ });
+});
diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts
new file mode 100644
index 000000000..5cb2c1d59
--- /dev/null
+++ b/server/test/unit/validation/notificationValidation.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "@jest/globals";
+import { createNotificationBodyValidation } from "@/api/validation/notificationValidation.js";
+
+describe("notification validation", () => {
+ it("accepts a Rocket.Chat incoming webhook", () => {
+ const result = createNotificationBodyValidation.safeParse({
+ notificationName: "Rocket.Chat alerts",
+ type: "rocket_chat",
+ address: "https://chat.example.com/hooks/integration-id/token",
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it("accepts a self-hosted Rocket.Chat webhook over HTTP", () => {
+ const result = createNotificationBodyValidation.safeParse({
+ notificationName: "Local Rocket.Chat alerts",
+ type: "rocket_chat",
+ address: "http://localhost:3000/hooks/integration-id/token",
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects an invalid Rocket.Chat incoming webhook URL", () => {
+ const result = createNotificationBodyValidation.safeParse({
+ notificationName: "Rocket.Chat alerts",
+ type: "rocket_chat",
+ address: "not-a-url",
+ });
+
+ expect(result.success).toBe(false);
+ });
+
+ it.each(["ftp://chat.example.com/hooks/integration-id/token", "mailto:admin@example.com", "data:text/plain,hello", "javascript:alert(1)"])(
+ "rejects a Rocket.Chat webhook using an unsupported protocol: %s",
+ (address) => {
+ const result = createNotificationBodyValidation.safeParse({
+ notificationName: "Rocket.Chat alerts",
+ type: "rocket_chat",
+ address,
+ });
+
+ expect(result.success).toBe(false);
+ }
+ );
+});