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. If the channel has delivery configured, the alert is escalated through its methods.
  1. 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.

  2. 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.

  3. 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).

Окно терминала
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."
}'
FieldRequiredLimits and defaults
appidyesyour channel or a shared one with send permission; immutable
nameyes1–200 characters
intervalSecyes30 – 2,592,000 (30 days)
graceSec—0 – 604,800 (7 days), default 0
alertMessageyes1–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.

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.

The simplest ping is a plain curl without authorization:

Окно терминала
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.

ResponseWhen
204ping accepted (for a paused heartbeat — accepted and ignored)
401the token doesn’t look like a ping token (doesn’t start with H)
404unknown token
429the 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.

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/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://api.notifly.ru/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 (or resumed), no ping has 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)

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.

Each heartbeat has an importance (tier) that affects the channel’s summary status:

tierIn the admin UIA 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.

Method & pathAuthorizationPurpose
GET /heartbeatAPI tokenlist your heartbeats
POST /heartbeatAPI token (write)create
PUT /heartbeat/:idAPI token (write)update (full replacement of fields)
DELETE /heartbeat/:idAPI token (write)delete
POST /heartbeat/:id/pauseAPI token (write)pause checks
POST /heartbeat/:id/resumeAPI token (write)resume checks (status pending)
POST /heartbeat/:id/test-alertAPI token (write)send a test alert immediately (status doesn’t change)
POST /heartbeat/:id/test-recoveryAPI token (write)send a test recovery immediately (status doesn’t change)
GET /application/:id/statusAPI tokenchannel heartbeat summary status (?recursive=1)
GET/POST /heartbeat/ping/:tokenpublicping (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.

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.