Skip to content

Heartbeat notifications (dead-man-switch)

Heartbeat — passive monitoring on the “if it didn’t call, something’s wrong” principle. Your service/cron/script regularly hits a short Notifly HTTP endpoint, and if the next ping doesn’t arrive within the configured time — you receive a push notification with a customizable message.

This is especially convenient for:

  • regular cron jobs (backups, imports, report generators);
  • daemons that “should always be running”;
  • IoT devices that “phone home”;
  • batch pipelines where the important thing is not “success” but “success on time”.
your cron ──ping──▶ POST /heartbeat/ping/<pingToken> (once every N sec)
└─▶ Notifly updates last_ping and shifts next_check_at
every minute: timer-trigger → notifly-heartbeat-checker
└─▶ SELECT WHERE next_check_at <= now AND status IN (ok, pending)
for each: create message + push to WS
  • Storage — the heartbeats table in YDB Serverless. One index on next_check_at — the checker scans only overdue records (cheap).
  • Checking — a separate Cloud Function notifly-heartbeat, triggered by a Yandex Cloud timer (cron * * * * ? * — once per minute).
  • Notification — a normal Notifly message, sent via the channel chosen when creating the heartbeat and delivered to all your clients (web, Android, desktop) like any other push notification.
  1. Open app.notifly.ru → Heartbeats.

  2. Click “Create heartbeat”, fill in:

    • Name — for display, e.g. “Database backup cron”.
    • Channel — which channel to use for the alert.
    • Interval (sec) — expected period between pings (minimum 30).
    • Grace (sec) — how long to wait after the interval (protection against jitter).
    • Alert text — what will arrive in the push when a ping misses the deadline.
    • Recovery text — optional “everything’s back to normal” message when a ping arrives after an alert.

    There is no priority field in the form — a heartbeat created via the admin UI has an alert priority of 5 by default. You can set a different priority (alertPriority, 0–10) via the REST API or MCP.

  3. Copy the Ping URL from the table — this is the URL your script will call.

Terminal window
curl -X POST "$NOTIFLY_URL/heartbeat" \
-H "Content-Type: application/json" \
-H "X-Notifly-Key: <client-token>" \
-d '{
"appid": 12345,
"name": "Cron бэкапа базы",
"intervalSec": 3600,
"graceSec": 300,
"alertTitle": "Бэкап не запустился",
"alertMessage": "За последний час cron бэкапа не пришёл — проверьте сервер!",
"alertPriority": 9,
"recoveryTitle": "Бэкап восстановлен",
"recoveryMessage": "Бэкап снова работает."
}'

The response will include a pingToken (starts with H...) and the final URL like https://<домен>/heartbeat/ping/<pingToken>.

If you have a Notifly MCP server set up (see MCP), just ask the assistant:

Create a heartbeat for the “backups” channel that expects a ping every hour with a 5-minute grace period, and sends “Backup did not run” with priority 9.

MCP tools: list_heartbeats, create_heartbeat, update_heartbeat, pause_heartbeat, resume_heartbeat, delete_heartbeat, ping_heartbeat.

The simplest ping is a plain curl without authorization:

Terminal window
curl -fsS "$NOTIFLY_URL/heartbeat/ping/<pingToken>" -o /dev/null

The pingToken itself is the authentication. No other headers are needed. Both GET and POST are supported — so you can call it directly from a cron job or monitoring systems that can’t do POST.

*/15 * * * * /usr/local/bin/backup.sh && curl -fsS "$NOTIFLY_URL/heartbeat/ping/H..." -o /dev/null

The ping is triggered only on success of the script (thanks to &&). If the backup fails — the ping won’t happen, and after intervalSec+graceSec an alert will arrive.

In the service unit of a regular timer:

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
ExecStartPost=/usr/bin/curl -fsS https://your-notifly/heartbeat/ping/H... -o /dev/null

ExecStartPost runs only if the main ExecStart completed successfully.

import requests, subprocess
subprocess.check_call(["/usr/local/bin/backup.sh"])
requests.post(f"{NOTIFLY_URL}/heartbeat/ping/{TOKEN}", timeout=10)
import {execSync} from 'child_process';
execSync('/usr/local/bin/backup.sh');
await fetch(`${NOTIFLY_URL}/heartbeat/ping/${TOKEN}`, {method: 'POST'});
StateMeaning
pendingCreated, no pings have arrived yet
okThe last ping is within the interval
alertingDeadline missed, alert has already been sent
pausedChecking is disabled (via UI or POST /heartbeat/:id/pause)

After an alert is sent a heartbeat does not “spam” — the next check is postponed for another intervalSec + graceSec seconds. When the first ping arrives after alerting — the heartbeat returns to ok and (if configured) a recovery notification is sent.

Method & pathAuthorizationPurpose
GET /heartbeatclient-tokenlist heartbeats
POST /heartbeatclient-tokencreate
PUT /heartbeat/:idclient-tokenupdate
DELETE /heartbeat/:idclient-tokendelete
POST /heartbeat/:id/pauseclient-tokenpause checks
POST /heartbeat/:id/resumeclient-tokenresume checks
POST /heartbeat/:id/test-alertclient-tokensend a test alert immediately (status doesn’t change)
POST /heartbeat/:id/test-recoveryclient-tokensend a test recovery immediately (status doesn’t change)
GET/POST /heartbeat/ping/:tokenpublicping (token = authentication)

Architecturally, checking “which heartbeats have next_check_at ≤ now” is an index point-query, not a scan. In YDB Serverless we have INDEX heartbeats_next_check_idx and one short transaction per minute (a few Request Units). In S3 you’d have to do LIST (1 request) + GET (a request per object), and Object Storage charges per request, so there would be many requests even with no alerts. Heartbeats in a project quickly become dozens or hundreds — so YDB is noticeably cheaper and faster.