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.telegramreturns 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:
- The service is platform-agnostic. The same automation can call
notify.send_messageand the platform decides the transport. - Each platform has its own service name. I register logical targets as notify services so automations never know or care about the vendor.
datais 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 ofcritical,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.