Home Assistant state change notifications on your phone.

Get a push notification when a Home Assistant entity changes state, in words (a door sensor says open or closed), louder for leaks, smoke and gas.

How it works

Three steps to your first push

STEP 01

Install the recipe

One click in Studio gives you a personal webhook URL.

STEP 02

Paste your URL into Home Assistant

Add it as a webhook for State trigger.

STEP 03

Get a push on your phone

With the text, sound and buttons from the recipe.

Setup

How to set up the Home Assistant state trigger webhook

  1. Click Install in Studio and sign in. The recipe is added to your account and you get a personal webhook URL.
  2. Home Assistant has no outgoing webhooks, so add a rest_command to your configuration.yaml that posts JSON to your webhook URL.https://••••••••/•••••••• your personal URL, shown after install
  3. Restart Home Assistant (or reload YAML) so the rest_command action appears.
  4. Call that action from an automation or script, as shown in the recipe's instructions.
  5. Home Assistant has no outgoing webhooks, so you send the state change yourself with a **RESTful Command** (`rest_command`) called from an automation with a **state trigger**. The command and the automation both use Home Assistant templates (to read the trigger's old and new state), so the copy-paste YAML is in this recipe's README on GitHub: https://github.com/JustPush-io/studio-recipes/tree/main/home-assistant/state-change
  6. Use this as the command's `url`:
  7. ```
  8. your webhook URL
  9. ```
  10. The body is `entity_id`, `name`, `from_state`, `to_state`, `device_class` and `unit` (plus optional `priority` and `topic`). With a `device_class`, raw states become words: a door sensor's `on` becomes **open**, a leak sensor's becomes **wet**. Leaks, smoke, gas and CO turning on are Highest priority.
  11. Changes to or from `unknown` and `unavailable` are skipped, because Home Assistant makes lots of them when it restarts. Add `?unavailable=1` to the URL to get a quiet push when an entity drops off. Add `?ha=` with your Home Assistant address (e.g. `?ha=https://homeassistant.local:8123`) for a **View history** button.
  12. Requests aren't signed, so keep your endpoint URL private (put it in `secrets.yaml` if you share your configuration).
The code

What runs when the webhook arrives

Studio calls handleRequest(request) with the incoming webhook and sends the message it returns. It's yours after install; change anything.

transform.js
1// Home Assistant → state change (rest_command)
2// Fires when a Home Assistant automation with a state trigger calls a rest_command that posts
3// {"entity_id", "name", "from_state", "to_state", "device_class", "unit", "priority"}.
4// Docs: https://www.home-assistant.io/docs/automation/trigger/#state-trigger
5//
6// Turns raw states into words ("on" on a door sensor becomes "open") and skips the noise
7// Home Assistant makes on restarts: changes to or from "unknown" and "unavailable".
8
9// What "on" and "off" mean for common binary_sensor device classes, plus an icon and how loud to be
10// when it turns on.
11const CLASSES = {
12 door: { on: "open", off: "closed", icon: "🚪", priority: 0 },
13 garage_door: { on: "open", off: "closed", icon: "🚗", priority: 0 },
14 window: { on: "open", off: "closed", icon: "🪟", priority: 0 },
15 opening: { on: "open", off: "closed", icon: "🚪", priority: 0 },
16 lock: { on: "unlocked", off: "locked", icon: "🔓", priority: 0 },
17 motion: { on: "motion detected", off: "clear", icon: "🏃", priority: 0 },
18 occupancy: { on: "occupied", off: "clear", icon: "🏃", priority: 0 },
19 presence: { on: "home", off: "away", icon: "🏠", priority: -1 },
20 moisture: { on: "wet", off: "dry", icon: "💧", priority: 2 }, // Highest: a leak
21 smoke: { on: "smoke detected", off: "clear", icon: "🚨", priority: 2 }, // Highest: fire
22 gas: { on: "gas detected", off: "clear", icon: "🚨", priority: 2 },
23 carbon_monoxide: { on: "CO detected", off: "clear", icon: "🚨", priority: 2 },
24 safety: { on: "unsafe", off: "safe", icon: "🚨", priority: 2 },
25 problem: { on: "problem", off: "OK", icon: "⚠️", priority: 1 }, // High: something needs fixing
26 battery: { on: "battery low", off: "battery normal", icon: "🔋", priority: 0 },
27 connectivity: { on: "connected", off: "disconnected", icon: "📶", priority: 0 },
28 plug: { on: "plugged in", off: "unplugged", icon: "🔌", priority: -1 },
29 power: { on: "power detected", off: "no power", icon: "⚡", priority: 0 },
30 running: { on: "running", off: "stopped", icon: "⚙️", priority: -1 },
31 tamper: { on: "tampered", off: "OK", icon: "🚨", priority: 1 },
32 vibration: { on: "vibration detected", off: "clear", icon: "📳", priority: 0 },
33}
34
35// Raw states that are the same for every device class.
36const STATES = {
37 on: "on",
38 off: "off",
39 open: "open",
40 closed: "closed",
41 locked: "locked",
42 unlocked: "unlocked",
43 home: "home",
44 not_home: "away",
45 armed_away: "armed (away)",
46 armed_home: "armed (home)",
47 armed_night: "armed (night)",
48 disarmed: "disarmed",
49 triggered: "triggered",
50}
51
52// What Home Assistant templates render when a value is missing. Treated as "not given".
53const MISSING = new Set(["", "none", "null", "unknown", "unavailable", "undefined", "nan"])
54
55// A trimmed, non-missing string (numbers are turned into text), or null.
56function clean(value, max = 250) {
57 if (typeof value === "number") return Number.isFinite(value) ? String(value) : null
58 if (typeof value === "boolean") return String(value)
59 if (typeof value !== "string") return null
60
61 const text = value.trim()
62 if (MISSING.has(text.toLowerCase())) return null
63 return text.length > max ? `${text.slice(0, max - 1)}…` : text
64}
65
66// Table lookup that ignores inherited names such as "toString".
67function pick(table, key, fallback) {
68 return typeof key === "string" && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback
69}
70
71// Only link to real http(s) URLs.
72function link(url, cta) {
73 return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : []
74}
75
76// Priority as a number (-2…2), a numeric string, or a word such as "high".
77const PRIORITY_WORDS = {
78 lowest: -2,
79 low: -1,
80 normal: 0,
81 default: 0,
82 high: 1,
83 highest: 2,
84 urgent: 2,
85 critical: 2,
86 emergency: 2,
87}
88
89function priority(value, fallback) {
90 const clamp = (n) => Math.max(-2, Math.min(2, Math.round(n)))
91 if (typeof value === "number") return Number.isFinite(value) ? clamp(value) : fallback
92
93 const text = clean(value)
94 if (!text) return fallback
95 if (/^[+-]?\d+(\.\d+)?$/.test(text)) return clamp(Number(text))
96 return pick(PRIORITY_WORDS, text.toLowerCase(), fallback)
97}
98
99// Entity IDs look like "binary_sensor.front_door".
100function entityId(value) {
101 const text = clean(value)
102 return text && /^[a-z0-9_]+\.[a-z0-9_]+$/.test(text) ? text : null
103}
104
105// The request body as an object. A rest_command without content_type: application/json
106// may arrive as a JSON string, so that's parsed too.
107function bodyOf(request) {
108 let body = request.body
109 if (typeof body === "string" && body.trim().startsWith("{")) {
110 try {
111 body = JSON.parse(body)
112 } catch {
113 return {}
114 }
115 }
116 return body && typeof body === "object" && !Array.isArray(body) ? body : {}
117}
118
119// ?ha=https://homeassistant.local:8123 adds a button to the entity's history.
120function historyButton(request, entity) {
121 const base = clean(request.query?.ha, 200)
122 if (!entity || !base || !/^https?:\/\/[^\s]+$/.test(base)) return []
123 return link(`${base.replace(/\/+$/, "")}/history?entity_id=${encodeURIComponent(entity)}`, "View history")
124}
125
126// A state in words, with the unit for numbers.
127function words(state, kind, unit) {
128 if (kind && (state === "on" || state === "off")) return kind[state]
129 if (unit && /^[+-]?\d+(\.\d+)?$/.test(state)) return `${state} ${unit}`
130 return pick(STATES, state, state)
131}
132
133function handleRequest(request) {
134 const body = bodyOf(request)
135
136 // Guard: only state-change bodies, so other requests on this integration stay quiet.
137 if (!Object.prototype.hasOwnProperty.call(body, "to_state")) {
138 console.log("Ignoring Home Assistant request without to_state")
139 return null
140 }
141
142 const entity = entityId(body.entity_id)
143 const name = clean(body.name, 80) ?? entity
144 const rawTo = typeof body.to_state === "string" ? body.to_state.trim().toLowerCase() : null
145 const rawFrom = typeof body.from_state === "string" ? body.from_state.trim().toLowerCase() : null
146 const to = clean(body.to_state, 100)
147 const from = clean(body.from_state, 100)
148
149 if (!name) {
150 console.log("Ignoring state change without entity_id or name")
151 return null
152 }
153
154 // An entity dropping off: only pushed with ?unavailable=1 (restarts make lots of these).
155 if (rawTo === "unavailable" || rawTo === "unknown") {
156 if (!["1", "true", "yes"].includes(String(request.query?.unavailable ?? "").toLowerCase())) {
157 console.log(`Skipping ${name} → ${rawTo}`)
158 return null
159 }
160 return {
161 title: `📴 ${name} unavailable`,
162 message: from ? `Was ${from}` : "Home Assistant lost contact with it",
163 topic: clean(body.topic, 50) ?? "Home Assistant",
164 priority: -1, // Low: often just a restart or a flaky battery sensor
165 buttons: historyButton(request, entity),
166 }
167 }
168
169 // Coming back after a restart, or an attribute-only change: not a real state change.
170 if (!to || rawFrom === "unavailable" || rawFrom === "unknown" || (from !== null && from === to)) {
171 console.log(`Skipping ${name}: ${from ?? "?"} → ${to ?? "?"}`)
172 return null
173 }
174
175 const kind = pick(CLASSES, clean(body.device_class, 40), null)
176 const unit = clean(body.unit, 20)
177 const toText = words(to.toLowerCase() === "on" || to.toLowerCase() === "off" ? to.toLowerCase() : to, kind, unit)
178 const fromText = from ? words(from.toLowerCase() === "on" || from.toLowerCase() === "off" ? from.toLowerCase() : from, kind, unit) : null
179 // Device classes are loud only when they turn on (a leak, smoke); turning off is Low.
180 const loud = kind && rawTo === "on"
181
182 return {
183 title: `${kind && loud ? `${kind.icon} ` : ""}${name}: ${toText}`,
184 message: fromText ? `Changed from ${fromText} to ${toText}` : `Now ${toText}`,
185 topic: clean(body.topic, 50) ?? "Home Assistant",
186 priority: priority(body.priority, loud ? kind.priority : kind ? -1 : 0),
187 buttons: historyButton(request, entity),
188 }
189}
Payload

The Home Assistant state_changed webhook

This is what Home Assistant sends to your URL for state trigger. It is a sample, trimmed to the fields recipes use.

state_changed · sample.json
1{
2 "name": "Front door",
3 "unit": "",
4 "priority": "",
5 "to_state": "on",
6 "entity_id": "binary_sensor.front_door",
7 "from_state": "off",
8 "device_class": "door"
9}
FAQ

Home Assistant state change notifications: questions

Does this work on iPhone and Android?

Yes. Install the JustPush app from the App Store or Google Play and sign in. Every phone signed in to your account gets the notification.

Do I need to write code?

No. Install the recipe in Studio, paste your webhook URL into Home Assistant and you are done. The code is there if you want to change the text, the sound or the buttons.

Can I change what the notification says?

Yes. After install the recipe's code is yours. Edit it in Studio and test it against the sample payload before you save.

What does it cost?

JustPush is free for 30 days. After that it's $19.99 a year, or $39.99 once. There is no extra charge for recipes.

Ready when you are

Home Assistant on your phone in two minutes.

Install the recipe, paste one URL, done. Free for 30 days, no credit card required.