diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 90730c227..71260c858 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -25917,6 +25917,14 @@ components:
maxLength: 500
- type: string
const: ""
+ format:
+ description: Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message`
+ expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved
+ before the choice existed.
+ type: string
+ enum:
+ - generic
+ - gotify
enabled:
type: boolean
required:
@@ -25926,7 +25934,7 @@ components:
re-checked at dispatch time; a private origin passes both only when the operator listed it in
`NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason
as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this
- endpoint."
+ endpoint. An omitted `format` keeps the stored body shape."
DeleteAccountRequest:
type: object
properties:
@@ -57501,11 +57509,18 @@ components:
hasHeaderValue:
type: boolean
description: Whether a header value (the shared secret) is stored. The value itself is never returned.
+ format:
+ type: string
+ enum:
+ - generic
+ - gotify
+ description: Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice.
required:
- enabled
- url
- headerName
- hasHeaderValue
+ - format
additionalProperties: false
description: Stored generic-webhook channel config, with the secret header value reduced to a presence flag.
PutWebhookSettingsResponse:
diff --git a/messages/de.json b/messages/de.json
index b5ac510ae..02e6afce7 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
"webhookHeaderName": "Header-Name (optional)",
"webhookHeaderValue": "Header-Wert (optional)",
+ "webhookFormat": "Nachrichtenformat",
+ "webhookFormatGeneric": "Generisches JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.",
"email": "E-Mail",
"emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.",
"emailEnable": "E-Mail aktivieren",
diff --git a/messages/en.json b/messages/en.json
index e54cffb55..1e7ea2328 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
"webhookHeaderName": "Header name (optional)",
"webhookHeaderValue": "Header value (optional)",
+ "webhookFormat": "Payload format",
+ "webhookFormatGeneric": "Generic JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.",
"email": "Email",
"emailDescription": "Receive alerts and reminders by email.",
"emailEnable": "Enable email",
diff --git a/messages/es.json b/messages/es.json
index 1ca650b2a..29f3f81b0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
"webhookHeaderName": "Nombre de cabecera (opcional)",
"webhookHeaderValue": "Valor de cabecera (opcional)",
+ "webhookFormat": "Formato del mensaje",
+ "webhookFormatGeneric": "JSON genérico",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Usa la URL que termina en /message y pon el token de la aplicación en la cabecera X-Gotify-Key.",
"email": "Correo electrónico",
"emailDescription": "Recibe alertas y recordatorios por correo electrónico.",
"emailEnable": "Activar correo electrónico",
diff --git a/messages/fr.json b/messages/fr.json
index aba616f1d..48c1a548e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
"webhookHeaderName": "Nom d’en-tête (facultatif)",
"webhookHeaderValue": "Valeur d’en-tête (facultatif)",
+ "webhookFormat": "Format du message",
+ "webhookFormatGeneric": "JSON générique",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Utilisez l’URL se terminant par /message et placez le jeton de l’application dans l’en-tête X-Gotify-Key.",
"email": "E-mail",
"emailDescription": "Recevoir les alertes et les rappels par e-mail.",
"emailEnable": "Activer l’e-mail",
diff --git a/messages/it.json b/messages/it.json
index 834d5da3d..9876034aa 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
"webhookHeaderName": "Nome intestazione (opzionale)",
"webhookHeaderValue": "Valore intestazione (opzionale)",
+ "webhookFormat": "Formato del messaggio",
+ "webhookFormatGeneric": "JSON generico",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.",
"email": "Email",
"emailDescription": "Ricevi avvisi e promemoria via email.",
"emailEnable": "Attiva email",
diff --git a/messages/ko.json b/messages/ko.json
index 48722e917..f99ab9a28 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "POST를 보낼 전체 URL이에요. 외부에서 접근할 수 있어야 해요.",
"webhookHeaderName": "헤더 이름 (선택)",
"webhookHeaderValue": "헤더 값 (선택)",
+ "webhookFormat": "메시지 형식",
+ "webhookFormatGeneric": "일반 JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.",
"email": "이메일",
"emailDescription": "경고와 알림을 이메일로 받아요.",
"emailEnable": "이메일 켜기",
diff --git a/messages/pl.json b/messages/pl.json
index 4689c536b..d401d3f51 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5590,6 +5590,10 @@
"webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
"webhookHeaderName": "Nazwa nagłówka (opcjonalnie)",
"webhookHeaderValue": "Wartość nagłówka (opcjonalnie)",
+ "webhookFormat": "Format wiadomości",
+ "webhookFormatGeneric": "Ogólny JSON",
+ "webhookFormatGotify": "Gotify",
+ "webhookFormatGotifyHint": "Użyj adresu URL kończącego się na /message, a token aplikacji wpisz w nagłówku X-Gotify-Key.",
"email": "E-mail",
"emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.",
"emailEnable": "Włącz e-mail",
diff --git a/src/app/api/settings/__tests__/webhook-format-route.test.ts b/src/app/api/settings/__tests__/webhook-format-route.test.ts
new file mode 100644
index 000000000..d20ba38b8
--- /dev/null
+++ b/src/app/api/settings/__tests__/webhook-format-route.test.ts
@@ -0,0 +1,194 @@
+/**
+ * The webhook payload format at the settings route (#947).
+ *
+ * Absent means generic, so a config saved before the choice existed keeps
+ * sending the body it always sent. Only the Gotify choice is written, an
+ * omitted format keeps the stored one, and the GET reports the resolved
+ * value rather than leaving the client to guess the default.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ notificationChannel: {
+ findUnique: vi.fn(),
+ updateMany: vi.fn(),
+ upsert: vi.fn(),
+ },
+ },
+}));
+vi.mock("@/lib/crypto", () => ({
+ encrypt: vi.fn((value: string) => `encrypted:${value}`),
+ decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn().mockResolvedValue({
+ allowed: true,
+ remaining: 4,
+ resetAt: Date.now() + 60_000,
+ }),
+ rateLimitHeaders: () => ({}),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { GET, PUT } from "../webhook/route";
+import { prisma } from "@/lib/db";
+import { getSession } from "@/lib/auth/session";
+
+const SESSION_OK = {
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+ user: { id: "user-1", username: "testuser", role: "USER" as const },
+};
+
+type Route = (request: Request) => Promise;
+
+function put(body: unknown): Promise {
+ return (PUT as Route)(
+ new Request("http://localhost/api/settings/webhook", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+function get(): Promise {
+ return (GET as Route)(new Request("http://localhost/api/settings/webhook"));
+}
+
+function stored(): string {
+ const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
+ return call.create.config as string;
+}
+
+function existing(config: Record) {
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
+ enabled: true,
+ config: `encrypted:${JSON.stringify(config)}`,
+ } as never);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
+ null as never,
+ );
+ vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
+});
+
+describe("PUT /api/settings/webhook — payload format", () => {
+ it("stores the Gotify choice", async () => {
+ const response = await put({
+ url: "https://gotify.example.com/message",
+ headerName: "X-Gotify-Key",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ enabled: true,
+ });
+
+ expect(response.status).toBe(200);
+ expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({
+ url: "https://gotify.example.com/message",
+ headerName: "X-Gotify-Key",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ });
+ });
+
+ it("writes nothing for generic, so the stored config keeps its old shape", async () => {
+ await put({
+ url: "https://relay.example.com/hook",
+ format: "generic",
+ enabled: true,
+ });
+
+ expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}');
+ });
+
+ it("keeps a stored Gotify choice when the body omits the field", async () => {
+ existing({
+ url: "https://gotify.example.com/message",
+ headerValue: "AbCdEfGh123",
+ format: "gotify",
+ });
+
+ await put({
+ url: "https://gotify.example.com/message",
+ headerValue: "NewToken456",
+ enabled: true,
+ });
+
+ expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({
+ headerValue: "NewToken456",
+ format: "gotify",
+ });
+ });
+
+ it("switches a Gotify channel back to generic when asked", async () => {
+ existing({ url: "https://gotify.example.com/message", format: "gotify" });
+
+ await put({
+ url: "https://gotify.example.com/message",
+ format: "generic",
+ enabled: true,
+ });
+
+ expect(stored()).toBe(
+ 'encrypted:{"url":"https://gotify.example.com/message"}',
+ );
+ });
+
+ it("refuses a format it does not know", async () => {
+ const response = await put({
+ url: "https://relay.example.com/hook",
+ format: "discord",
+ enabled: true,
+ });
+
+ expect(response.status).toBe(422);
+ expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
+ });
+});
+
+describe("GET /api/settings/webhook — payload format", () => {
+ it("reports generic for a config saved before the choice existed", async () => {
+ existing({ url: "https://relay.example.com/hook" });
+
+ const json = await (await get()).json();
+
+ expect(json.data.format).toBe("generic");
+ });
+
+ it("reports the stored Gotify choice", async () => {
+ existing({ url: "https://gotify.example.com/message", format: "gotify" });
+
+ const json = await (await get()).json();
+
+ expect(json.data.format).toBe("gotify");
+ });
+
+ it("reports generic when no channel exists", async () => {
+ const json = await (await get()).json();
+
+ expect(json.data).toEqual({
+ enabled: false,
+ url: "",
+ headerName: "",
+ hasHeaderValue: false,
+ format: "generic",
+ });
+ });
+});
diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts
index 1f1b072b3..49cccdec3 100644
--- a/src/app/api/settings/webhook/route.ts
+++ b/src/app/api/settings/webhook/route.ts
@@ -20,6 +20,7 @@ import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
+import type { WebhookChannelConfig } from "@/lib/notifications/types";
/**
* The save-time schema evaluates the same policy the sender does: the public
@@ -61,22 +62,23 @@ export const GET = apiHandler(async () => {
url: "",
headerName: "",
hasHeaderValue: false,
+ format: "generic",
});
}
- const config = JSON.parse(decrypt(channel.config)) as {
- url: string;
- headerName?: string;
- headerValue?: string;
- };
+ const config = JSON.parse(
+ decrypt(channel.config),
+ ) as Partial;
annotate({ action: { name: "settings.webhook.get" } });
return apiSuccess({
enabled: channel.enabled,
- url: config.url,
+ url: config.url ?? "",
headerName: config.headerName ?? "",
hasHeaderValue: !!config.headerValue,
+ // A config saved before the choice existed carries no format: generic.
+ format: config.format === "gotify" ? "gotify" : "generic",
});
});
@@ -166,7 +168,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return apiValidationError("Invalid data", issues, 422);
}
- const { url, headerName, headerValue, enabled } = parsed.data;
+ const { url, headerName, headerValue, format, enabled } = parsed.data;
if (enabled && !url) {
return apiError("Webhook URL is required when the webhook is enabled", 422);
@@ -174,24 +176,30 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Preserve an existing header value when the client sends an empty one (the
// GET path never returns the secret, so a save round-trip would otherwise
- // wipe it). A non-empty value replaces it.
+ // wipe it). A non-empty value replaces it. An omitted format keeps the
+ // stored one too, so a client that does not know the field cannot flip a
+ // Gotify channel back to the generic body by saving.
let nextHeaderValue = headerValue || undefined;
- if (!nextHeaderValue) {
+ let nextFormat = format;
+ if (!nextHeaderValue || nextFormat === undefined) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
});
if (existing) {
- const prev = JSON.parse(decrypt(existing.config)) as {
- headerValue?: string;
- };
- nextHeaderValue = prev.headerValue || undefined;
+ const prev = JSON.parse(
+ decrypt(existing.config),
+ ) as Partial;
+ if (!nextHeaderValue) nextHeaderValue = prev.headerValue || undefined;
+ if (nextFormat === undefined) nextFormat = prev.format;
}
}
+ // Absent means generic, so only the Gotify choice is written.
const config = JSON.stringify({
url: url || "",
...(headerName ? { headerName } : {}),
...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}),
+ ...(nextFormat === "gotify" ? { format: "gotify" } : {}),
});
const encryptedConfig = encrypt(config);
@@ -207,7 +215,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
update: { enabled, config: encryptedConfig },
});
- annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } });
+ annotate({
+ action: { name: "settings.webhook.update" },
+ meta: { enabled, format: nextFormat === "gotify" ? "gotify" : "generic" },
+ });
return apiSuccess({ saved: true });
});
diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx
index a7cda1b36..81499de41 100644
--- a/src/components/settings/__tests__/notification-channel-cards.test.tsx
+++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx
@@ -154,4 +154,27 @@ describe("notification channel cards", () => {
);
},
);
+
+ it("webhook card offers the payload format and sends it with the full save (#947)", async () => {
+ const html = render();
+
+ expect(html).toContain('id="webhook-format"');
+ expect(html).toMatch(/
+
+
+
+ setFormat(e.target.value === "gotify" ? "gotify" : "generic")
+ }
+ >
+
+
+
+ {format === "gotify" && (
+
+ {t("settings.webhookFormatGotifyHint")}
+
+ )}
+