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

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](https://belvador.dev/en/docs/privacy). 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](#send-output-with-a-ping)).

## 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

```text
https://ping.belvador.dev/p/bvp_<your-ping-token>             finished OK
https://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 once
https://ping.belvador.dev/p/bvp_<your-ping-token>/<0-255>     exit status: 0 = OK, 1–255 = failed
https://ping.belvador.dev/p/bvp_<your-ping-token>/log         attach output or a note; never changes the status
```

`GET` and `POST` both work. A trailing `/` is fine.

### curl

```bash
# Alive / finished OK
curl -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 once
curl -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>/$? || true
```

### crontab

```bash
# 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>/$? || true
```

### Node.js, Bun or Deno

```ts
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

```python
import os, urllib.request

PING_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 job

ping("/start")
try:
    run_job()
    ping()
except Exception:
    ping("/fail")
    raise
```

### GitHub Actions

```text
# .github/workflows/nightly.yml — store the URL as the secret BELVADOR_PING_URL
jobs:
  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" || true
```

## Send 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.

```bash
# 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 status
echo "exported 1204 rows" | curl -fsS -m 10 --retry 5 -o /dev/null --data-binary @- https://ping.belvador.dev/p/bvp_<your-ping-token>/log || true
```

- Up to 64 KB of text per ping. A larger body is refused with `413` and **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](https://belvador.dev/en/docs/privacy) 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](https://belvador.dev/en/docs/alerts), including [your own webhook](https://belvador.dev/en/docs/alerts/webhooks).

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.
