WhatsApp alert when a job stops running

A URL your job pings when it finishes. If a ping does not arrive on time, you get a WhatsApp message. No phone number of your own, no WhatsApp Business account, and nothing to install on the box.

What a heartbeat is

A heartbeat check is a URL your job calls each time it runs. Jabee expects a call on the schedule the job declared, and messages you when one does not arrive. It is the inverse of an alert: silence is the failure, which is how a job that never ran at all gets noticed.

That is the gap the cron alert page used to end on. A curl after a failing job tells you the job failed; nothing tells you the crontab was dropped in a rebuild, the container exited at 3am, or the machine never came back from a reboot. Those are the failures that go unnoticed for a week.

Make a key

In the dashboard, Management → Approvals → New key. It begins jak_ and is shown once. Whoever gets the message verifies their phone by tapping a link in the dashboard and sending one message; until then, alerts fall back to the account owner's email.

One line, and the check exists

There is no dashboard step. The first ping creates the check and sets its schedule:

curl -fsS -X POST \
  -H "Authorization: Bearer $JABEE_KEY" \
  "https://api.jabee.ai/api/v1/heartbeat/nightly-backup?every=1d&grace=1h"

That says: this job checks in once a day, and may be up to an hour late. Miss that window and a WhatsApp message goes out saying nightly-backup has not checked in, with how long the silence has lasted.

Later pings need no parameters — ?every= is only read when you send it, so a second line pinging the same name cannot silently re-time a check you configured deliberately:

curl -fsS -X POST -H "Authorization: Bearer $JABEE_KEY" \
  https://api.jabee.ai/api/v1/heartbeat/nightly-backup

GET works too. A BusyBox container has wget and no curl, and a Kubernetes CronJob wrapper or an uptime checker pointed at this URL only ever sends GET:

wget -q -O- --header="Authorization: Bearer $JABEE_KEY" \
  https://api.jabee.ai/api/v1/heartbeat/nightly-backup

The key stays in the header and never in the URL. A URL carrying a credential ends up in shell history, in a CI log and in whatever proxy sits in front of the job — and unlike a ping URL that can only mark a job healthy, a leaked Jabee key can send messages.

How late is late

Two numbers, both written the way a crontab is written — 90s, 15m, 2h, 1d, or a plain number of seconds:

ParameterWhat it meansDefault
everyHow often the job checks in. Required on the first ping.—
graceHow much later than that is still fine.None
recovertrue to also be told when it checks in again.Off

Set grace to however long the job itself can reasonably take, plus the jitter in your scheduler. A backup that usually finishes in twenty minutes and sometimes takes fifty wants every=1d&grace=1h, not grace=0, or you will be woken by a job that was merely slow.

The floor is every=60s and the ceiling is 30 days. Jabee looks for missed check-ins every 30 seconds, so a message goes out within about half a minute of the deadline — this is a dead man's switch for scheduled work, not a sub-second uptime monitor.

Wiring it to the job

Ping after the work, and only if the work succeeded. && is doing the important thing here: a job that pings regardless of its exit code is a job that reports health it does not have.

# crontab: ping only when the backup actually worked
17 2 * * * /usr/local/bin/backup.sh && curl -fsS -X POST -H "Authorization: Bearer $JABEE_KEY" https://api.jabee.ai/api/v1/heartbeat/nightly-backup

A crontab line cannot be continued with a trailing backslash and a literal % must be escaped, so anything longer than this belongs in a script — the same trap the cron page describes.

The pair that covers both halves of the problem:

#!/bin/sh
# /usr/local/bin/nightly
set -e
if /usr/local/bin/backup.sh; then
  # it worked: say so, which resets the deadline
  curl -fsS -X POST -H "Authorization: Bearer $JABEE_KEY" \
    https://api.jabee.ai/api/v1/heartbeat/nightly-backup
else
  # it ran and failed: say that instead, with the detail
  curl -fsS -X POST -H "Authorization: Bearer $JABEE_KEY" \
    -H 'Content-Type: application/json' \
    -d '{"title":"Nightly backup failed","body":"backup.sh exited non-zero"}' \
    https://api.jabee.ai/api/v1/alerts
fi

Now all three failures are covered: the job failed, the job never ran, and the box is gone.

In a GitHub Actions workflow, the last step of the job:

- name: Tell Jabee it ran
  run: |
    curl -fsS -X POST -H "Authorization: Bearer ${{ secrets.JABEE_KEY }}" \
      "https://api.jabee.ai/api/v1/heartbeat/nightly-sync?every=1d&grace=2h"

A scheduled workflow is worth a heartbeat more than most things: GitHub disables schedule triggers on repositories with no activity for 60 days, and the first you hear of it is usually nothing at all.

Being told when it comes back

Off by default. Add &recover=true to any ping and a check that had gone quiet sends a second message when it checks in again.

The reason it is not the default is arithmetic: a recovery doubles the messages an outage costs, so 500 alerts a month buys 250 outages instead of 500. The Grafana route makes the same call for the same reason. Turn it on for the handful of jobs where "it is back" changes what you do next.

Seeing what you have

curl -fsS -H "Authorization: Bearer $JABEE_KEY" \
  https://api.jabee.ai/api/v1/heartbeats

{
  "heartbeats": [
    {
      "name": "nightly-backup",
      "every_seconds": 86400,
      "grace_seconds": 3600,
      "state": "up",
      "last_ping_at": "2026-11-13T02:17:04.000Z",
      "due_at": "2026-11-14T03:17:04.000Z",
      "recover": false
    }
  ]
}

And when a job is retired, so is its check — otherwise it goes quiet for ever and the quiet is what raises alerts:

curl -fsS -X DELETE -H "Authorization: Bearer $JABEE_KEY" \
  https://api.jabee.ai/api/v1/heartbeats/nightly-backup

The limits, stated plainly

What this is not

It is not a status page, it has no history to browse, and it does not know why a job stopped — only that it did. It is not an on-call rota either: there is one destination per business, and if that handset is asleep, nobody else is paged. For several people, connect your own WhatsApp number and send from there.

And it cannot see inside the job. A backup that runs on time and writes a corrupt file pings exactly like a healthy one. Check what you actually care about in the script, and ping only when that check passes.