diff --git a/docs/en/integrations.md b/docs/en/integrations.md
index 681402b..1237c53 100644
--- a/docs/en/integrations.md
+++ b/docs/en/integrations.md
@@ -5,10 +5,12 @@ Click 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
@@ -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=)`, 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&b=2"` — the JSON parses fine but the receiver gets a URL with literal `&`.
+ - **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 `<`-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:`. 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 `&` 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
\ No newline at end of file
+Triggered connections, their responses, and any errors will be logged here