# Device-offline phone alerts with Echobell: a flow with per-source cooldown

**URL:** <https://discourse.nodered.org/t/device-offline-phone-alerts-with-echobell-a-flow-with-per-source-cooldown/101800>\
**Category:** Share Your Projects\
**Tags:** docker\
**Created:** [17 September 2026 15:46 UTC](https://discourse.nodered.org/t/device-offline-phone-alerts-with-echobell-a-flow-with-per-source-cooldown/101800 "2026-09-17T15:46:50Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![noobnooc](https://sea2.discourse-cdn.com/flex026/user_avatar/discourse.nodered.org/noobnooc/32/108404_2.png) [@noobnooc](https://discourse.nodered.org/u/noobnooc)\
**Post date:** [17 September 2026 15:46 UTC](https://discourse.nodered.org/t/device-offline-phone-alerts-with-echobell-a-flow-with-per-source-cooldown/101800/1 "2026-09-17T15:46:50Z")

</div>

Hi everyone — I'm the developer of [Echobell](https://echobell.one), 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

1. In Echobell, create and subscribe to a test channel. Set the title template to `[{{source}}] {{state}}` and the body to `{{detail}} — {{time}}`.
2. Copy that channel's webhook URL. Set `ECHOBELL_WEBHOOK_URL` in 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.
3. Import the JSON below using **Menu → Import → Clipboard** , then Deploy. No events are sent automatically.
4. 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`:

```json
{"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**
>
> ```json
> [{"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":[]}]
> 
> ```
