Hi everyone — I'm the developer of Echobell, operated by Dippod LLC. It's an iOS/Android app for webhook and email notifications. I'm sharing a small Node-RED example for getting device-offline events onto a phone without sending a notification for every repeated event.
The flow uses only built-in nodes:
Inject → Function (per-source cooldown) → HTTP Request → Function (check response) → Debug.
A scoped Catch node handles failures from the HTTP Request node.
What it does
- The two Inject buttons simulate an offline event and a recovery for
demo-sensor. They do not detect a real outage. - Repeated events with the same source and state are suppressed for 60 seconds after a send attempt. A state change passes immediately, so recovery and a subsequent outage aren't hidden by the cooldown.
- Different sources have separate cooldowns. This is not a global rate limiter: rapidly alternating states can still generate many alerts.
- HTTP 200 alone isn't treated as success: the response must also contain
success: true. - The final Debug node receives only a small status summary, not the webhook URL or response headers.
Set it up
- In Echobell, create and subscribe to a test channel. Set the title template to
[{{source}}] {{state}}and the body to{{detail}} — {{time}}. - Copy that channel's webhook URL. Set
ECHOBELL_WEBHOOK_URLin the Node-RED process environment, then restart Node-RED. Don't put the real URL into the shared JSON. For service/Docker installs, pass the variable to the actual service/container. - Import the JSON below using Menu → Import → Clipboard, then Deploy. No events are sent automatically.
- Click Simulate offline, then click it again: the second attempt should be suppressed. Click Simulate recovery to send a recovery immediately. Check both the Debug sidebar and your phone.
The HTTP Request node uses POST, parses the response as JSON, and gets a 10-second request timeout from the preceding Function. Echobell's call-style alert is an app-based ringing notification, not a carrier phone call. Basic notifications are available on the free tier; call-style alerts require Premium.
Connecting your own monitoring
Replace the Inject nodes with your existing monitor's events. Send a stable source identifier in msg.topic, and an object like this in msg.payload:
{"state":"offline","detail":"No heartbeat for 90 seconds"}
Send {"state":"recovered","detail":"Heartbeat resumed"} when it comes back. With MQTT, for example, map your device's availability/LWT messages to this format; an LWT needs to be configured on the publishing device. Only include details you're comfortable putting in a phone notification.
Limits and verification
This is a small example, not a durable alert queue. Cooldown is based on attempts, including failed requests; there are no automatic retries. Another incoming event after 60 seconds can trigger a new attempt. With default in-memory context, restarting Node-RED resets the cooldown. The example caps active cooldown records at 1,000 sources. If your Node-RED host itself goes offline, use an external monitor.
The flow logic and real HTTP Request node were checked with Node-RED 5.0.7 against a local test endpoint: JSON POST, duplicate suppression/recovery, HTTP 200 with success:false, HTTP 500, and connection refusal. Actual Echobell delivery and phone ringing were not part of that test, so please check receipt and notification permissions on your device. Avoid adding complete-message Debug nodes around the HTTP request: those messages can contain the secret URL.
For those doing device monitoring: do you prefer a periodic reminder while a device stays offline, or just the initial outage and recovery? This example allows reminders on incoming events after the cooldown; I'd be interested in how you handle that in practice.
Importable flow
Flow JSON — expand to copy
[{"id":"eb-demo","type":"tab","label":"Echobell offline alerts","disabled":false,"info":"Demo events only. Set ECHOBELL_WEBHOOK_URL outside the exported flow. Same state/source: one attempt per 60 seconds. State changes pass immediately. No automatic retries."},{"id":"eb-off","type":"inject","z":"eb-demo","name":"Simulate offline","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"demo-sensor","payload":"{\"state\":\"offline\",\"detail\":\"Demo: no heartbeat received\"}","payloadType":"json","x":150,"y":100,"wires":[["eb-prepare"]]},{"id":"eb-on","type":"inject","z":"eb-demo","name":"Simulate recovery","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"demo-sensor","payload":"{\"state\":\"recovered\",\"detail\":\"Demo: heartbeat resumed\"}","payloadType":"json","x":150,"y":180,"wires":[["eb-prepare"]]},{"id":"eb-prepare","type":"function","z":"eb-demo","name":"60s per source/state","func":"const event = msg.payload;\nif (!event || typeof msg.topic !== \"string\" || !msg.topic.trim() ||\n ![\"offline\", \"recovered\"].includes(event.state)) {\n node.warn(\"Expected msg.topic and payload.state = offline or recovered\");\n return null;\n}\nif (!env.get(\"ECHOBELL_WEBHOOK_URL\")) {\n node.warn(\"Set ECHOBELL_WEBHOOK_URL in the runtime environment first\");\n return null;\n}\nconst now = Date.now();\nconst cooldown = 60000;\nconst history = context.get(\"history\") || {};\n// Bound memory by discarding expired entries before adding another source.\nfor (const key of Object.keys(history)) {\n if (now - history[key].time >= cooldown) delete history[key];\n}\nconst key = JSON.stringify(msg.topic);\nconst previous = history[key];\nif (previous && previous.state === event.state) return null;\nif (!previous && Object.keys(history).length >= 1000) {\n node.warn(\"Alert source limit reached; event dropped\");\n return null;\n}\nhistory[key] = { state: event.state, time: now };\ncontext.set(\"history\", history);\n// Return only intended notification fields, never the complete source message.\nreturn {\n payload: {\n source: msg.topic,\n state: event.state,\n detail: typeof event.detail === \"string\" ? event.detail : \"\",\n time: new Date(now).toISOString()\n },\n headers: { \"content-type\": \"application/json\" },\n requestTimeout: 10000\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":400,"y":140,"wires":[["eb-http"]]},{"id":"eb-http","type":"http request","z":"eb-demo","name":"Echobell webhook","method":"POST","ret":"obj","paytoqs":"ignore","url":"${ECHOBELL_WEBHOOK_URL}","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":true,"headers":[],"x":650,"y":140,"wires":[["eb-result"]]},{"id":"eb-catch","type":"catch","z":"eb-demo","name":"Only webhook errors","scope":["eb-http"],"uncaught":false,"x":650,"y":220,"wires":[["eb-result"]]},{"id":"eb-result","type":"function","z":"eb-demo","name":"Check success; redact","func":"const ok = !msg.error && Number(msg.statusCode) >= 200 &&\n Number(msg.statusCode) < 300 && msg.payload && msg.payload.success === true;\n// Discard URL, headers, response body and transport error text before Debug.\nreturn { payload: {\n accepted: Boolean(ok),\n status: typeof msg.statusCode === \"number\" ? msg.statusCode : \"transport-error\",\n note: ok ? \"Webhook accepted; phone receipt is not confirmed\" :\n \"Webhook failed or success was not true; check channel and connectivity\"\n}};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":900,"y":140,"wires":[["eb-debug"]]},{"id":"eb-debug","type":"debug","z":"eb-demo","name":"Safe webhook result","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1140,"y":140,"wires":[]}]