WhatsApp alert when a cron job fails
One curl after the job, or a systemd OnFailure= unit that
covers every job on the box. And an honest section on the thing this does not do.
The job that matters is rarely the one that fails loudly. It is the backup that started exiting 1 in March, into a mail spool nobody reads, because cron's idea of telling you is an email to a local user.
Two ways to do it properly. Both are a curl; the second one you write
once and then forget.
1. Make a key
In the Jabee dashboard, go to Management then
Approvals and create an API key. It starts jak_. Copy
it now; it is not shown again.
Whoever is going to be messaged needs a verified phone, which they get by tapping a link in the dashboard and sending one message. Until somebody has done that there is nobody to deliver to.
2. One script, used by both
Put the key in a file rather than in a crontab or a unit file, so it is not in
crontab -l output, in systemctl cat, or in a backup of
either:
printf 'jak_your_key_here' | sudo tee /etc/jabee.key >/dev/null
sudo chmod 600 /etc/jabee.key
Then one small script, which is what both wirings below call:
#!/bin/sh
# /usr/local/bin/jabee-alert — usage: jabee-alert "title" "body"
set -eu
jq -n --arg t "$1" --arg b "${2:-}" '{title: $t, body: $b}' \
| curl -sS -X POST https://api.jabee.ai/api/v1/alerts \
-H "Authorization: Bearer $(cat /etc/jabee.key)" \
-H "Content-Type: application/json" \
--data @-
chmod 755 /usr/local/bin/jabee-alert and it is done. Using
jq to build the JSON rather than printf is what stops an
apostrophe in an error message from breaking the request — and error messages are
exactly where apostrophes live. If the host has no jq, keep the titles
plain and build the body with printf '{"title": "%s"}' "$1", knowing that
is what you have given up.
3. Wiring it to a cron job
The || is the whole mechanism: the script runs only when the job exits
non-zero.
0 3 * * * /usr/local/bin/backup.sh || /usr/local/bin/jabee-alert "Nightly backup failed on $(hostname)" "backup.sh exited non-zero"
One line, deliberately: a crontab entry cannot be continued with a
trailing backslash the way a shell script can — the command is the rest of the
line and nothing else. Which is the first reason the curl is in a script.
Two more things specific to crontabs. A literal % must be written
\% or cron cuts the command off there, so avoid
date +%F-style arguments in the entry itself. And PATH in
cron is short: if the script is not somewhere boring like
/usr/local/bin, give the full path.
4. Or wire it once for the whole machine
If the jobs are systemd timers — and on a modern box they probably should be — you can do this once for everything rather than per job. A wrapper that adds the last few log lines:
#!/bin/sh
# /usr/local/bin/jabee-alert-unit — usage: jabee-alert-unit backup.service
set -eu
/usr/local/bin/jabee-alert \
"$1 failed on $(hostname)" \
"$(systemctl status "$1" --no-pager --lines=5 | tail -c 1500)"
The tail -c 1500 is not decoration: the body limit is 2000 characters
and a stack trace is longer than that.
# /etc/systemd/system/jabee-alert@.service
[Unit]
Description=WhatsApp alert for %i
[Service]
Type=oneshot
ExecStart=/usr/local/bin/jabee-alert-unit %i
Then one line in any unit you want covered:
[Unit]
OnFailure=jabee-alert@%n.service
%n is the failing unit's own full name, so
backup.service instantiates
jabee-alert@backup.service.service — which looks odd, is correct,
and means one template covers every service on the host.
Keeping the work in a script rather than in ExecStart also avoids the
trap in that file: systemd expands $NAME and ${NAME} itself
and does no command substitution, so a $(cat ...) written directly into
ExecStart does not do what a shell would do with it.
After editing, systemctl daemon-reload, then prove it with a unit whose
ExecStart is /bin/false. An alerting path you have never seen
fire is a guess.
The other half: noticing that nothing ran
This endpoint is push-only. If cron never fires, if the machine is off, if the crontab was lost in a rebuild, then nothing is posted here and this endpoint has nothing to notice — which is exactly the failure that bit you last time.
That is what heartbeat checks are for, and they are part of the same plan. The job pings a URL when it finishes, Jabee expects that ping on the schedule the job declared, and silence past the deadline is what raises the alert. One extra line at the end of the job covers the case this page cannot:
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
Use both and all three failures are covered: the job failed (this page), the job never ran, and the box is gone (that one).
Only telling you about the failures
Tempting to post on success too, "so I know it ran". Do not: a nightly message that always says fine is a message you stop reading within a fortnight, and it is 30 of your 500 a month. Keep WhatsApp for the exceptions and let the heartbeat check live somewhere you look on purpose.
Where it goes
One phone per business, and it is your own: the account owner's verified number by default, or another handset — an ops phone, a shared on-call one — which you point alerts at in the dashboard, under Where alerts go. Either way that phone verifies itself by sending one message to the Jabee number, carrying a code from the dashboard.
So there is nothing to address and no recipient field. To reach several people, connect your own WhatsApp number and send from there: alerts on Jabee's number go out on a sender every business here shares, and the volume you send spends a reputation everybody else depends on.
What it costs
$29 a month for 500 alerts and approvals together, then 3 cents each. The first three are free, so you can wire this up and watch a message arrive before paying for anything. No per-seat charge.
And the ceiling that matters for a machine: 200 new alerts per business in any 24
hours, 60 calls a minute per key. A job in a crash loop can reach that; it answers
429. Giving the alert an external_id makes repeats free, so a
retry loop on the same failure costs one.
Questions
How do I get a WhatsApp message when a cron job fails?
Chain a curl after the job with || so it runs only on a
non-zero exit, POSTing a title and a body to
https://api.jabee.ai/api/v1/alerts with your key on the
Authorization header. Keep the key in a file with mode 600 rather than
in the crontab, and escape any literal % as \%.
Can it tell me when a cron job stops running altogether?
No. This endpoint only sends what you post to it, so a job that never runs sends nothing. For a dead man's switch you need something off the machine expecting a heartbeat — which can then post here when it fires. Saying otherwise would be selling you a safety net with a hole in it.
Can one systemd unit cover every job on the machine?
Yes. Write a templated jabee-alert@.service of
Type=oneshot whose ExecStart calls a small wrapper with
%i, then add OnFailure=jabee-alert@%n.service to each
unit. Keep the curl in the script rather than in
ExecStart: systemd expands its own variables there and does no command
substitution.
What happens if a crashing job sends hundreds of alerts?
There is a ceiling of 200 new alerts per business in any 24 hours and 60 calls a
minute per key; past it the call answers 429. Give the alert an
external_id and repeats of that id are free, so a retry loop on one
failure costs one alert.
Do I need a WhatsApp number or a Meta account?
No, neither, at any point. The message goes over Meta's official Cloud API on Jabee's registered number using Jabee's approved template, so you need an API key and nothing else. There is no number of yours involved, so there is nothing of yours to get banned.
Can it message my customers?
No, deliberately. It reaches your own people, once they have verified their phone. A shared number messaging strangers is what gets numbers banned. To reach customers you connect your own number, which is the inbox.
What happens if nobody ever answers the messages?
Five notifications in a row that a handset never replies to and we stop sending to it, with an email to the account owner saying so. Sending any message to the Jabee number starts it again. One number's standing with Meta is shared by every business on it, which is why the rule exists.