diff --git a/docs/using-seerr/notifications/webhook.md b/docs/using-seerr/notifications/webhook.md index eb99a5eef..a63508b61 100644 --- a/docs/using-seerr/notifications/webhook.md +++ b/docs/using-seerr/notifications/webhook.md @@ -22,6 +22,17 @@ This is typically not needed. Please refer to your webhook provider's documentat This value will be sent as an `Authorization` HTTP header. +### Custom Headers (optional) + +You can add additional custom HTTP headers to be sent with each webhook request. This is useful for API keys, custom authentication schemes, or any other headers your webhook endpoint requires. + +- Click "Add Header" to add a new header +- Enter the header name and value + +:::warning +You cannot configure both the **Authorization Header** field and a custom `Authorization` header in Custom Headers at the same time. You must choose one method. +::: + ### JSON Payload Customize the JSON payload to suit your needs. Seerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered. diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 40b9d2254..a45481a78 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -196,16 +196,36 @@ class WebhookAgent } try { + const headers: Record = {}; + + if (settings.options.authHeader) { + headers.Authorization = settings.options.authHeader; + } + + if ( + settings.options.customHeaders && + settings.options.customHeaders.length > 0 + ) { + settings.options.customHeaders.forEach((header) => { + const key = header.key?.trim(); + const value = header.value?.trim(); + + if (key && value) { + // Don't override Authorization header if it's already set via authHeader + if ( + key.toLowerCase() !== 'authorization' || + !settings.options.authHeader + ) { + headers[key] = value; + } + } + }); + } + await axios.post( webhookUrl, this.buildPayload(type, payload), - settings.options.authHeader - ? { - headers: { - Authorization: settings.options.authHeader, - }, - } - : undefined + Object.keys(headers).length > 0 ? { headers } : undefined ); return true; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 261e19b56..7057cf2b0 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -277,6 +277,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig { webhookUrl: string; jsonPayload: string; authHeader?: string; + customHeaders?: { key: string; value: string }[]; supportVariables?: boolean; }; } diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 5984e5385..52cc2ee0f 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -279,6 +279,7 @@ notificationRoutes.get('/webhook', (_req, res) => { 'utf8' ) ), + customHeaders: webhookSettings.options.customHeaders ?? [], supportVariables: webhookSettings.options.supportVariables ?? false, }, }; @@ -301,6 +302,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => { ), webhookUrl: req.body.options.webhookUrl, authHeader: req.body.options.authHeader, + customHeaders: req.body.options.customHeaders ?? [], supportVariables: req.body.options.supportVariables ?? false, }, }; @@ -333,6 +335,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => { ), webhookUrl: req.body.options.webhookUrl, authHeader: req.body.options.authHeader, + customHeaders: req.body.options.customHeaders ?? [], supportVariables: req.body.options.supportVariables ?? false, }, }; diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx index 3b13b1056..c2cb8a9f1 100644 --- a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -5,7 +5,12 @@ import SettingsBadge from '@app/components/Settings/SettingsBadge'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { isValidURL } from '@app/utils/urlValidationHelper'; -import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; +import { + ArrowDownOnSquareIcon, + BeakerIcon, + PlusIcon, + TrashIcon, +} from '@heroicons/react/24/outline'; import { ArrowPathIcon, QuestionMarkCircleIcon, @@ -80,6 +85,16 @@ const messages = defineMessages( supportVariablesTip: 'Available variables are documented in the webhook template variables section', authheader: 'Authorization Header', + customHeaders: 'Custom Headers', + customHeadersTip: + 'Add custom HTTP headers to include with webhook requests', + customHeadersAdd: 'Add Header', + customHeadersRemove: 'Remove', + customHeadersKey: 'Header Name', + customHeadersValue: 'Header Value', + customHeadersIncomplete: 'All headers must have both name and value', + customHeadersAuthConflict: + 'Cannot use both Authorization Header and custom Authorization header. Please remove one.', validationJsonPayloadRequired: 'You must provide a valid JSON payload', webhooksettingssaved: 'Webhook notification settings saved successfully!', webhooksettingsfailed: 'Webhook notification settings failed to save.', @@ -125,6 +140,43 @@ const NotificationsWebhook = () => { supportVariables: Yup.boolean(), + customHeaders: Yup.array() + .of( + Yup.object().shape({ + key: Yup.string(), + value: Yup.string(), + }) + ) + .test( + 'complete-headers', + intl.formatMessage(messages.customHeadersIncomplete), + function (headers) { + if (!headers || headers.length === 0) return true; + return headers.every( + (header) => + (!header.key || !header.key.trim()) === + (!header.value || !header.value.trim()) + ); + } + ) + .test( + 'auth-conflict', + intl.formatMessage(messages.customHeadersAuthConflict), + function (headers) { + const { authHeader } = this.parent; + if (!authHeader || !headers || headers.length === 0) return true; + + const hasCustomAuthHeader = headers.some( + (header) => + header.key && + header.value && + header.key.trim().toLowerCase() === 'authorization' + ); + + return !hasCustomAuthHeader; + } + ), + jsonPayload: Yup.string() .when('enabled', { is: true, @@ -159,6 +211,7 @@ const NotificationsWebhook = () => { webhookUrl: data.options.webhookUrl, jsonPayload: data.options.jsonPayload, authHeader: data.options.authHeader, + customHeaders: data.options.customHeaders ?? [], supportVariables: data.options.supportVariables ?? false, }} validationSchema={NotificationsWebhookSchema} @@ -171,6 +224,15 @@ const NotificationsWebhook = () => { webhookUrl: values.webhookUrl, jsonPayload: JSON.stringify(values.jsonPayload), authHeader: values.authHeader, + customHeaders: (values.customHeaders ?? []) + .map((h: { key: string; value: string }) => ({ + key: h.key?.trim() ?? '', + value: h.value?.trim() ?? '', + })) + .filter( + (h: { key: string; value: string }) => + h.key.length > 0 && h.value.length > 0 + ), supportVariables: values.supportVariables, }, }); @@ -229,6 +291,15 @@ const NotificationsWebhook = () => { webhookUrl: values.webhookUrl, jsonPayload: JSON.stringify(values.jsonPayload), authHeader: values.authHeader, + customHeaders: (values.customHeaders ?? []) + .map((h: { key: string; value: string }) => ({ + key: h.key?.trim() ?? '', + value: h.value?.trim() ?? '', + })) + .filter( + (h: { key: string; value: string }) => + h.key.length > 0 && h.value.length > 0 + ), supportVariables: values.supportVariables ?? false, }, }); @@ -344,6 +415,86 @@ const NotificationsWebhook = () => { +
+ +
+
+ {values.customHeaders.map( + (header: { key: string; value: string }, index: number) => ( +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+ ) + )} + +
+ {errors.customHeaders && + touched.customHeaders && + typeof errors.customHeaders === 'string' && ( +
{errors.customHeaders}
+ )} +
+