Skip to content

Latest commit

 

History

History
310 lines (225 loc) · 13.1 KB

File metadata and controls

310 lines (225 loc) · 13.1 KB

Webhook reference

ChannelWatch can send outbound webhook notifications to external HTTP receivers. These webhooks are for ChannelWatch events going out to automation tools, dashboards, and custom services. They are not inbound Channels DVR webhooks.

Overview

ChannelWatch sends a webhook when the notification pipeline sends an alert and webhook delivery is enabled for that alert route. Each configured webhook receives the same JSON envelope, signed with its shared secret.

The current sender derives eventType from the notification title, message, and optional image URL. The implemented outbound event types are:

Event type When it is sent
test A notification title contains [test].
channel.watching.start A notification title contains watching tv.
vod.playback.start A notification title contains watching dvr content.
disk.space.critical A notification title contains low disk space critical.
disk.space.warning A notification title contains low disk space warning.
recording.scheduled A recording event notification message contains scheduled.
recording.started A recording event notification message contains started.
recording.cancelled A recording event notification message contains cancelled.
recording.completed A recording event notification message contains completed or stopped.
recording.updated A recording event notification does not match a more specific recording state.
alert.notification A notification includes image_url and no earlier rule matched.
notification Fallback for all other notifications.

Future payload versions may add event-specific fields such as live stream details and recording metadata. The v0.9 payload is a notification envelope with title, message, and optional imageUrl inside data.

Payload schema

ChannelWatch serializes the payload as compact JSON before signing and sending it:

json.dumps(payload, separators=(",", ":")).encode("utf-8")

Example payload:

{
  "eventType": "disk.space.warning",
  "timestamp": "2026-04-26T18:30:00Z",
  "instanceName": "ChannelWatch",
  "instanceUrl": "http://localhost:8501",
  "version": "0.9.10",
  "deliveryId": "8d79f4f7-5c46-4f31-b812-dc17f5c6cf1b",
  "dvr_id": "dvr_1234",
  "dvr_name": "Living Room DVR",
  "data": {
    "title": "Low Disk Space Warning",
    "message": "Living Room DVR is below the configured free space threshold.",
    "imageUrl": null
  }
}
Field Type Required Description
eventType string Yes Dot notation event name selected by ChannelWatch. See the event type table above.
timestamp string Yes UTC ISO 8601 timestamp generated when the payload is built. It ends with Z.
instanceName string Yes Application name from the core package. Current value is ChannelWatch.
instanceUrl string Yes Base URL resolved from CHANNELWATCH_INSTANCE_URL, CW_INSTANCE_URL, then APP_URL. Defaults to http://localhost:8501.
version string Yes ChannelWatch version from the core package.
deliveryId string Yes UUID v4 for this delivery payload. Use it for idempotency in receivers.
dvr_id string Yes DVR identifier passed through the notification call. Empty string when unavailable. This is the multi DVR v1 identity field.
dvr_name string Yes DVR display name passed through the notification call. Empty string when unavailable.
data object Yes Notification details. Current fields are listed below.
data.title string Yes Notification title passed to the webhook manager.
data.message string Yes Notification message passed to the webhook manager.
data.imageUrl string or null Yes Optional image URL from image_url. It is null when no image URL is supplied.

Current payload field count: 9 top-level fields, 12 fields when the three nested data fields are counted.

Fields not present in the current payload

The current code does not include a separate severity field. Severity is only implied by values such as disk.space.warning, disk.space.critical, and the notification text.

The current code does not include the raw Channels DVR event payload. Receivers should treat data.message as formatted notification text, not as a source event object.

HMAC signing

Every outbound webhook must have a non-empty shared secret. ChannelWatch skips delivery when the secret is empty or still set to the masked value ****.

Signature details:

Item Value
Algorithm HMAC-SHA256
Header X-ChannelWatch-Signature
Format sha256=<hex digest>
Signed bytes The exact compact JSON request body bytes
Secret encoding UTF-8

ChannelWatch also sends these headers:

Header Value
Content-Type application/json
X-ChannelWatch-Signature sha256=<hex digest>
X-ChannelWatch-Delivery The payload deliveryId.
X-ChannelWatch-Event The payload eventType.

Verify a signature in Python

import hashlib
import hmac


def verify_channelwatch_signature(secret: str, body: bytes, signature: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature or "")

Use the raw request body bytes from your HTTP framework. Do not parse and reserialize the JSON before checking the signature.

Verify a signature in Node.js

import crypto from "node:crypto";

export function verifyChannelWatchSignature(secret, bodyBuffer, signature) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", Buffer.from(secret, "utf8"))
      .update(bodyBuffer)
      .digest("hex");

  const provided = Buffer.from(signature || "", "utf8");
  const expectedBuffer = Buffer.from(expected, "utf8");

  return provided.length === expectedBuffer.length &&
    crypto.timingSafeEqual(provided, expectedBuffer);
}

Retry and backoff behavior

Webhook delivery has two layers.

Layer Behavior
Per webhook endpoint WebhookManager tries each enabled endpoint up to 3 times. Attempt 1 is immediate. Attempts 2 and 3 wait 1 second and 2 seconds.
Notification wrapper NotificationManager wraps the webhook fanout in deliver_with_retry with with_retry=False. This means the wrapper records success or failure and checks the circuit breaker, but does not add another retry loop.

A per endpoint retry happens after any of these results:

Condition Retried?
HTTP 2xx No. Delivery is successful.
HTTP non-2xx Yes, until 3 attempts are used.
httpx.TimeoutException Yes, until 3 attempts are used.
httpx.RequestError Yes, until 3 attempts are used.

Current code uses fixed exponential delays of 1 second and 2 seconds. It does not add jitter in app/core/notifications/webhook.py.

Each HTTP attempt uses a 5 second timeout.

Failure modes

Failure mode Current behavior
Missing secret The endpoint is skipped and delivery returns failure for that endpoint.
Masked secret, **** The endpoint is skipped. This prevents accidentally saving the UI mask as the real secret.
Non-2xx response Logged and retried by the endpoint retry loop. After 3 failed attempts, the endpoint returns failure.
Timeout or request error Logged and retried by the endpoint retry loop. After 3 failed attempts, the endpoint returns failure.
Circuit breaker open The notification wrapper skips webhook fanout and records circuit_open.
Delivery log unavailable Delivery still proceeds. Persistence is skipped if the delivery database cannot be opened.
Dead letter queue Not implemented. Failed webhook payloads are not queued for later replay.

The shared circuit breaker opens after 5 failed webhook notification fanout calls for the same dvr_id and webhook channel. It stays open for 300 seconds, then resets on the next check.

Delivery records are written through the shared delivery log when /config/channelwatch.db, or CHANNELWATCH_DB, is available. Records include the DVR id, event type, channel, provider type, status, retry count, payload size, error message, and optional activity event id.

Security considerations

Keep the secret private

Use a unique, high entropy secret for each receiver. Do not put real secrets in examples, screenshots, tickets, or logs. ChannelWatch masks webhook secrets as **** when settings are read back through the UI API.

Rotate secrets safely

To rotate a secret, accept both the old and new secret on the receiver for a short overlap window. Update ChannelWatch to send with the new secret, confirm successful deliveries, then remove the old secret from the receiver.

Verify signatures before parsing trusted data

Check X-ChannelWatch-Signature against the raw request body before acting on the payload. Use constant time comparison, such as hmac.compare_digest in Python or crypto.timingSafeEqual in Node.js.

Treat receiver URLs as external targets

Webhook delivery calls the shared URL safety helper before posting. It skips destinations that use non HTTP schemes, localhost, metadata hostnames, or private, reserved, loopback, or link local IP literals, and logs the skipped URL in redacted form.

Private LAN webhook receivers can be approved from Settings > Notifications with the trusted-local destination flow. The approval is exact to the normalized scheme, host, and port, and it applies only to notification delivery. It does not allow image fetching or other outbound URL fetches. Metadata, link-local, loopback, reserved, malformed, and unresolved destinations remain blocked.

Use TLS for remote receivers

Use https:// for receivers outside a trusted local network. ChannelWatch uses httpx, which verifies TLS certificates by default. Do not disable certificate verification in a reverse proxy or receiver setup unless you have another authenticated transport layer.

Make receivers idempotent

Use deliveryId to ignore duplicate deliveries. Retries can send the same payload more than once when a receiver processes the request but returns an error or times out before ChannelWatch sees the response.

Example receiver

Flask receiver

This Flask app validates the signature before reading the JSON payload. It reads the shared secret from CHANNELWATCH_WEBHOOK_SECRET and is written as an app factory so importing the file does not bind a port.

import hashlib
import hmac
import os

WEBHOOK_SECRET = os.environ["CHANNELWATCH_WEBHOOK_SECRET"]


def verify_channelwatch_signature(secret: str, body: bytes, signature: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature or "")


def create_app():
    from flask import Flask, abort, request

    app = Flask(__name__)

    @app.post("/channelwatch")
    def channelwatch_webhook():
        body = request.get_data()
        signature = request.headers.get("X-ChannelWatch-Signature", "")
        if not verify_channelwatch_signature(WEBHOOK_SECRET, body, signature):
            abort(401)

        payload = request.get_json(force=True)
        event_type = payload.get("eventType")
        delivery_id = payload.get("deliveryId")
        print(f"received {event_type} delivery {delivery_id}")
        return {"ok": True}

    return app

Run it with:

flask --app receiver:create_app run --host 0.0.0.0 --port 9000

Express receiver

Express must keep the raw body bytes for signature validation. This example uses express.raw for the webhook route.

import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.CHANNELWATCH_WEBHOOK_SECRET;

if (!secret) {
  throw new Error("Set CHANNELWATCH_WEBHOOK_SECRET before starting the receiver.");
}

function verifyChannelWatchSignature(body, signature) {
  const expected = "sha256=" + crypto
    .createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(body)
    .digest("hex");

  const provided = Buffer.from(signature || "", "utf8");
  const expectedBuffer = Buffer.from(expected, "utf8");

  return provided.length === expectedBuffer.length &&
    crypto.timingSafeEqual(provided, expectedBuffer);
}

app.post("/channelwatch", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.header("X-ChannelWatch-Signature") || "";
  if (!verifyChannelWatchSignature(req.body, signature)) {
    return res.sendStatus(401);
  }

  const payload = JSON.parse(req.body.toString("utf8"));
  console.log(`received ${payload.eventType} delivery ${payload.deliveryId}`);
  return res.json({ ok: true });
});

app.listen(9000, () => {
  console.log("ChannelWatch receiver listening on port 9000");
});

See also

Document Purpose
docs/how-to/configure-webhooks.md Setup guide for webhook destinations.
docs/reference/templates.md Notification template reference.