# 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. - Company: Home Assistant (https://www.justpush.io/recipes/home-assistant) - Event: `state_changed` (State trigger) - Tags: Smart home, Home automation, Sensors & alarms - Install: https://studio.justpush.io/recipes/home-assistant/state-change - Web page: https://www.justpush.io/recipes/home-assistant/state-change ## Setup 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 (shown in Studio 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 (shown in Studio after install) 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). ## Code Studio calls `handleRequest(request)` with the incoming webhook (`{ method, headers, body, query, raw }`) and sends the message object it returns. Returning `null` sends nothing. ```js // Home Assistant → state change (rest_command) // Fires when a Home Assistant automation with a state trigger calls a rest_command that posts // {"entity_id", "name", "from_state", "to_state", "device_class", "unit", "priority"}. // Docs: https://www.home-assistant.io/docs/automation/trigger/#state-trigger // // Turns raw states into words ("on" on a door sensor becomes "open") and skips the noise // Home Assistant makes on restarts: changes to or from "unknown" and "unavailable". // What "on" and "off" mean for common binary_sensor device classes, plus an icon and how loud to be // when it turns on. const CLASSES = { door: { on: "open", off: "closed", icon: "🚪", priority: 0 }, garage_door: { on: "open", off: "closed", icon: "🚗", priority: 0 }, window: { on: "open", off: "closed", icon: "🪟", priority: 0 }, opening: { on: "open", off: "closed", icon: "🚪", priority: 0 }, lock: { on: "unlocked", off: "locked", icon: "🔓", priority: 0 }, motion: { on: "motion detected", off: "clear", icon: "🏃", priority: 0 }, occupancy: { on: "occupied", off: "clear", icon: "🏃", priority: 0 }, presence: { on: "home", off: "away", icon: "🏠", priority: -1 }, moisture: { on: "wet", off: "dry", icon: "💧", priority: 2 }, // Highest: a leak smoke: { on: "smoke detected", off: "clear", icon: "🚨", priority: 2 }, // Highest: fire gas: { on: "gas detected", off: "clear", icon: "🚨", priority: 2 }, carbon_monoxide: { on: "CO detected", off: "clear", icon: "🚨", priority: 2 }, safety: { on: "unsafe", off: "safe", icon: "🚨", priority: 2 }, problem: { on: "problem", off: "OK", icon: "⚠️", priority: 1 }, // High: something needs fixing battery: { on: "battery low", off: "battery normal", icon: "🔋", priority: 0 }, connectivity: { on: "connected", off: "disconnected", icon: "📶", priority: 0 }, plug: { on: "plugged in", off: "unplugged", icon: "🔌", priority: -1 }, power: { on: "power detected", off: "no power", icon: "⚡", priority: 0 }, running: { on: "running", off: "stopped", icon: "⚙️", priority: -1 }, tamper: { on: "tampered", off: "OK", icon: "🚨", priority: 1 }, vibration: { on: "vibration detected", off: "clear", icon: "📳", priority: 0 }, } // Raw states that are the same for every device class. const STATES = { on: "on", off: "off", open: "open", closed: "closed", locked: "locked", unlocked: "unlocked", home: "home", not_home: "away", armed_away: "armed (away)", armed_home: "armed (home)", armed_night: "armed (night)", disarmed: "disarmed", triggered: "triggered", } // What Home Assistant templates render when a value is missing. Treated as "not given". const MISSING = new Set(["", "none", "null", "unknown", "unavailable", "undefined", "nan"]) // A trimmed, non-missing string (numbers are turned into text), or null. function clean(value, max = 250) { if (typeof value === "number") return Number.isFinite(value) ? String(value) : null if (typeof value === "boolean") return String(value) if (typeof value !== "string") return null const text = value.trim() if (MISSING.has(text.toLowerCase())) return null return text.length > max ? `${text.slice(0, max - 1)}…` : text } // Table lookup that ignores inherited names such as "toString". function pick(table, key, fallback) { return typeof key === "string" && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : fallback } // Only link to real http(s) URLs. function link(url, cta) { return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : [] } // Priority as a number (-2…2), a numeric string, or a word such as "high". const PRIORITY_WORDS = { lowest: -2, low: -1, normal: 0, default: 0, high: 1, highest: 2, urgent: 2, critical: 2, emergency: 2, } function priority(value, fallback) { const clamp = (n) => Math.max(-2, Math.min(2, Math.round(n))) if (typeof value === "number") return Number.isFinite(value) ? clamp(value) : fallback const text = clean(value) if (!text) return fallback if (/^[+-]?\d+(\.\d+)?$/.test(text)) return clamp(Number(text)) return pick(PRIORITY_WORDS, text.toLowerCase(), fallback) } // Entity IDs look like "binary_sensor.front_door". function entityId(value) { const text = clean(value) return text && /^[a-z0-9_]+\.[a-z0-9_]+$/.test(text) ? text : null } // The request body as an object. A rest_command without content_type: application/json // may arrive as a JSON string, so that's parsed too. function bodyOf(request) { let body = request.body if (typeof body === "string" && body.trim().startsWith("{")) { try { body = JSON.parse(body) } catch { return {} } } return body && typeof body === "object" && !Array.isArray(body) ? body : {} } // ?ha=https://homeassistant.local:8123 adds a button to the entity's history. function historyButton(request, entity) { const base = clean(request.query?.ha, 200) if (!entity || !base || !/^https?:\/\/[^\s]+$/.test(base)) return [] return link(`${base.replace(/\/+$/, "")}/history?entity_id=${encodeURIComponent(entity)}`, "View history") } // A state in words, with the unit for numbers. function words(state, kind, unit) { if (kind && (state === "on" || state === "off")) return kind[state] if (unit && /^[+-]?\d+(\.\d+)?$/.test(state)) return `${state} ${unit}` return pick(STATES, state, state) } function handleRequest(request) { const body = bodyOf(request) // Guard: only state-change bodies, so other requests on this integration stay quiet. if (!Object.prototype.hasOwnProperty.call(body, "to_state")) { console.log("Ignoring Home Assistant request without to_state") return null } const entity = entityId(body.entity_id) const name = clean(body.name, 80) ?? entity const rawTo = typeof body.to_state === "string" ? body.to_state.trim().toLowerCase() : null const rawFrom = typeof body.from_state === "string" ? body.from_state.trim().toLowerCase() : null const to = clean(body.to_state, 100) const from = clean(body.from_state, 100) if (!name) { console.log("Ignoring state change without entity_id or name") return null } // An entity dropping off: only pushed with ?unavailable=1 (restarts make lots of these). if (rawTo === "unavailable" || rawTo === "unknown") { if (!["1", "true", "yes"].includes(String(request.query?.unavailable ?? "").toLowerCase())) { console.log(`Skipping ${name} → ${rawTo}`) return null } return { title: `📴 ${name} unavailable`, message: from ? `Was ${from}` : "Home Assistant lost contact with it", topic: clean(body.topic, 50) ?? "Home Assistant", priority: -1, // Low: often just a restart or a flaky battery sensor buttons: historyButton(request, entity), } } // Coming back after a restart, or an attribute-only change: not a real state change. if (!to || rawFrom === "unavailable" || rawFrom === "unknown" || (from !== null && from === to)) { console.log(`Skipping ${name}: ${from ?? "?"} → ${to ?? "?"}`) return null } const kind = pick(CLASSES, clean(body.device_class, 40), null) const unit = clean(body.unit, 20) const toText = words(to.toLowerCase() === "on" || to.toLowerCase() === "off" ? to.toLowerCase() : to, kind, unit) const fromText = from ? words(from.toLowerCase() === "on" || from.toLowerCase() === "off" ? from.toLowerCase() : from, kind, unit) : null // Device classes are loud only when they turn on (a leak, smoke); turning off is Low. const loud = kind && rawTo === "on" return { title: `${kind && loud ? `${kind.icon} ` : ""}${name}: ${toText}`, message: fromText ? `Changed from ${fromText} to ${toText}` : `Now ${toText}`, topic: clean(body.topic, 50) ?? "Home Assistant", priority: priority(body.priority, loud ? kind.priority : kind ? -1 : 0), buttons: historyButton(request, entity), } } ``` ## Home Assistant state_changed webhook payload (sample) ```json { "name": "Front door", "unit": "", "priority": "", "to_state": "on", "entity_id": "binary_sensor.front_door", "from_state": "off", "device_class": "door" } ``` ## FAQ ### 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.