Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 61 additions & 14 deletions docs/using-seerr/notifications/webhook.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,57 @@ You cannot configure both the **Authorization Header** field and a custom `Autho

### 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.
Customize the JSON payload to suit your needs. The payload is a [Liquid](https://liquidjs.com/) template that is rendered when a notification is triggered and then sent as the request body. Seerr exposes a set of [template variables](#template-variables), plus the full `media`, `request`, `issue`, and `comment` objects, for use in [conditionals and filters](#template-syntax). If you are new to Liquid, see the [introduction to Liquid tutorial](https://liquidjs.com/tutorials/intro-to-liquid.html).

The template only needs to _render_ to valid JSON — it does not need to be valid JSON itself (for example, optional sections can be wrapped in `{% if %}` blocks). Use the **Test** button to confirm your template renders correctly; template errors are also written to the server logs.

## Template Syntax

Payloads and webhook URLs are rendered with [LiquidJS](https://liquidjs.com/), so in addition to simple `{{ variable }}` substitution you can use Liquid tags and filters.

### Conditionals

Render a section only when the relevant object is present, falling back to `null` otherwise:

```liquid
"media": {% if media %}{ "tmdbId": "{{ media_tmdbid }}" }{% else %}null{% endif %}
```

You can also branch on a value:

```liquid
{% if media_status == "AVAILABLE" %}...{% else %}...{% endif %}
```

### Filters

- **`json`** — `{{ subject | json }}`: serializes a value as JSON, quoting and escaping strings so characters like `"` or newlines can't break the payload. Circular-safe.
- **`default`** — `{{ notifyuser_username | default: "Unknown User" }}`: falls back to a value when the input is nil or empty.
- **`upcase` / `downcase`** — `{{ media_type | upcase }}`: changes case.
- **`capitalize`** — `{{ notifyuser_username | capitalize }}`: capitalizes the first character.
- **`truncate`** — `{{ subject | truncate: 50 }}`: shortens a string.
- **`url_encode`** — `{{ subject | url_encode }}`: percent-encodes a value for use in a URL.

See the [LiquidJS filter reference](https://liquidjs.com/filters/overview.html) for the full list.

:::info
Free-text values (titles, overviews, usernames, comments) should be emitted through the `json` filter so quotes and newlines can't produce invalid JSON, e.g. `"subject": {{ subject | json }}`.
:::

:::warning
Webhook URL values are rendered raw. When substituting a value into a URL, use the `url_encode` filter (e.g. `...?user={{ notifyuser_username | url_encode }}`) so characters like `&`, `?`, or spaces don't break the URL.
:::

### Advanced object access

In addition to the flat variables documented below, the full `media`, `request`, `issue`, and `comment` objects are available for conditionals and advanced templates (for example `{% if issue %}` or `{{ request.id }}`). These map to the [`NotificationPayload` interface](https://github.com/seerr-team/seerr/blob/develop/server/lib/notifications/agents/agent.ts).

## Template Variables

:::warning Deprecated
The flat template variables listed below (e.g. `{{ media_tmdbid }}`, `{{ notifyuser_username }}`) are deprecated and will be replaced in a future release. They continue to work for now, and a migration will be provided to convert existing templates automatically.
:::

### General

| Variable | Value |
Expand Down Expand Up @@ -81,21 +128,21 @@ On the other hand, the `notifyuser` variables _will_ be replaced with the reques
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
:::

### Special
### Objects

The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
The following values are the full objects for each notification. They are `null` when not relevant to the notification, and are primarily useful in conditionals (e.g. `{% if media %}`) or for accessing fields not covered by the flat variables below. Serialize an object into the payload with the `json` filter, e.g. `"extra": {{ extra | json }}`.

| Variable | Value |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `{{media}}` | The relevant media object |
| `{{request}}` | The relevant request object |
| `{{issue}}` | The relevant issue object |
| `{{comment}}` | The relevant issue comment object |
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
| Variable | Value |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `media` | The relevant media object |
| `request` | The relevant request object |
| `issue` | The relevant issue object |
| `comment` | The relevant issue comment object |
| `extra` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |

### Media

The `{{media}}` will be `null` if there is no relevant media object for the notification.
`media` will be `null` if there is no relevant media object for the notification.

These following special variables are only included in media-related notifications, such as requests.

Expand All @@ -113,7 +160,7 @@ These following special variables are only included in media-related notificatio

### Request

The `{{request}}` will be `null` if there is no relevant media object for the notification.
`request` will be `null` if there is no relevant request object for the notification.

The following special variables are only included in request-related notifications.

Expand All @@ -129,7 +176,7 @@ The following special variables are only included in request-related notificatio

### Issue

The `{{issue}}` will be `null` if there is no relevant media object for the notification.
`issue` will be `null` if there is no relevant issue object for the notification.

The following special variables are only included in issue-related notifications.

Expand All @@ -144,7 +191,7 @@ The following special variables are only included in issue-related notifications

### Comment

The `{{comment}}` will be `null` if there is no relevant media object for the notification.
`comment` will be `null` if there is no relevant comment object for the notification.

The following special variables are only included in issue comment-related notifications.

Expand Down
1 change: 1 addition & 0 deletions gen-docs/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ const config: Config = {
'nix',
'nginx',
'batch',
'liquid',
],
},
} satisfies Preset.ThemeConfig,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"humanize-duration": "^3.33.2",
"ip-address": "^10.7.5",
"js-yaml": "^4.3.2",
"liquidjs": "^10.27.0",
"lodash": "4.18.1",
"lru-cache": "11.2.1",
"mime": "^4.1.0",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 114 additions & 0 deletions server/lib/notifications/agents/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Notification } from '@server/lib/notifications';
import WebhookAgent from '@server/lib/notifications/agents/webhook';
import type { NotificationAgentWebhook } from '@server/lib/settings';
import axios from 'axios';
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { NotificationPayload } from './agent';

const encode = (template: string) => Buffer.from(template).toString('base64');

const makePayload = (
overrides: Partial<NotificationPayload> = {}
): NotificationPayload =>
({
subject: 'Test Subject',
notifySystem: true,
notifyAdmin: false,
...overrides,
}) as NotificationPayload;

const makeSettings = (
overrides: Partial<NotificationAgentWebhook['options']> = {}
): NotificationAgentWebhook => ({
enabled: true,
embedPoster: false,
types: Notification.TEST_NOTIFICATION,
options: {
webhookUrl: 'https://example.com/webhook',
jsonPayload: encode('{ "subject": {{ subject | json }} }'),
...overrides,
},
});

describe('WebhookAgent', () => {
describe('send', () => {
it('returns true and posts the rendered payload', async (t) => {
const post = t.mock.method(axios, 'post', async () => ({}));

const agent = new WebhookAgent(makeSettings());
const result = await agent.send(
Notification.TEST_NOTIFICATION,
makePayload({ subject: 'The "Best" Movie' })
);

assert.equal(result, true);
assert.equal(post.mock.callCount(), 1);
const [url, body] = post.mock.calls[0].arguments;
assert.equal(url, 'https://example.com/webhook');
assert.deepEqual(body, { subject: 'The "Best" Movie' });
});

it('returns false when the payload template fails to render', async (t) => {
const post = t.mock.method(axios, 'post', async () => ({}));

const agent = new WebhookAgent(
makeSettings({ jsonPayload: encode('{% invalid %}') })
);
const result = await agent.send(
Notification.TEST_NOTIFICATION,
makePayload()
);

assert.equal(result, false);
assert.equal(post.mock.callCount(), 0);
});

it('returns false when the rendered payload is not valid JSON', async (t) => {
const post = t.mock.method(axios, 'post', async () => ({}));

const agent = new WebhookAgent(
makeSettings({ jsonPayload: encode('{ not json }') })
);
const result = await agent.send(
Notification.TEST_NOTIFICATION,
makePayload()
);

assert.equal(result, false);
assert.equal(post.mock.callCount(), 0);
});

it('returns false when the webhook URL template fails to render', async (t) => {
const post = t.mock.method(axios, 'post', async () => ({}));

const agent = new WebhookAgent(
makeSettings({
webhookUrl: 'https://example.com/{% invalid %}',
supportVariables: true,
})
);
const result = await agent.send(
Notification.TEST_NOTIFICATION,
makePayload()
);

assert.equal(result, false);
assert.equal(post.mock.callCount(), 0);
});

it('returns false when posting to the webhook URL fails', async (t) => {
t.mock.method(axios, 'post', async () => {
throw new Error('connect ECONNREFUSED');
});

const agent = new WebhookAgent(makeSettings());
const result = await agent.send(
Notification.TEST_NOTIFICATION,
makePayload()
);

assert.equal(result, false);
});
});
});
Loading
Loading