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”.
How it works
Section titled “How it works”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
heartbeatstable in YDB Serverless. One index onnext_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. If the channel has delivery configured, the alert is escalated through its methods.
Creating a heartbeat
Section titled “Creating a heartbeat”Via the admin UI
Section titled “Via the admin UI”-
In app.notifly.ru open the channel → the Heartbeats tab → “Create heartbeat”. The same dialog is opened by the monitor creation wizard (Monitoring → Monitors, “What do you want to monitor?”) — the “My job pings by itself (dead-man switch)” card.
-
Fill in the fields:
- Name — for display, e.g. “Database backup cron”.
- Channel — which channel to use for the alert. Your own channels and shared ones with send permission are available.
- Interval — expected period between pings: the form offers a fixed list (1 min, 5 min, 10 min, 30 min, 1 h, 1 d). Any value from 30 seconds to 30 days can be set via the REST API.
- Grace (sec) — how long to wait after the interval (protection against jitter), 60 by default.
- Importance — “Critical”, “Normal” or “Informational”, see Importance and channel summary status.
- Down notification — title and text (Markdown) of the push sent when a ping misses the deadline. If left empty, a default text is used.
- Recovery notification — title and text sent when a ping arrives again after an alert (default: “Service is back to normal.”). The downtime duration is appended automatically.
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. -
Copy the ping URL from the “Ping URL” column of the heartbeats table (the “Copy URL” button). Each row has a “History” tab (chart of pings and misses) and a “Code examples” tab (ready-made snippets for curl, cron and languages, including a ping with a reason).
Via REST API
Section titled “Via REST API”curl -X POST "$NOTIFLY_URL/heartbeat" \ -H "Content-Type: application/json" \ -H "X-Notifly-Key: <api-token>" \ -d '{ "appid": 12345, "name": "Database backup cron", "intervalSec": 3600, "graceSec": 300, "tier": "critical", "alertTitle": "Backup did not run", "alertMessage": "The backup cron has not reported in the last hour — check the server!", "alertPriority": 9, "recoveryTitle": "Backup recovered", "recoveryMessage": "Backups are running again." }'| Field | Required | Limits and defaults |
|---|---|---|
appid | yes | your channel or a shared one with send permission; immutable |
name | yes | 1–200 characters |
intervalSec | yes | 30 – 2,592,000 (30 days) |
graceSec | — | 0 – 604,800 (7 days), default 0 |
alertMessage | yes | 1–2000 characters |
alertTitle | — | alert title |
alertPriority | — | 0–10; omitted — 0 |
recoveryTitle | — | recovery title |
recoveryMessage | — | recovery text; without it test-recovery responds 400 |
tier | — | critical, normal or info; omitted — normal |
The response is the heartbeat object with status pending and a pingToken
(starts with H...); the final URL is https://<domain>/heartbeat/ping/<pingToken>.
PUT /heartbeat/:id accepts the same set of fields and replaces the
settings entirely: omitted alertPriority, tier, graceSec are reset to
their defaults, so send the full object. The channel is not changed on update:
appid is required in the body but ignored.
Via MCP (for AI assistant)
Section titled “Via MCP (for AI assistant)”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.
If no priority is given, create_heartbeat uses 8. The MCP tools don’t set
the importance (tier) — change it in the admin UI or via the REST API.
Using ping
Section titled “Using ping”The simplest ping is a plain curl without authorization:
curl -fsS "$NOTIFLY_URL/heartbeat/ping/<pingToken>" -o /dev/nullThe 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.
| Response | When |
|---|---|
204 | ping accepted (for a paused heartbeat — accepted and ignored) |
401 | the token doesn’t look like a ping token (doesn’t start with H) |
404 | unknown token |
429 | the daily event quota is exhausted — the ping is not counted |
Each ping is one event of the plan’s daily quota, see Quotas and plans.
Reason in the ping
Section titled “Reason in the ping”A POST may carry JSON {"fail_reason": "..."} (up to 500 characters). If
this ping takes the heartbeat out of alerting, the reason is attached to the
recovery notification (extras.fail_reason) — next to the downtime duration
(extras.downtime_sec, counted from the last live ping):
curl -fsS -X POST "$NOTIFLY_URL/heartbeat/ping/H..." \ -H "Content-Type: application/json" \ -d '{"fail_reason": "DB connection timeout after 30s"}'*/15 * * * * /usr/local/bin/backup.sh && curl -fsS "$NOTIFLY_URL/heartbeat/ping/H..." -o /dev/nullThe 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.
systemd timer
Section titled “systemd timer”In the service unit of a regular timer:
[Service]Type=oneshotExecStart=/usr/local/bin/backup.shExecStartPost=/usr/bin/curl -fsS https://api.notifly.ru/heartbeat/ping/H... -o /dev/nullExecStartPost runs only if the main ExecStart completed successfully.
Python
Section titled “Python”import requests, subprocess
subprocess.check_call(["/usr/local/bin/backup.sh"])requests.post(f"{NOTIFLY_URL}/heartbeat/ping/{TOKEN}", timeout=10)Node.js
Section titled “Node.js”import {execSync} from 'child_process';execSync('/usr/local/bin/backup.sh');await fetch(`${NOTIFLY_URL}/heartbeat/ping/${TOKEN}`, {method: 'POST'});Heartbeat states
Section titled “Heartbeat states”| State | Meaning |
|---|---|
pending | Created (or resumed), no ping has arrived yet |
ok | The last ping is within the interval |
alerting | Deadline missed, alert has already been sent |
paused | Checking is disabled (via UI or POST /heartbeat/:id/pause) |
The deadline is intervalSec + graceSec after the last ping (for a new
heartbeat — after creation). Once an alert is sent the heartbeat doesn’t
“spam”: while it is in alerting, no repeat alerts are sent, however long the
ping stays missing. The first ping after alerting returns the heartbeat to
ok and sends a recovery notification (with the same priority as the alert).
The pending → ok transition sends no recovery.
Importance and channel summary status
Section titled “Importance and channel summary status”Each heartbeat has an importance (tier) that affects the channel’s
summary status:
tier | In the admin UI | A failure of such a heartbeat gives the channel |
|---|---|---|
critical | ”Critical” | alerting (“Failing”) |
normal | ”Normal” | alerting (“Failing”) |
info | ”Informational” | only degraded (“Degraded”) |
The summary status is computed with precedence alerting > degraded >
pending > ok; a channel without heartbeats is none. Paused heartbeats
don’t affect the status. Importance changes only the channel’s color, not
delivery: the alert priority is always alertPriority.
In the admin UI the status is shown as a colored dot next to the channel in the list, and the Heartbeats tab on a channel page shows the summary for the channel and all its nested channels. Critical and informational heartbeats are marked with a chip.
The same status is returned by GET /application/:id/status (with
?recursive=1 — across the whole channel subtree):
{ "overall": "degraded", "total": 4, "alerting": 0, "alertingCritical": 0, "alertingInfo": 1, "pending": 0, "lastStatusAt": "2026-06-23T09:59:12Z", "heartbeats": [ { "id": 17, "appid": 12345, "name": "Slack report", "tier": "info", "status": "alerting", "lastPing": "2026-06-23T07:00:03Z", "owner": "ops@example.com" } ]}alerting counts only critical + normal, alertingCritical is their
critical subset, alertingInfo counts info failures (not included in
alerting). The heartbeats items are full heartbeat objects (as in
GET /heartbeat); the example shows some of the fields.
The summary of a shared channel includes heartbeats of all its participants.
Other people’s heartbeats are marked readOnly: true, carry their author
(owner) and an empty pingToken: only the author can change, pause or ping
a heartbeat.
REST API
Section titled “REST API”| Method & path | Authorization | Purpose |
|---|---|---|
GET /heartbeat | API token | list your heartbeats |
POST /heartbeat | API token (write) | create |
PUT /heartbeat/:id | API token (write) | update (full replacement of fields) |
DELETE /heartbeat/:id | API token (write) | delete |
POST /heartbeat/:id/pause | API token (write) | pause checks |
POST /heartbeat/:id/resume | API token (write) | resume checks (status pending) |
POST /heartbeat/:id/test-alert | API token (write) | send a test alert immediately (status doesn’t change) |
POST /heartbeat/:id/test-recovery | API token (write) | send a test recovery immediately (status doesn’t change) |
GET /application/:id/status | API token | channel heartbeat summary status (?recursive=1) |
GET/POST /heartbeat/ping/:token | public | ping (token = authentication) |
The header is X-Notifly-Key: <api-token>, see API tokens.
Test notifications arrive with a [ТЕСТ] (“test” in Russian) prefix in the title.
Why YDB and not S3?
Section titled “Why YDB and not S3?”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.