Recipe · Servers & homelab

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

§ 1

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.

crontab — failure-only ping (host hard-coded)
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:

crontab — failure ping with live hostname
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.

§ 2

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.

relay.py — FastAPI, one route
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.

§ 3

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.

relay.py — second route for Uptime Kuma
@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.

§ 4

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.

disk-check.sh — cron every 5 min, alerts once/day per mount
#!/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.

§ 5

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.

cert-check.sh — cron weekly, warns under 14 days
#!/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.)

§ 6

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.

§ 7

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.

QR code to join pingwa on WhatsApp

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.