Ping URLs: get alerted when scheduled work goes quiet
Each monitor has one secret URL; your job calls it when done and adds /fail on failure. Belvador alerts when the pings stop — even if your app, database or node is down.
On this page
A ping URL monitor (a heartbeat, or dead man's switch) watches work that should happen on a rhythm — a cron job, a timer that calls your app every minute, a backup script, a CI schedule. Your job calls one secret HTTPS URL each time it runs. When the calls stop, Belvador alerts "no signal".
It needs no SDK and no header: anything that can make an HTTPS request can use it — curl, a shell crontab, Python, Node.js, PHP, Go, GitHub Actions.
Why it keeps working when your stack is down#
Your app cannot report that it has stopped. So the deadline for each ping URL monitor is kept by Belvador at the edge, not by your app and not by your node:
If your app, its database or its timer dies, the pings stop and Belvador alerts on time.
If your Belvador node is offline, ping URL monitors are still watched and still alert. Only the detailed list of recent pings waits for the node to come back.
To do this, Belvador keeps a small amount of liveness metadata outside your node: the monitor's opaque id, which signal arrived (ok, start, fail, log, exit code) and when, the deadlines and the incident state. This is the one exception to "event data stays on your node" and it is listed on the privacy page. It never includes request bodies, headers, your server's IP address or anything your job prints — output you choose to send goes to your node only (see below).
Create one#
In a project, open Monitors → Create monitor and choose Ping URL (heartbeat).
| Field | Meaning |
|---|---|
| Name and slug | How the monitor appears in Belvador and in alerts |
| Schedule | Every N minutes/hours (for example every 1 minute), or a cron schedule with a time zone (0 2 * * * = 02:00 every day) |
| Allowed delay | How long after the expected ping Belvador waits before alerting — 5 minutes by default, up to 1440 |
| Max runtime | Only used when your job also calls /start: a run still open after this long counts as timed out |
| Environment | One ping URL reports for one environment (production by default). Create a second monitor for staging |
After saving, Belvador shows the ping URL once. Copy it into a secret of your server or CI. Belvador keeps only a fingerprint of it, so it cannot show the URL again — if you lose it, rotate it (see below).
The monitor shows Waiting for the first ping until the first call arrives; nothing is judged before that.
Call the URL#
https://ping.belvador.dev/p/bvp_<your-ping-token> finished OKhttps://ping.belvador.dev/p/bvp_<your-ping-token>/start started (optional; arms the max-runtime check)https://ping.belvador.dev/p/bvp_<your-ping-token>/fail failed — alerts at oncehttps://ping.belvador.dev/p/bvp_<your-ping-token>/<0-255> exit status: 0 = OK, 1–255 = failedhttps://ping.belvador.dev/p/bvp_<your-ping-token>/log attach output or a note; never changes the statusGET and POST both work. A trailing / is fine.
curl#
# Alive / finished OKcurl -fsS -m 10 --retry 5 -o /dev/null https://ping.belvador.dev/p/bvp_<your-ping-token> || true# Started (optional: arms the max-runtime check)curl -fsS -m 10 --retry 5 -o /dev/null https://ping.belvador.dev/p/bvp_<your-ping-token>/start || true# Failed — alerts at oncecurl -fsS -m 10 --retry 5 -o /dev/null https://ping.belvador.dev/p/bvp_<your-ping-token>/fail || true# Or send the job's exit status right after it: 0 = success, 1–255 = failure./nightly-job.sh; curl -fsS -m 10 --retry 5 -o /dev/null https://ping.belvador.dev/p/bvp_<your-ping-token>/$? || truecrontab#
# Runs at 02:00; Belvador gets "started", then the job's exit status.0 2 * * * curl -fsS -m 10 --retry 5 -o /dev/null https://ping.belvador.dev/p/bvp_<your-ping-token>/start || true ; /usr/local/bin/nightly-job.sh ; curl -fsS -m 10 --retry 5 -o /dev/null https://ping.belvador.dev/p/bvp_<your-ping-token>/$? || trueNode.js, Bun or Deno#
const PING_URL = process.env.BELVADOR_PING_URL!; // https://ping.belvador.dev/p/bvp_<your-ping-token>/** Never lets monitoring break the job: 10 s timeout, errors ignored. */async function ping(suffix = "") { await fetch(PING_URL + suffix, { method: "POST", signal: AbortSignal.timeout(10_000) }).catch(() => {});}await ping("/start");try { await runJob(); await ping();} catch (error) { await ping("/fail"); throw error;}Python#
import os, urllib.requestPING_URL = os.environ["BELVADOR_PING_URL"] # https://ping.belvador.dev/p/bvp_<your-ping-token>def ping(suffix=""): try: urllib.request.urlopen(PING_URL + suffix, data=b"", timeout=10) except OSError: pass # monitoring never breaks the jobping("/start")try: run_job() ping()except Exception: ping("/fail") raiseGitHub Actions#
# .github/workflows/nightly.yml — store the URL as the secret BELVADOR_PING_URLjobs: nightly: runs-on: ubuntu-latest steps: - run: curl -fsS -m 10 --retry 5 -o /dev/null "${{ secrets.BELVADOR_PING_URL }}/start" || true - run: ./scripts/nightly.sh - if: success() run: curl -fsS -m 10 --retry 5 -o /dev/null "${{ secrets.BELVADOR_PING_URL }}" || true - if: failure() run: curl -fsS -m 10 --retry 5 -o /dev/null "${{ secrets.BELVADOR_PING_URL }}/fail" || trueSend output with a ping#
Any signal can carry the job's output as a POST body — the last lines of a log, an error message, a row count. It shows under Ping output on the monitor page and inside the run it belongs to.
# Run the job, then send its exit status with the end of its output (60 KB; the cap is 64 KB).out=$(./nightly-job.sh 2>&1); code=$?printf '%s\n' "$out" | tail -c 60000 | curl -fsS -m 10 --retry 5 -o /dev/null --data-binary @- https://ping.belvador.dev/p/bvp_<your-ping-token>/$code || true# A progress note at any time — shown with the run, never changes the statusecho "exported 1204 rows" | curl -fsS -m 10 --retry 5 -o /dev/null --data-binary @- https://ping.belvador.dev/p/bvp_<your-ping-token>/log || trueUp to 64 KB of text per ping. A larger body is refused with
413and the ping is not recorded, so trim it (tail -c 60000).The output is stored only on your node, the newest 100 per environment. On the way it is handled like your error events: passed straight to your node and sealed to your node's key whenever it has to wait at Cloudflare. Belvador's database and alerts never contain it.
Your node's scrubbing rules run on it, and any ping URL in the text is replaced before it leaves the ping service.
Output never decides the status: only the URL does. A body saying "error" on the plain URL is still a success.
Delivery to your node is best effort and never delays the
200. While your node is unreachable, pings are still judged and alerts still go out; output waits for your node within the same limits as your events.
Every recipe uses a 10-second timeout and ignores errors (|| true after each curl), so a Belvador outage can never fail your job — not even in a set -e script or a GitHub Actions step, which stops at the first failing command. Against a ping URL that never answers, one ping gives up after about 90 seconds; add --retry-max-time 30 if your job cannot wait that long.
What the statuses mean#
| Status | Meaning |
|---|---|
| Waiting for the first ping | Created, no call yet |
| Healthy | The last call was a success and the next one is not due yet |
| Late | The next ping is overdue but still inside the allowed delay — no alert yet |
| Failing | No ping arrived within the allowed delay (Missed), the job called /fail or a non-zero exit code, or a run that called /start exceeded its max runtime |
| Paused | Not judged until you resume, or until the pause ends |
The next successful ping closes the incident and sends a recovered message.
The monitor page shows when the next ping is expected, when Belvador would alert, and the last 7 days hour by hour (OK, failed, missed).
Alerts#
Ping URL monitors use the same alert rules as other monitors: a missed or failed run opens an incident and the rule's destinations get a message; recovery sends a closing message. Don't repeat for (30 minutes by default) stops the same monitor from alerting twice in a row. Pick destinations under Alerts, including your own webhook.
Messages say "no signal received", never "your server is down": Belvador only knows that the pings stopped.
Pause for maintenance#
On the monitor page choose Pause — for 30 minutes, 1, 2, 8 or 24 hours, until a time you pick, or until you resume. While paused, missing pings never alert. Pings still count, so the history stays complete.
Rotate or reset the URL#
Treat the ping URL like a password: anyone who has it can report for this monitor (they cannot read anything). Owners and admins manage it under Ping URL on the monitor page; each action asks for your authenticator code.
Rotate URL issues a new URL and keeps the old one working for an overlap you choose — 1 hour, 24 hours (default), 3 or 7 days — so you can update your job without missing a ping. At most 2 URLs work at a time.
Stop the previous URL now ends the overlap early.
Reset after a leak stops every URL at once and issues a new one.
Changes reach the ping endpoint within 60 seconds.
Answers#
| Status | Meaning |
|---|---|
200 OK | The signal is stored — Belvador answers only after it is safely recorded |
404 | Unknown, revoked or expired URL (the same answer for each, so a URL cannot be guessed) |
400 | A suffix other than start, fail, log or 0–255 |
403 | The request came from a web page (Origin header), or the workspace does not accept data right now |
405 | A method other than GET or POST |
413 | The body is larger than 64 KB; nothing is recorded |
429 | More than 60 calls a minute for this monitor (bursts of 20), or the plan's monthly limit. Respect Retry-After |
503 | Try again in a few seconds (Retry-After) |
Retrying the same call is safe: send an Idempotency-Key header (or ?rid=<id>) and repeats of one call count once.
Good to know#
Never call the URL from a web page. Browsers are refused, and the URL would be visible to every visitor.
A ping arriving up to one minute before a cron time counts for that time, to absorb clock drift on the job's host.
If Belvador itself was briefly unable to judge, it waits for buffered pings before deciding, and marks a late decision as detected late instead of alerting twice.
Posts on this topic
All posts- Monitoring AI agents that run on serverless functionsAn AI agent on serverless is a chain of short functions with hard time limits. What to monitor at each step, with three lab runs and documented limits.
- Monitoring vibe-coded apps: what breaks after you publishWhat breaks in a vibe-coded app after you publish, why uptime checks still see 200 OK, and the three checks that catch it. Tested on five real failures.
- Cron jobs that never finish: what your heartbeat actually seesWe stopped a cron job with SIGTERM and SIGKILL, three runs each. Where you send the heartbeat decides whether you get a failure, a false OK or silence.
Spotted something wrong or missing? Tell us: hello@belvador.dev