Your servers, on the pager you already carry.
Backup failed at 3am, root filesystem crossed 90%, a cert
expires Friday — the handful of alerts you'd be sorry to miss land in
WhatsApp. The firehose stays where it belongs: Grafana, ntfy, the log
pipeline. This page wires the must-reach-me few to a single HTTPS
request — POST /v1/notify — from a cron line, a tiny relay,
or a shell check. No app to install, no linked device; it's the official Meta
Cloud API underneath. Get the key by sending join on
WhatsApp (details at the bottom).
Cron: ping when a job fails
The most-pasted line here. Wrap the job so a
failure taps you — success stays silent. Watch the quoting: shell
does not expand $(...) or $VARS inside
single quotes, so the host name has to sit in
double-quoted JSON with the inner quotes escaped — or just be hard-coded.
And one cron-specific trap: cron does not source your
.bashrc, so $PINGWA_KEY from your shell is empty
inside a cron job — define it at the top of the crontab itself (as below) or
export it in the script you're wrapping.
PINGWA_KEY=pw_your_key_here
0 3 * * * /opt/backup.sh || curl -sf -X POST https://pingwa.dev/v1/notify -H "Authorization: Bearer $PINGWA_KEY" -H "Content-Type: application/json" -d '{"text": "🔥 backup FAILED on myhost"}' || true
Want the host name filled in dynamically? Switch the body to
double quotes and escape the inner ones — the single-quoted form above will
print $(hostname) literally, which is the classic footgun:
0 3 * * * /opt/backup.sh || curl -sf -X POST https://pingwa.dev/v1/notify -H "Authorization: Bearer $PINGWA_KEY" -H "Content-Type: application/json" -d "{\"text\": \"🔥 backup FAILED on $(hostname)\"}" || true
Two rules earn their keep. The trailing
|| true stops a failed ping (our outage, a WiFi blip)
from poisoning cron's own exit status. And prefer failure-only
over a nightly "done" ping: a success message you'll learn to ignore, so its
absence tells you nothing — but a job that dies silently also sends
nothing, and you never notice. Alert on the exception, not the routine; if you
do want proof-of-life, that's a dead-man's-switch job for a watcher, not a
message you'll swipe away half-asleep.
Grafana → a tiny relay
Grafana's webhook contact point POSTs its own JSON
shape — it carries the alert as fields like title,
message, status and an alerts[] array —
and it can't be told to send our {"text": …} body with a Bearer
header. So put a tiny relay in the middle: it accepts Grafana's payload and
reshapes title/message into one line for
/v1/notify. Point Grafana's webhook URL at the relay; keep your
pingwa key in the relay's environment, never in Grafana.
import os, httpx
from fastapi import FastAPI, Request
app = FastAPI()
KEY = os.environ["PINGWA_KEY"]
@app.post("/grafana")
async def grafana(req: Request):
a = await req.json()
# Grafana's webhook JSON carries the alert in title/message.
text = f"🔔 {a.get('title', 'Grafana alert')} — {a.get('message', '')}"[:1024]
async with httpx.AsyncClient() as c:
await c.post(
"https://pingwa.dev/v1/notify",
json={"text": text},
headers={"Authorization": f"Bearer {KEY}"},
)
return {"ok": True}
Honest note: if you already run n8n, you don't need this process at all — a two-node flow (Webhook → HTTP Request) does the exact same reshape with nothing to deploy. See the n8n recipe. The relay is for when a small always-on service is simpler than one more n8n workflow.
Uptime Kuma → the same relay
Uptime Kuma's Webhook notification type
posts a JSON body whose msg field holds the human-readable line
("… is DOWN"). Same pattern as § 2 — add a second route so one relay serves
both. Set Kuma's webhook URL to …/kuma; the default body already
includes msg, so no custom body is needed.
@app.post("/kuma")
async def kuma(req: Request):
a = await req.json()
# Kuma's default webhook body carries the line in msg.
text = f"🟥 {a.get('msg', 'Uptime Kuma alert')}"[:1024]
async with httpx.AsyncClient() as c:
await c.post(
"https://pingwa.dev/v1/notify",
json={"text": text},
headers={"Authorization": f"Bearer {KEY}"},
)
return {"ok": True}
Field names on external tools drift between versions — if a
key isn't where you expect, log the raw body once and read it off the wire.
The relay pattern is the durable part: accept their shape, map it to
{"text": …}, forward with your key.
Disk over threshold — once a day, not every poll
df piped through awk gives you the
used-percent as a bare integer. Run it from cron every 5 minutes and the trap
is alert-per-poll: root crosses 90% and you get a buzz every five minutes
until you fix it. The Idempotency-Key header ends that —
same key with the same body is honored for 24 hours, replays absorbed, nothing
re-sent. Key it on the mount plus the day and you get one alert per
mount per day. The $(date +%F) lives inside the
.sh file, so there's no crontab %-escaping to worry
about.
#!/bin/sh
MOUNT=/
THRESHOLD=90
used=$(df -P "$MOUNT" | awk 'NR==2 {gsub(/%/,""); print $5}')
if [ "$used" -ge "$THRESHOLD" ]; then
curl -sf -X POST https://pingwa.dev/v1/notify \
-H "Authorization: Bearer $PINGWA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: disk-$MOUNT-$(date +%F)" \
-d '{"text": "💾 disk on / over 90% — free space now"}' || true
fi
One rule to respect: keep the body constant for a given key. A different body under the same key is rejected with a 409 and nothing is sent — so don't interpolate the live "91%" into the text, or today's second run silently drops. Report the threshold in words, not the reading.
TLS certificate about to expire
Ask the server for its leaf cert's end date, turn both dates into epoch seconds, and tap yourself when fewer than 14 days remain. Run it weekly — a cert that's 40 days out doesn't need a daily nag, and by the time it's inside two weeks a weekly check still gives you room to act.
#!/bin/sh
HOST=example.com
end=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
end_epoch=$(date -d "$end" +%s)
days=$(( (end_epoch - $(date +%s)) / 86400 ))
if [ "$days" -lt 14 ]; then
curl -sf -X POST https://pingwa.dev/v1/notify \
-H "Authorization: Bearer $PINGWA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cert-$HOST-$(date +%F)" \
-d "{\"text\": \"🔒 $HOST cert expires in $days days\"}" || true
fi
Here the day count is the news, so it goes in the
text — and because the count shifts as the deadline nears, this one body
legitimately changes. Fine: the Idempotency-Key still caps it at
one per host per day, and a new body under a new day's key sends
cleanly. (date -d is GNU coreutils; on BSD/macOS use
date -j -f.)
Where this fits — and where it doesn't
Respect where it's due
ntfy is excellent — open source, self-hostable, brilliant at pushing a high-volume stream of events to a topic you subscribe to. If you want every alert, every deploy, every log line on tap in a dedicated feed, that's ntfy's home turf, and this page won't pretend otherwise.
What's different here
No extra app to install or check — the alert lands in the app you already answer, next to your family and your team. Nothing new to open, nothing new to keep unmuted.
The honest counter
That same property makes pingwa wrong for the firehose. A busy alert stream in your personal chat is noise you'll learn to swipe away — keep that in ntfy or Grafana. pingwa is for the handful that must reach you, and the free tier's 30 paid messages a month quietly enforces that discipline: if a check is firing more than that, it isn't paging you, it's logging at you. Route the stream to ntfy; route the exceptions here.
FAQ
How do I get a Grafana WhatsApp alert?
Grafana's webhook contact point can't POST our body shape or carry a
Bearer header, so you put a small relay in between (§ 2): it accepts
Grafana's JSON, pulls title/message, and forwards
{"text": …} to /v1/notify with your key. Point the
contact point's webhook URL at the relay and you're done. Already on n8n? A
two-node flow does the same with nothing to deploy — see
the n8n recipe.
Uptime Kuma WhatsApp notification — supported?
Yes, via the same relay (§ 3). Choose the Webhook
notification type in Kuma; its default body includes a msg
field with the status line, which the relay maps into the text. One relay
with two routes covers both Grafana and Kuma. Field names can shift between
versions — if msg isn't there, log the raw body once and read
it off the wire.
Can I get a cron job failure alert on my phone?
That's § 1 — append || curl … /v1/notify … after the job so a
non-zero exit taps you, then a final || true so a failed ping
never breaks cron's own status. Failure-only beats a nightly "done" ping:
you'll ignore the routine message, and a silently dead job sends nothing
either way — so alert on the exception.
Won't a flapping check burn my free tier?
Not with the Idempotency-Key pattern from §§ 4–5: key on
state-plus-day and a check that runs every 5 minutes still sends at most one
message per day per thing. The free tier is 30 paid messages a month
(replies inside your 24h window are free) — enough for real exceptions, and
a useful ceiling that keeps the firehose out. High-volume streams belong in
ntfy or Grafana; send only what must reach you.
Scan the code and get instant access. It opens WhatsApp with “join” ready to send, and your API key comes straight back.
The relay and shell snippets above are the honest shape of the integration, not lab-verified against every Grafana or Uptime Kuma release — external webhook field names drift between versions, so log a raw payload once and confirm the mapping before you trust a 3am page to it.