Skip to content

Multi-Channel Push Done Right: A Unified Notify Architecture for Home Assistant

I have run Home Assistant in production for five years, and for the first two of those years my notification strategy was one Telegram bot and a prayer. It worked until it did not. A bot token that silently expired, a WeCom webhook that started rate-limiting, and a Xiaomi push that arrived twelve minutes late — each one taught me the same lesson: a single notify target is a single point of failure. This article is the architecture I built in response: one unified notify pipeline, grouped by domain, routed by severity, protected by cooldown, and built to degrade instead of die.

1. Why One Channel Is Not a Strategy

The default Home Assistant approach is to create one notify service per platform (notify.telegram, notify.wecom, notify.mobile_app_phone) and call whichever you happen to remember. That works for a demo. In production it fails in three specific ways I have all hit:

  • Silent failure. notify.telegram returns success even when Telegram silently refuses the message. You find out when a smoke alarm fired and nobody got told.
  • Channel-specific limits. Telegram truncates at 4096 characters. WeCom webhooks rate-limit at 20 messages per minute. Xiaomi push has variable latency. Each platform has its own contract, and the abstraction has to absorb all of them.
  • No routing. When everything goes to one chat, a door sensor that flapped at 3am buried the actual 3:15am leak alarm. Severity was never a dimension I could use.

The fix is not more channels. The fix is a layer that treats every channel as a sink with a contract and adds routing, dedup, cooldown, and degradation above them.

2. The Notify Service and Domain Grouping

The Home Assistant notify domain exposes notify.send_message (the modern name for notify.notify) with a common interface: message, title, data. Every platform implements it. My architecture leans on three facts about it:

  1. The service is platform-agnostic. The same automation can call notify.send_message and the platform decides the transport.
  2. Each platform has its own service name. I register logical targets as notify services so automations never know or care about the vendor.
  3. data is a free-form payload. I use it to carry severity, the originating domain, and a fingerprint for dedup.

I group by domain so that a motion sensor, a leak sensor, and the front door each have their own notify service. That gives me per-domain cooldown and per-domain formatting without writing it in every automation.

3. My Production configuration.yaml

Here is the full notify block I run. It defines four targets, one per domain, each pointing at a real platform. The push alias is the one automations actually call.

# configuration.yaml
notify:
  - name: push_alerts
    platform: group
    services:
      - service: notify.telegram
      - service: notify.wecom
      - service: notify.mobile_app_phone

  - name: telegram
    platform: telegram_bot
    api_token: !secret telegram_bot_token
    chat_id: !secret telegram_chat_id

  - name: wecom
    platform: wecom  # custom_component; wraps the enterprise WeChat webhook
    webhook: !secret wecom_webhook_url

  - name: mobile_app_phone
    platform: mobile_app

The group platform is the heart of it. notify.push_alerts fans a single call out to every member. I register the group as a real service, so an automation can call notify.push_alerts and the whole fan-out happens behind the interface. When I add a fifth channel later, I edit one service list, not forty automations.

4. The Unified Dispatch: One Helper, Four Severities

Every automation in my house calls one script, script.notify_dispatch. It owns the routing decision so nothing else has to. The interface is deliberately small:

  • target — a friendly label (e.g. "leak sensor living room").
  • severity — one of critical, warning, info, debug.
  • message — the body.
  • domain — which domain owns the event, used for per-domain cooldown.
# scripts.yaml
notify_dispatch:
  alias: "Route a notification by severity"
  fields:
    target:
      description: "Human label for where the alert came from"
    severity:
      description: "critical | warning | info | debug"
    message:
      description: "The message body"
    domain:
      description: "Originating domain, for cooldown bucketing"
  sequence:
    - variables:
        sev: "{{ severity | default('info') }}"
        chan: >
          {% set m = {'critical': ['notify.push_alerts', 'notify.telegram', 'notify.wecom'],
                      'warning': ['notify.telegram', 'notify.wecom'],
                      'info':    ['notify.telegram'],
                      'debug':   []} %}
          {{ m.get(sev, m['info']) }}
    - repeat:
        for_each: "{{ chan }}"
        sequence:
          - service: "{{ repeat.item }}"
            data:
              title: "{{ target }} [{{ sev | upper }}]"
              message: "{{ message }}"
              data:
                severity: "{{ sev }}"
                fingerprint: "{{ domain }}:{{ target }}"

The channel map is the whole routing policy in one place. critical goes to every channel including the mobile app (which can raise an alert on a locked phone). warning goes to push + wecom. info goes to a single Telegram chat. debug goes nowhere unless I flip it. Changing policy means editing one dict.

5. Severity Routing: The Table I Actually Use

The rule I live by: critical alerts get redundancy, info alerts get one quiet channel, and nothing low-priority ever shares a target with a fire alarm. Concretely:

Severity Example Channels Cooldown
critical Smoke, CO, leak, door forced push_alerts + telegram + wecom 5 min
warning Battery low, device offline, lock jam telegram + wecom 15 min
info Arrival home, mode change, daily summary telegram 60 min
debug Sensor polling, test pings none (off) —

The cooldown column is the second half of the routing story and it is not optional. A leak sensor that flaps will otherwise hammer every channel, and the cooldown is what turns "50 identical messages" into "one message, then silence."

6. Cooldown and Dedup: Stop the Storm Before It Starts

A notification storm is the failure mode I hit hardest. A door sensor with a dying battery re-triggered every 30 seconds for four hours. Telegram rate-limited, WeCom refused, and my phone lit up two hundred times. Two mechanisms fixed it.

Dedup by fingerprint. I compute a fingerprint per event and refuse to send a repeat within the window. The state machine lives in an input number per domain, keyed by the fingerprint:

# input_number.yaml — cooldown counters
notify_cool_leak:
  name: "Leak cooldown timestamp"
  min: 0
  max: 9999999999
# scripts.yaml — the dedup guard, called before dispatch
notify_guard:
  alias: "Suppress duplicate notifications inside the cooldown window"
  fields:
    domain:
    fingerprint:
    window:
      default: 300
  sequence:
    - variables:
        now: "{{ now().timestamp() | int }}"
        key: "notify_cool_{{ domain }}"
    - condition: template
      value_template: >
        {{ states(key) | float(0) | int + window | int < now }}
    - service: input_number.set_value
      target:
        entity_id: "{{ key }}"
      data:
        value: "{{ now }}"
    - service: script.notify_dispatch
      data:
        domain: "{{ domain }}"
        fingerprint: "{{ fingerprint }}"
        severity: "{{ severity }}"
        message: "{{ message }}"
        target: "{{ target }}"

If the last send was inside the window, the condition fails and the guard returns without sending. The side effect is that a storm collapses to exactly one message per window. For a genuinely persistent problem I send a summary instead of a repeat — one line like "Leak sensor: 14 triggers in the last hour, still active," which is far more useful than 14 copies.

7. Retry and Degradation: Failing Toward the User

Channels die. The pipeline has to notice and route around it. I wrap every send in a retry-with-backoff, then on final failure degrade to the next channel in the severity group. critical never gives up — it walks the whole list. info gives up after one retry because a stale info message is not worth a second channel.

# scripts.yaml — retry wrapper
notify_send_with_retry:
  alias: "Send a notify call with backoff, then degrade"
  fields:
    service:
    message:
    attempts:
      default: 3
  sequence:
    - repeat:
        for_each: "{{ range(1, attempts | int + 1) }}"
        sequence:
          - service: "{{ service }}"
            data:
              message: "{{ message }}"
            continue_on_error: true
          - condition: template
            value_template: "{{ repeat.last }}"
          - delay: "00:00:0{{ repeat.index * 2 }}"

The continue_on_error: true is the production-grade part. It means one failing channel does not abort the whole script. On the final attempt I log the failure to a sensor so the dashboard shows me the pipeline health instead of hiding it.

8. Five Real Incidents

Incident 1: Telegram bot token revoked, and I did not know for a week

Symptom: A "battery low" alert never arrived. Root cause: I had pasted the token into a public gist once, and Telegram's BotFather revoked it after a privacy scan. The notify call still returned success because the platform had no error handling wired to me. Fix: I added a telegram_health ping sensor, moved the token to secrets.yaml, and made critical alerts fan out to WeCom as well. Now a dead Telegram channel is visible on the dashboard, not a silent gap in my life.

Incident 2: WeCom webhook rate-limited, then silently dropped

Symptom: A front-door "left open" alert stopped arriving for 20 minutes. Root cause: WeCom enterprise webhooks cap at roughly 20 messages per minute. My door sensor had flapped 40 times in 2 minutes, the webhook went into refusal, and subsequent messages were queued and dropped. Fix: the cooldown guard from section 6, plus an alert when the WeCom client reports a 429. The 429 is now an info-level event so I know the webhook is being throttled before it matters.

Incident 3: Xiaomi push arrived twelve minutes late

Symptom: A CO alarm fired, but the Xiaomi push landed 12 minutes after the Telegram message. Root cause: Xiaomi push is not a priority channel; the vendor queues messages behind its own traffic, and latency is unbounded. Fix: I demoted Xiaomi to an info-only, never-critical channel. For anything time-sensitive I rely on Telegram + WeCom, which are both faster, and I never let a slow vendor be the only path for a critical event.

Incident 4: Notification storm — 200 messages in four hours

Symptom: Phone vibrating non-stop, WeCom rate-limited, Telegram throttled. Root cause: a door sensor with a failing battery re-triggered every 30 seconds. Fix: the dedup + cooldown guard, plus a per-domain aggregation that collapses repeats into a single summary. The same sensor now produces one message, and my automation logs the trigger count so I can see the underlying fault.

Incident 5: Telegram message body truncated at 4096 characters

Symptom: A daily summary cut off mid-sentence with a ... and no error. Root cause: Telegram's 4096-character limit; the notify platform silently truncates. Fix: I now render summaries to a length-aware template that keeps under 3500 characters, and for full logs I attach a file instead of inlining. The template asserts the length and logs a warning if it ever exceeds the safe bound.

9. Automation Triggers That Call the Pipeline

The pipeline is only as good as the automations that feed it. Here is a real one — a leak sensor that routes by severity and carries the domain through:

# automations.yaml
- id: leak_alert_living_room
  alias: "Leak sensor living room — critical alert"
  trigger:
    - platform: state
      entity_id: binary_sensor.leak_living_room
      to: "on"
      for: "00:00:05"
  action:
    - service: script.notify_dispatch
      data:
        domain: "leak"
        target: "Living room leak sensor"
        severity: "critical"
        message: >
          Water detected under the kitchen sink.
          Current humidity: {{ states('sensor.kitchen_humidity') }}%.

And a battery-low warning that should never fire twice in the same day:

- id: battery_low_alert
  alias: "Battery low — warning with daily dedup"
  trigger:
    - platform: numeric_state
      entity_id: sensor.office_temp_battery
      below: 20
      for: "00:10:00"
  action:
    - service: script.notify_guard
      data:
        domain: "battery"
        fingerprint: "battery:office_temp"
        window: 86400
        severity: "warning"
        target: "Office temp sensor battery"
        message: "Battery at {{ states('sensor.office_temp_battery') }}%. Replace within 7 days."

The 24-hour window on the battery alert is the difference between "one useful heads-up" and "a nagging reminder every ten minutes."

10. Template Variables and Message Rendering

I keep all message rendering in a template so I can reuse it and test the length bound. A helper template lives in the templates: section:

# configuration.yaml
template:
  - notify_summary:
      alias: "Render a severity-tagged summary under the Telegram limit"
      fields:
        header:
        lines:
      then: >
        {% set body = (header + "\n" + lines | join("\n")) %}
        {% if body | length > 3500 %}
          {{ (body[:3500] + "\n… truncated") }}
        {% else %}
          {{ body }}
        {% endif %}

Templates are where the per-domain voice lives. A leak alert is terse and imperative. A daily summary is prose. Both pass through the same length guard, so neither can ever silently truncate.

11. Testing the Pipeline Before You Trust It

I test with a debug-severity ping, once a week, on a schedule, and I assert the result. If a channel is dead I find out on my schedule, not on a fire alarm's schedule.

- id: notify_health_check
  alias: "Weekly notify health ping"
  trigger:
    - platform: time_pattern
      hours: "/168"  # every 7 days
      minutes: 9
  action:
    - service: script.notify_dispatch
      data:
        domain: "health"
        target: "Pipeline health check"
        severity: "info"
        message: "All channels nominal. {{ now().strftime('%Y-%m-%d %H:%M') }}"

A healthy pipeline is boring. That is the goal. When my phone buzzes, it means something real, not a door sensor that flapped at 3am.

12. FAQ

Q: Why does my Telegram notification randomly stop arriving?

A: The bot token was revoked, the chat ID changed, or the webhook got rate-limited. Add a second channel and a weekly health-check ping before you trust a silent failure.

Q: How do I stop a door sensor from spamming every minute?

A: Dedup and cooldown on the notify service. Track the last message fingerprint and refuse repeats inside the window, then aggregate into a single summary line.

Q: What happens when the primary channel is down?

A: Degradation: retry with backoff, then fall through to the next channel in the severity group. Critical alerts always try every channel; info alerts give up faster.

Q: How should I pick a channel for each alert?

A: By severity and scenario. Critical goes to push + all channels, warning to push only, info to a single chat. Never send low-priority noise to the same target as a fire alarm.

Q: Why was my message body cut off in Telegram?

A: The 4096-character limit. Send data as an attachment or truncate with a clean summary, and never put a full sensor log into a notify title.