diff --git a/docs/widgets/services/ntfy.md b/docs/widgets/services/ntfy.md
new file mode 100644
index 000000000..e8d243705
--- /dev/null
+++ b/docs/widgets/services/ntfy.md
@@ -0,0 +1,36 @@
+---
+title: ntfy
+description: ntfy Widget Configuration
+---
+
+Learn more about [ntfy](https://github.com/binwiederhier/ntfy).
+
+This widget shows the latest notification for a ntfy topic, including the title or body, priority level, and when it was received. Works with both self-hosted ntfy instances and the public [ntfy.sh](https://ntfy.sh) service.
+
+Allowed fields: `["title", "message", "priority", "lastReceived", "tags"]`.
+
+Default fields: `["title", "message", "priority", "lastReceived"]`.
+
+If more than 4 fields are provided, only the first 4 are displayed.
+
+## Authentication
+
+ntfy supports both public and private topics. For private instances or access-controlled topics, you can authenticate using either a **Bearer token** (ntfy access token) or **Basic auth** (username/password).
+
+| Auth Method | Config Fields | Notes |
+| ------------ | ------------------------------ | --------------------------------- |
+| None | _(omit key/username/password)_ | For public topics |
+| Bearer token | `key` | ntfy access tokens (`tk_` prefix) |
+| Basic auth | `username` + `password` | Username/password credentials |
+
+See the [ntfy documentation](https://docs.ntfy.sh/config/#access-control) for details on access control.
+
+```yaml
+widget:
+ type: ntfy
+ url: http://ntfy.host.or.ip:port # required
+ topic: mytopic # required
+ # key: tk_accesstoken # optional — for token auth
+ # username: user # optional — for basic auth
+ # password: pass # optional — for basic auth
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index 1933825c5..e954e0219 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -116,6 +116,7 @@ nav:
- widgets/services/nextcloud.md
- widgets/services/nextdns.md
- widgets/services/nginx-proxy-manager.md
+ - widgets/services/ntfy.md
- widgets/services/nzbget.md
- widgets/services/octoprint.md
- widgets/services/omada.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 6d2204774..8908133b8 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -936,6 +936,19 @@
"warnings": "Warnings",
"criticals": "Criticals"
},
+ "ntfy": {
+ "title": "Title",
+ "priority": "Priority",
+ "lastReceived": "Last Received",
+ "message": "Message",
+ "tags": "Tags",
+ "none": "None",
+ "min": "Min",
+ "low": "Low",
+ "default": "Default",
+ "high": "High",
+ "urgent": "Urgent"
+ },
"plantit": {
"events": "Events",
"plants": "Plants",
diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js
index 27f02f3e5..afe53457d 100644
--- a/src/utils/proxy/handlers/credentialed.js
+++ b/src/utils/proxy/handlers/credentialed.js
@@ -77,6 +77,12 @@ export default async function credentialedProxyHandler(req, res, map) {
} else {
headers.Authorization = basicAuthHeader(widget);
}
+ } else if (widget.type === "ntfy") {
+ if (widget.key) {
+ headers.Authorization = `Bearer ${widget.key}`;
+ } else if (widget.username && widget.password) {
+ headers.Authorization = basicAuthHeader(widget);
+ }
} else if (widget.type === "proxmox") {
headers.Authorization = `PVEAPIToken=${widget.username}=${widget.password}`;
} else if (widget.type === "proxmoxbackupserver") {
diff --git a/src/utils/proxy/handlers/credentialed.test.js b/src/utils/proxy/handlers/credentialed.test.js
index 72e7b5e3d..ef720669a 100644
--- a/src/utils/proxy/handlers/credentialed.test.js
+++ b/src/utils/proxy/handlers/credentialed.test.js
@@ -34,6 +34,7 @@ vi.mock("widgets/widgets", () => ({
paperlessngx: { api: "{url}/api/{endpoint}" },
proxmox: { api: "{url}/api2/json/{endpoint}" },
truenas: { api: "{url}/api/v2.0/{endpoint}" },
+ ntfy: { api: "{url}/{endpoint}" },
proxmoxbackupserver: { api: "{url}/api2/json/{endpoint}" },
checkmk: { api: "{url}/{endpoint}" },
stocks: { api: "{url}/{endpoint}" },
@@ -185,6 +186,51 @@ describe("utils/proxy/handlers/credentialed", () => {
expect(params.headers.Authorization).toBe("Bearer k");
});
+ it("uses Bearer auth for ntfy when key is provided", async () => {
+ getServiceWidget.mockResolvedValue({ type: "ntfy", url: "http://ntfy", topic: "alerts", key: "tk_test" });
+ httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
+
+ const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } };
+ const res = createMockRes();
+
+ await credentialedProxyHandler(req, res);
+
+ const [, params] = httpProxy.mock.calls.at(-1);
+ expect(params.headers.Authorization).toBe("Bearer tk_test");
+ });
+
+ it("uses Basic auth for ntfy when username/password are provided", async () => {
+ getServiceWidget.mockResolvedValue({
+ type: "ntfy",
+ url: "http://ntfy",
+ topic: "alerts",
+ username: "u",
+ password: "p",
+ });
+ httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
+
+ const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } };
+ const res = createMockRes();
+
+ await credentialedProxyHandler(req, res);
+
+ const [, params] = httpProxy.mock.calls.at(-1);
+ expect(params.headers.Authorization).toMatch(/^Basic /);
+ });
+
+ it("sends no auth header for ntfy when no credentials are configured", async () => {
+ getServiceWidget.mockResolvedValue({ type: "ntfy", url: "http://ntfy", topic: "alerts" });
+ httpProxy.mockResolvedValue([200, "application/json", { ok: true }]);
+
+ const req = { method: "GET", query: { group: "g", service: "s", endpoint: "alerts/json", index: 0 } };
+ const res = createMockRes();
+
+ await credentialedProxyHandler(req, res);
+
+ const [, params] = httpProxy.mock.calls.at(-1);
+ expect(params.headers.Authorization).toBeUndefined();
+ });
+
it.each([
[{ type: "paperlessngx", url: "http://x", key: "k" }, { Authorization: "Token k" }],
[
diff --git a/src/widgets/components.js b/src/widgets/components.js
index c5f144e39..fbc34d3a2 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -91,6 +91,7 @@ const components = {
nextcloud: dynamic(() => import("./nextcloud/component")),
nextdns: dynamic(() => import("./nextdns/component")),
npm: dynamic(() => import("./npm/component")),
+ ntfy: dynamic(() => import("./ntfy/component")),
nzbget: dynamic(() => import("./nzbget/component")),
octoprint: dynamic(() => import("./octoprint/component")),
omada: dynamic(() => import("./omada/component")),
diff --git a/src/widgets/ntfy/component.jsx b/src/widgets/ntfy/component.jsx
new file mode 100644
index 000000000..e4f2b191a
--- /dev/null
+++ b/src/widgets/ntfy/component.jsx
@@ -0,0 +1,63 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import { useTranslation } from "next-i18next";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+const priorityLabels = {
+ 1: "min",
+ 2: "low",
+ 3: "default",
+ 4: "high",
+ 5: "urgent",
+};
+
+function Truncated({ text }) {
+ return (
+
+ {text}
+
+ );
+}
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+
+ const { data: messagesData, error: messagesError } = useWidgetAPI(widget, "messages");
+
+ if (messagesError) {
+ return ;
+ }
+
+ if (!widget.fields || widget.fields.length === 0) {
+ widget.fields = ["title", "message", "priority", "lastReceived"];
+ } else if (widget.fields?.length > 4) {
+ widget.fields = widget.fields.slice(0, 4);
+ }
+
+ if (!messagesData) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ } />
+ } />
+
+
+ 0 ? messagesData.tags.join(", ") : t("ntfy.none")} />
+
+ );
+}
diff --git a/src/widgets/ntfy/component.test.jsx b/src/widgets/ntfy/component.test.jsx
new file mode 100644
index 000000000..1132d459a
--- /dev/null
+++ b/src/widgets/ntfy/component.test.jsx
@@ -0,0 +1,216 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+import { expectBlockValue } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+
+vi.mock("utils/proxy/use-widget-api", () => ({
+ default: useWidgetAPI,
+}));
+
+import Component from "./component";
+
+describe("widgets/ntfy/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("ntfy.title")).toBeInTheDocument();
+ expect(screen.getByText("ntfy.message")).toBeInTheDocument();
+ expect(screen.getByText("ntfy.priority")).toBeInTheDocument();
+ expect(screen.getByText("ntfy.lastReceived")).toBeInTheDocument();
+ });
+
+ it("renders message data with default fields", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: {
+ title: "Disk Alert",
+ message: "Disk usage at 90%",
+ priority: 4,
+ time: 1700000000,
+ tags: ["warning"],
+ },
+ error: undefined,
+ }));
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "ntfy.title", "Disk Alert");
+ expectBlockValue(container, "ntfy.message", "Disk usage at 90%");
+ expectBlockValue(container, "ntfy.priority", "ntfy.high");
+ });
+
+ it("shows placeholder for title when message has no title set", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: {
+ title: null,
+ message: "Simple notification",
+ priority: 3,
+ time: 1700000000,
+ tags: [],
+ },
+ error: undefined,
+ }));
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expectBlockValue(container, "ntfy.title", "ntfy.none");
+ expectBlockValue(container, "ntfy.message", "Simple notification");
+ expectBlockValue(container, "ntfy.priority", "ntfy.default");
+ });
+
+ it("renders no messages state", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: {
+ title: null,
+ message: null,
+ priority: 3,
+ time: null,
+ tags: [],
+ },
+ error: undefined,
+ }));
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expectBlockValue(container, "ntfy.title", "ntfy.none");
+ expectBlockValue(container, "ntfy.message", "ntfy.none");
+ expectBlockValue(container, "ntfy.lastReceived", "ntfy.none");
+ });
+
+ it("renders error when API fails", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: undefined,
+ error: { message: "Request failed" },
+ }));
+
+ renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(screen.getByText("Request failed")).toBeInTheDocument();
+ });
+
+ it("renders optional tags field when included", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: {
+ title: "Alert",
+ message: "Test",
+ priority: 5,
+ time: 1700000000,
+ tags: ["warning", "skull"],
+ },
+ error: undefined,
+ }));
+
+ const service = {
+ widget: {
+ type: "ntfy",
+ url: "https://ntfy.example.com",
+ topic: "alerts",
+ fields: ["title", "priority", "lastReceived", "tags"],
+ },
+ };
+
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expectBlockValue(container, "ntfy.tags", "warning, skull");
+ expectBlockValue(container, "ntfy.priority", "ntfy.urgent");
+ });
+
+ it("caps visible blocks at 4 when more than 4 fields are configured", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: {
+ title: "Alert",
+ message: "Body",
+ priority: 3,
+ time: 1700000000,
+ tags: ["a"],
+ },
+ error: undefined,
+ }));
+
+ const service = {
+ widget: {
+ type: "ntfy",
+ url: "https://ntfy.example.com",
+ topic: "alerts",
+ fields: ["title", "message", "priority", "lastReceived", "tags"],
+ },
+ };
+
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ });
+
+ it("falls back to default priority label when priority is out of range", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: {
+ title: "Alert",
+ message: "Body",
+ priority: 99,
+ time: 1700000000,
+ tags: [],
+ },
+ error: undefined,
+ }));
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expectBlockValue(container, "ntfy.priority", "ntfy.default");
+ });
+
+ it("renders optional message field when included", () => {
+ useWidgetAPI.mockImplementation(() => ({
+ data: {
+ title: "Disk Alert",
+ message: "Disk usage at 90%",
+ priority: 4,
+ time: 1700000000,
+ tags: [],
+ },
+ error: undefined,
+ }));
+
+ const service = {
+ widget: {
+ type: "ntfy",
+ url: "https://ntfy.example.com",
+ topic: "alerts",
+ fields: ["title", "priority", "message"],
+ },
+ };
+
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expectBlockValue(container, "ntfy.title", "Disk Alert");
+ expectBlockValue(container, "ntfy.message", "Disk usage at 90%");
+ });
+});
diff --git a/src/widgets/ntfy/widget.js b/src/widgets/ntfy/widget.js
new file mode 100644
index 000000000..577e124a7
--- /dev/null
+++ b/src/widgets/ntfy/widget.js
@@ -0,0 +1,14 @@
+import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+
+const widget = {
+ api: "{url}/{endpoint}",
+ proxyHandler: credentialedProxyHandler,
+
+ mappings: {
+ messages: {
+ endpoint: "{topic}/json?poll=1&since=latest",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/ntfy/widget.test.js b/src/widgets/ntfy/widget.test.js
new file mode 100644
index 000000000..4827c60b2
--- /dev/null
+++ b/src/widgets/ntfy/widget.test.js
@@ -0,0 +1,11 @@
+import { describe, it } from "vitest";
+
+import { expectWidgetConfigShape } from "test-utils/widget-config";
+
+import widget from "./widget";
+
+describe("ntfy widget config", () => {
+ it("exports a valid widget config", () => {
+ expectWidgetConfigShape(widget);
+ });
+});
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index be7f685e4..7f18efad3 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -82,6 +82,7 @@ import netdata from "./netdata/widget";
import nextcloud from "./nextcloud/widget";
import nextdns from "./nextdns/widget";
import npm from "./npm/widget";
+import ntfy from "./ntfy/widget";
import nzbget from "./nzbget/widget";
import octoprint from "./octoprint/widget";
import omada from "./omada/widget";
@@ -239,6 +240,7 @@ const widgets = {
nextcloud,
nextdns,
npm,
+ ntfy,
nzbget,
octoprint,
omada,