Skip to content
Merged
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
302 changes: 299 additions & 3 deletions docs/en/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ Click <i data-lucide="square-plus" style="color: White; width: 18px;"></i> New C

* Supports multiple event types including:
* Channel lifecycle (start, stop, reconnect, error, failover)
* Stream operations (switch),
* Stream operations (switch),
* Recording events (start, end)
* Data refreshes (EPG, M3U)
* Client activity (connect, disconnect)
* Client activity (connect, disconnect)
* Security events (login failed, EPG/M3U blocked)
* VOD playback (start, stop)

Event data is available as environment variables in scripts (prefixed with DISPATCHARR_), POST payloads for webhooks, and plugin execution payloads

Expand Down Expand Up @@ -58,5 +60,299 @@ Event data is available as environment variables in scripts (prefixed with DISPA
print(response.text)
```

## Webhook Payload Templates

When you create a webhook connection you can optionally supply a **payload template**. The template is rendered with the [Django template language](https://docs.djangoproject.com/en/stable/ref/templates/language/) (the help text mentions Jinja2, but the engine is Django's). Variables from the event payload are exposed directly in the template context — reference them as `{{ variable_name }}`.

If the rendered template parses as valid JSON, Dispatcharr forces `Content-Type: application/json` on the outgoing request. Otherwise the rendered string is POSTed as-is using whatever headers you set on the connection.

!!! warning
If you **leave the template blank**, the raw event payload dict is passed to `requests.post(..., data=<dict>)`, which form-encodes it as `application/x-www-form-urlencoded` (e.g. `channel_name=CNN&stream_name=...`). It is **not** sent as JSON. If you want JSON, supply a template — even a trivial one like `{{ payload|default:"" }}` will not work either; you must render the body yourself, e.g. `{"channel": "{{ channel_name }}"}`.

### Auto-injected variables (channel-context events)

Any event that carries a `channel_id` is enriched at dispatch time with the following fields (sourced from the channel record, the currently playing stream, and the M3U/profile in Redis):

| Variable | Description |
| --- | --- |
| `channel_name` | Channel display name |
| `stream_name` | Name of the currently playing stream |
| `stream_url` | Upstream URL of the currently playing stream |
| `channel_url` | Alias of `stream_url` |
| `provider_name` | M3U account name backing the active stream |
| `profile_used` | StreamProfile name applied to the active stream |

!!! warning "Falsy values are stripped before render"
Any payload key whose value is falsy under Python truthiness — `None`, `""`, `0`, `0.0`, `False`, empty `[]`, empty `{}` — is **deleted from the payload before the template is rendered**. That means numeric and boolean fields can also vanish: e.g. `bytes_written=0` on `recording_end` or `interrupted=False` will both be missing, not present-with-zero. If a field might be missing, guard it with a default: `{{ stream_name|default:"-" }}`. Otherwise an undefined variable will render as an empty string and can produce invalid JSON.

!!! warning "Escape variables that go inside JSON strings"
Django's template engine HTML-escapes `<`, `>`, `&`, `"`, `'` by default but does **not** escape backslashes or newlines. That means:

- **A URL with `&` query params** rendered as `"url": "{{ stream_url }}"` produces `"url": "http://host/?a=1&amp;b=2"` — the JSON parses fine but the receiver gets a URL with literal `&amp;`.
- **An `error_message` containing a newline or backslash** (very common for exception text) produces invalid JSON.

The safe pattern for any variable that lands inside a JSON string is the `escapejs` filter, which escapes everything dangerous in a JS/JSON string context (quotes, backslashes, newlines, `<`, `>`, `&`) as `\uXXXX` sequences. The output looks noisy but is unambiguous after `JSON.parse`:

```jinja
"url": "{{ stream_url|escapejs }}"
"detail": "{{ error_message|escapejs }}"
```

For **plain-text** targets like ntfy line bodies, you can use `|safe` on each variable to bypass autoescape and avoid `&lt;`-style noise in the rendered message.

### Per-event variables

The tables below list the variables each event provides _in addition to_ the auto-injected fields above (where applicable).

#### `channel_start` — Channel Started
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `stream_name` | Stream name |
| `stream_id` | Internal stream ID |

#### `channel_stop` — Channel Stopped
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `runtime` | Total runtime in seconds |
| `total_bytes` | Total bytes streamed |

#### `channel_reconnect` — Channel Reconnected
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `attempt` | Current retry attempt number |
| `max_attempts` | Maximum configured retries |

#### `channel_error` — Channel Error
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `error_type` | One of `connection_failed`, `connection_exception` |
| `url` | First 100 chars of the stream URL |
| `attempts` | How many attempts were made before giving up |
| `error_message` | Exception detail (only on `connection_exception`) |

#### `channel_failover` — Channel Failover
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `reason` | Why failover triggered (e.g. `buffering_timeout`) |
| `duration` | How long the failure condition persisted before failover |

#### `stream_switch` — Stream Switch
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `new_url` | First 100 chars of the new stream URL |
| `stream_id` | New stream ID |

#### `recording_start` — Recording Started
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `recording_id` | Recording row ID |

#### `recording_end` — Recording Ended
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `recording_id` | Recording row ID |
| `interrupted` | `True` if the recording ended early |
| `bytes_written` | Total bytes written to disk |

#### `epg_refresh` — EPG Refreshed
| Variable | Description |
| --- | --- |
| `source_name` | EPG source name |
| `programs` | Number of programs ingested |
| `channels` | Number of channels with programs |
| `skipped_programs` | Programs skipped during parse |
| `unmapped_channels` | EPG channels with no matching Dispatcharr channel |

#### `m3u_refresh` — M3U Refreshed
| Variable | Description |
| --- | --- |
| `account_name` | M3U account name |
| `elapsed_time` | Refresh duration in seconds (rounded to 2dp) |
| `streams_created` | New streams added this refresh |
| `streams_updated` | Existing streams updated |
| `streams_deleted` | Streams removed because the provider dropped them |
| `total_processed` | Total streams seen in the playlist |

#### `client_connect` — Client Connected
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `client_ip` | Client IP address |
| `client_id` | Internal per-connection client ID |
| `user_agent` | Client User-Agent (truncated to 100 chars) |
| `username` | Authenticated username, if any |

#### `client_disconnect` — Client Disconnected
| Variable | Description |
| --- | --- |
| `channel_name` | Channel name |
| `client_ip` | Client IP address |
| `client_id` | Internal per-connection client ID |
| `user_agent` | Client User-Agent (truncated to 100 chars) |
| `duration` | Session length in seconds (rounded to 2dp) |
| `bytes_sent` | Bytes delivered to this client |
| `username` | Authenticated username, if any |

#### `login_failed` — Login Failed
| Variable | Description |
| --- | --- |
| `user` | Username submitted (or `unknown` / `token_refresh`) |
| `client_ip` | Client IP address |
| `user_agent` | Client User-Agent |
| `reason` | Failure reason (invalid credentials, network access denied, etc.) |

#### `epg_blocked` — EPG Blocked
| Variable | Description |
| --- | --- |
| `profile` | Output profile name (or `all`) |
| `reason` | Why the request was blocked |
| `client_ip` | Client IP address |
| `user_agent` | Client User-Agent |

#### `m3u_blocked` — M3U Blocked
| Variable | Description |
| --- | --- |
| `profile` | Output profile name (or `all`) — present on the standard M3U path |
| `user` | Submitted username — present on the Xtream Codes API path |
| `reason` | Why the request was blocked |
| `client_ip` | Client IP address |
| `user_agent` | Client User-Agent |

#### `vod_start` / `vod_stop` — VOD Started / VOD Stopped
| Variable | Description |
| --- | --- |
| `content_name` | VOD title |
| `content_uuid` | VOD content UUID (shared by the matching start/stop pair) |
| `client_ip` | Client IP address |
| `username` | Authenticated username, if any |

### Gotchas

* **Falsy values disappear.** As noted above, Dispatcharr deletes any payload key whose value is empty before rendering. Always use `{{ var|default:"-" }}` (or wrap fields in `{% if var %}…{% endif %}`) to keep your JSON valid.
* **Channel-context fields can be missing on some events.** `stream_name` / `stream_url` / `channel_url` / `provider_name` / `profile_used` are populated from a `Stream` row. The dispatcher uses an explicit `stream_id` from the call site if one was passed (true for `channel_start` and `stream_switch`); otherwise it falls back to the Redis key `channel_stream:<channel.id>`. If neither yields a stream, all five fields are `None` and get stripped before render. In practice this affects events like `channel_stop`, `channel_reconnect`, `channel_error`, `channel_failover`, `recording_start`, `recording_end`, `client_connect`, and `client_disconnect` when the live-stream Redis state has already been cleared.
* **Some logged events do not fire webhooks.** `login_success`, `logout`, `m3u_download`, `epg_download`, and `channel_buffering` are written to the System Events log but are **not** in the webhook event registry, so subscriptions on those events will never fire.
* **The Channel UUID is not exposed to templates.** The dispatch layer accepts a `channel_id` argument internally and uses it as the UUID lookup key for the `Channel` row, but the value is consumed at that point and is **not** added to the template context. `{{ channel_id }}` in a payload template renders as an empty string. Use `{{ channel_name }}` to identify the channel.
* **No HMAC signing is built in.** If your receiver needs to authenticate the webhook, put a long shared secret in the connection's headers (e.g. `X-Auth-Token`) and validate it on the receiving end.

### Worked examples

#### Discord — channel start / stop
Use **two separate subscriptions** with hardcoded labels — don't try to detect start vs. stop from `total_bytes`/`runtime` truthiness, because a legitimate 0-byte session would be stripped to nothing and misclassified.

For `channel_start`:

```jinja
{
"username": "Dispatcharr",
"embeds": [{
"title": "{{ channel_name|default:"Unknown"|escapejs }} started",
"fields": [
{"name": "Stream", "value": "{{ stream_name|default:"-"|escapejs }}", "inline": true},
{"name": "Provider", "value": "{{ provider_name|default:"-"|escapejs }}", "inline": true},
{"name": "Profile", "value": "{{ profile_used|default:"-"|escapejs }}", "inline": true}
]
}]
}
```

For `channel_stop`:

```jinja
{
"username": "Dispatcharr",
"embeds": [{
"title": "{{ channel_name|default:"Unknown"|escapejs }} stopped",
"fields": [
{"name": "Stream", "value": "{{ stream_name|default:"-"|escapejs }}", "inline": true},
{"name": "Provider", "value": "{{ provider_name|default:"-"|escapejs }}", "inline": true},
{"name": "Runtime (s)", "value": "{{ runtime|default:0 }}", "inline": true},
{"name": "Bytes", "value": "{{ total_bytes|default:0 }}", "inline": true}
]
}]
}
```

#### Slack — error / failover / reconnect alerts
One Slack webhook covers all three error-class events. Note the `|escapejs` filter on every variable value — without it, an `error_message` that contains a newline or backslash (very common in raw Python exception text) will produce invalid JSON and the delivery will fail:

```jinja
{
"text": ":warning: *{{ channel_name|escapejs }}* — {% if error_type %}{{ error_type|escapejs }}{% elif reason %}{{ reason|escapejs }}{% else %}reconnect {{ attempt }}/{{ max_attempts }}{% endif %}",
"attachments": [{
"color": "warning",
"fields": [
{"title": "Provider", "value": "{{ provider_name|default:"-"|escapejs }}", "short": true},
{"title": "Stream", "value": "{{ stream_name|default:"-"|escapejs }}", "short": true}
{% if error_message %},{"title": "Detail", "value": "{{ error_message|escapejs }}", "short": false}{% endif %}
]
}]
}
```

#### ntfy / Gotify / Pushover — mobile push for security events
Subscribe `login_failed`, `m3u_blocked`, `epg_blocked` to a ntfy topic. ntfy accepts a plain text body, so the template doesn't need to be JSON:

```jinja
{{ user|default:"unknown" }} from {{ client_ip }} — {{ reason }}
```

The event name itself is **not** in the template context (only the payload dict is), so include it in the connection headers instead, alongside other ntfy metadata:

```json
{"Title": "Dispatcharr security alert", "Priority": "high", "Tags": "warning"}
```

#### Home Assistant — now-playing state
Use **two separate subscriptions** with hardcoded `state` values (same reason as Discord). Note `|escapejs` on `stream_url` — without it, a URL with `&` query params (every Xtream/HLS URL) would be rendered as `&amp;` and HA would store the broken URL.

For `channel_start` and `stream_switch`:

```jinja
{
"state": "playing",
"channel": "{{ channel_name|escapejs }}",
"stream": "{{ stream_name|escapejs }}",
"provider": "{{ provider_name|escapejs }}",
"url": "{{ stream_url|escapejs }}"
}
```

For `channel_stop`:

```jinja
{
"state": "idle",
"channel": "{{ channel_name|escapejs }}"
}
```

Drive an `input_text` or template sensor from the webhook automation on the HA side.

#### InfluxDB — provider health metrics
Send `m3u_refresh` and `epg_refresh` to an InfluxDB HTTP write endpoint as InfluxDB line protocol — plain text, not JSON, so the JSON-detection path won't fire. The two events share no field names, so use one subscription with one template for each.

!!! warning
InfluxDB line protocol requires tag values to escape spaces, commas, and `=`. Django has no built-in line-protocol filter, so the cheap workaround is `|cut:" "|cut:","|cut:"="` to strip those characters from the tag. The clean alternative is to rename your M3U accounts / EPG sources to be alphanumeric+dash only.

For `m3u_refresh`:

```jinja
m3u_refresh,account={{ account_name|cut:" "|cut:","|cut:"=" }} created={{ streams_created|default:0 }},updated={{ streams_updated|default:0 }},deleted={{ streams_deleted|default:0 }},elapsed={{ elapsed_time|default:0 }}
```

For `epg_refresh`:

```jinja
epg_refresh,source={{ source_name|cut:" "|cut:","|cut:"=" }} programs={{ programs|default:0 }},channels={{ channels|default:0 }},skipped={{ skipped_programs|default:0 }},unmapped={{ unmapped_channels|default:0 }}
```

## Logs
Triggered connections, their responses, and any errors will be logged here
Triggered connections, their responses, and any errors will be logged here