SignalK ntfy: pushing SignalK alarms to your phone with a zero-dependency notification relay
SignalK knows when something is wrong. It raises alarms into its notifications.* tree with a clear severity ladder:
nominal < normal < alert < warn < alarm < emergency
A low-battery condition shows up as a warn; a man-overboard lands at notifications.mob with state emergency. The data is right there, structured and timestamped:
{
"notifications": {
"mob": {
"value": {
"state": "emergency",
"method": ["visual", "sound"],
"message": "Man overboard"
},
"timestamp": "2026-06-01T19:30:00Z"
}
}
}
The problem: nothing pushes those alarms to your phone when you’re not standing in front of the chartplotter. If you’re below, ashore, or asleep, the alarm fires into the void. We wanted one dependable thing — a SignalK notification turning into a push on a phone — and we wanted it on a safety path, which raises the bar.
This is the broke → tried → fixed of getting SignalK push notifications to your phone. The punchline: a one-way SignalK→ntfy relay needs zero npm dependencies, so we built signalk-ntfy-relay.
What we evaluated (and why each fell short)
Option 1: the classic Pushover relay — stale
The most-referenced community answer to “SignalK notification to phone” is a relay plugin that forwards notifications to a push service. The canonical one is signalk-pushover-notification-relay, targeting Pushover.
The architecture is right — subscribe to notifications, POST to a push service. But the receipts are bad:
$ npm view signalk-pushover-notification-relay
signalk-pushover-notification-relay@1.0.0 | ISC | deps: none
published over a year ago
$ # repo: last pushed 2022 — roughly four years stale
When we checked, the repo hadn’t moved since 2022 and pushover.net was unresponsive. The plugin is fine; the sink is the problem. You do not want to hang a safety-relevant alert on a paid, closed service that’s unresponsive when you test it, behind a plugin nobody’s touched in four years.
That sent us looking for a better sink.
The better sink: ntfy
ntfy is the right target. It’s open-source, free, self-hostable, actively maintained, and has real iOS and Android apps. Most importantly, its publish API is just an HTTP POST — title, priority, and tags all ride in headers:
curl \
-H "Title: Low battery" \
-H "Priority: 4" \
-H "Tags: rotating_light" \
-H "Authorization: Bearer tk_yourtoken" \
-d "House bank below 30%" \
https://ntfy.sh/your-topic
Priority is 1 (min) through 5 (max). Tags is a comma-separated list of emoji shortcodes that render as icons on the notification. That’s the entire contract. No SDK, no handshake — a POST with headers. For a man-overboard you’d send:
curl -H "Title: MOB" -H "Priority: 5" -H "Tags: sos" \
-d "Man overboard" https://ntfy.sh/your-topic
Self-hostable, free, maintained, and a one-line publish API. That’s the sink. Now: who relays SignalK into it?
Option 2: the existing ntfy plugin — an early POC with the wrong scope
There’s already a plugin: signalk-ntfy (Enand-lab/signalk-ntfy). Verify it yourself — here’s what we found:
$ npm view signalk-ntfy
signalk-ntfy@0.0.4 | Apache-2.0 | deps: 2
dependencies:
node-fetch: ^2.7.0
ws: ^8.16.0
published 2 months ago
$ # GitHub: ~9 commits, single author, 0 stars / 0 forks
This is an early proof-of-concept: 0.0.4, one author, single-digit commits, effectively no adoption. That alone is reason to be careful on a safety path — but the more interesting issue is scope.
signalk-ntfy is bidirectional. Its headline feature is interactive ntfy buttons whose responses get published back into SignalK. That’s a neat idea, and it’s exactly why it pulls in two runtime dependencies:
ws— a WebSocket client to listen for button-press responses coming back from ntfy.node-fetch— to POST to ntfy.
For a one-way safety relay, the bidirectional half is dead weight, and both dependencies exist only to serve it. Worse, for our purposes the POC is missing the things a relay actually needs:
- edge-triggering — notify on a state change, not repeatedly while a condition persists;
- severity filtering — a configurable floor so
alert-level chatter doesn’t page you; - severity → ntfy priority/tags mapping — so an
emergencyarrives as priority 5 with an SOS icon; - position enrichment — where was the boat when this fired;
- tests.
So the choice was: adopt an immature 0.0.x and bolt the relay essentials onto a bidirectional architecture we don’t want — or build a focused relay.
Option 3: a generic SignalK→MQTT gateway — wrong shape
We also considered routing notifications through a generic SignalK→MQTT bridge and pushing from there. Wrong shape for a phone push: it speaks a foreign topic scheme, carries no notion of ntfy priority or tags, and pushes the severity→presentation mapping problem somewhere else instead of solving it. A relay should own the SignalK-notification → phone-push semantics end to end.
The decision: a focused, zero-dependency relay
Here’s the key insight that made the whole thing easy: a one-way SignalK→ntfy relay needs no npm dependencies at all. Both of the existing plugin’s dependencies fall away once you drop the bidirectional feature.
No ws. A SignalK plugin runs in-process inside the SignalK server, so it reads notifications directly through the plugin API — no external WebSocket client. You subscribe to the notifications.* subtree:
module.exports = function (app) {
const plugin = { id: 'signalk-ntfy-relay', name: 'SignalK ntfy relay' };
plugin.start = function (options) {
app.subscriptionmanager.subscribe(
{
context: 'vessels.self',
subscribe: [{ path: 'notifications.*', policy: 'instant' }]
},
[], // unsubscribes array
(err) => app.error(err),
(delta) => handleDelta(app, options, delta)
);
};
return plugin;
};
The ws dependency in the existing plugin exists only to hear button responses come back from ntfy. A relay never listens, so it’s gone.
No node-fetch. Node ships an HTTPS client. The entire ntfy POST is the standard library:
const https = require('node:https');
function postToNtfy({ server, topic, token, title, priority, tags, body }) {
const url = new URL(topic, server); // e.g. https://ntfy.sh/your-topic
const headers = {
'Title': title,
'Priority': String(priority), // 1..5
'Tags': tags.join(',') // emoji shortcodes
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const req = https.request(url, { method: 'POST', headers });
req.on('error', (err) => app.error(`ntfy post failed: ${err.message}`));
req.end(body);
}
That’s it. node-fetch was only ever a polyfill for fetch/HTTP — and on a modern Node runtime you don’t need it for a single POST.
Edge-triggering: notify on change, not on repeat
The relay essential the POC lacks. SignalK can re-emit a notification delta while a condition holds; you do not want a buzz every few seconds for a battery that’s still low. Keep an in-memory Map of the last state seen per path and only fire on transitions:
const lastState = new Map(); // path -> last notification state
function handleDelta(app, options, delta) {
for (const update of delta.updates ?? []) {
for (const { path, value } of update.values ?? []) {
const state = value?.state ?? 'normal'; // nominal|normal|alert|warn|alarm|emergency
if (lastState.get(path) === state) continue; // no change — skip
lastState.set(path, state);
if (severityRank(state) < severityRank(options.minSeverity)) continue;
relay(app, options, path, value, state, update.timestamp);
}
}
}
One transition, one push. A condition that clears (back to normal/nominal) is itself a transition — so you can send a “cleared” notification too, instead of leaving a stale alarm on the phone.
Severity → priority + tags
The mapping that turns a SignalK state into something ntfy renders meaningfully — loud icons for the loud states:
const RANK = {
nominal: 0, normal: 0, alert: 1, warn: 2, alarm: 3, emergency: 4
};
const severityRank = (s) => RANK[s] ?? 0;
// state -> { ntfy priority (1..5), tags (emoji shortcodes) }
const NTFY = {
emergency: { priority: 5, tags: ['sos'] },
alarm: { priority: 4, tags: ['rotating_light'] },
warn: { priority: 3, tags: ['warning'] },
alert: { priority: 2, tags: ['information_source'] },
cleared: { priority: 1, tags: ['white_check_mark'] } // back to normal/nominal
};
An emergency (man-overboard) lands as priority 5 with an SOS icon; a clear comes through as a quiet priority-1 check mark.
Position enrichment
When something fires, where were we? If the vessel has a fix, append a position line to the push body:
function withPosition(app, body) {
const pos = app.getSelfPath('navigation.position')?.value;
if (!pos) return body;
const ns = pos.latitude >= 0 ? 'N' : 'S';
const ew = pos.longitude >= 0 ? 'E' : 'W';
return `${body}\n${Math.abs(pos.latitude).toFixed(4)} ${ns}, ` +
`${Math.abs(pos.longitude).toFixed(4)} ${ew}`;
}
Self-hosted server + token
ntfy is self-hostable, so the server URL and an optional bearer token are config, defaulting to the public instance:
{
"server": "https://ntfy.sh",
"topic": "your-topic",
"token": "tk_optional_for_self_hosted_or_private_topics",
"minSeverity": "warn"
}
Why zero-dependency is the point, not a flex
It would be easy to read “zero dependencies” as a vanity metric. On a safety path it isn’t:
- Smaller supply-chain and audit surface. Every dependency is code you didn’t write running on the path between “alarm fired” and “phone buzzed.” For something safety-relevant, the right number of transitive packages to audit is as close to zero as you can get. With
node:httpsand the in-process plugin API, there’s nothing to audit but the plugin itself. - Nothing to vendor or break on upgrade.
node-fetchv2 vs v3 (CommonJS vs ESM),wsmajors — these are exactly the upgrades that silently break a plugin you forgot you were running. No deps, no upgrade treadmill. - Readable end to end. You can sit down and read the whole relay in one pass and convince yourself it does what it says. That property is worth more on an alarm path than any feature.
The contrast is the whole argument: the existing plugin’s two dependencies aren’t waste — they earn their keep serving the bidirectional button feature. A one-way relay simply doesn’t have that feature, so it doesn’t pay that cost.
What we did not do, honestly
- Use
signalk-ntfyas-is. An immature0.0.xwith no adoption, on a safety-relevant path, missing edge-triggering / severity filtering / tests. No. - Contribute the changes upstream. Tempting, but its bidirectional architecture and goals genuinely differ from a one-way relay. Bending a focused relay onto a button-response design — or pushing a from-scratch rewrite into someone else’s POC — serves nobody. A separately-publishable, single-purpose relay is cleaner and more reusable for the next person searching “signalk ntfy.”
- Build a generic SignalK→MQTT gateway. Wrong shape (covered above): foreign topic scheme, no severity/priority semantics, just relocates the mapping problem.
Tests
A safety relay gets tests, and node:test ships with Node — so even the test layer adds no dependency:
const { test } = require('node:test');
const assert = require('node:assert');
test('edge-triggering fires once per transition', () => {
const seen = [];
const opts = { minSeverity: 'warn', topic: 't', server: 'https://ntfy.sh' };
const send = (path, v, s) => seen.push(s);
step(opts, 'notifications.x', 'warn', send); // fire
step(opts, 'notifications.x', 'warn', send); // repeat — skip
step(opts, 'notifications.x', 'alarm', send); // change — fire
assert.deepStrictEqual(seen, ['warn', 'alarm']);
});
test('emergency maps to priority 5 / sos', () => {
assert.deepStrictEqual(NTFY.emergency, { priority: 5, tags: ['sos'] });
});
Close
This came out of building the AI and notification plumbing for an all-electric sailing charter — where “the battery alarm fired” needs to reach a phone, not just the chartplotter, and the path it travels has to be auditable end to end. signalk-ntfy-relay is MIT-licensed and zero-dependency — on npm as signalk-ntfy-relay and on GitHub. Install it from the SignalK app store (search “ntfy”) or with npm i signalk-ntfy-relay. If you’ve been searching for SignalK push notifications to your phone — a SignalK notification relay that turns a SignalK alarm into a phone push — that’s what it’s for.