Heartbeats
Backups, imports, sitemaps, digest emails, renewals: the jobs that run on a schedule break silently. Give each one a heartbeat: the job calls its URL when it runs, and PageHum alerts you when a call doesn't come, or the job says it failed.
How many
| Free | Starter | Pro | Ultra | |
|---|---|---|---|---|
| Heartbeats, all sites together | 2 | 10 | 30 | 100 |
Like checked pages, they’re one allowance for the whole account, spread across its sites however you like. After a downgrade the oldest stay watched, up to the new allowance; the rest show as not watched until there’s room again.
Adding one
On a site’s board, under Heartbeats, press Add a heartbeat. Name the job and say when it runs: every so many minutes, hours, or days, or a cron expression in a time zone. The grace time is how late a run may be before it counts as down; leave it empty for a fifth of the period, between 5 minutes and an hour. You get the job’s URL and a line to paste into its crontab.
Nothing alerts until the first ping, so a heartbeat you haven’t wired up yet stays quiet. You can also add them with the API or an assistant over MCP.
The URL
| Call | Means |
|---|---|
https://ingest.pagehum.com/hb/<token> | The job ran and succeeded. |
…/start | It started. If neither success nor failure follows within the grace time, it's down: it hung or died. |
…/fail | It failed. POST the error output as the body; the first KB shows on the board and in the alert's AI prompt. |
…/0, …/1, …/2 | An exit code: 0 is success, anything else a failure. |
?ms=1234 | How long the run took, if the job measured it. After /start, PageHum measures it. |
- GET, POST, and HEAD all work. The answer is a plain-text OK, or 404 for a URL that isn’t a heartbeat’s.
- A body is read up to 10 KB and only its first 1 KB kept, for failures. PageHum keeps each heartbeat’s last 50 pings and 14 days of counts.
- Up to 60 pings a minute per heartbeat; past that the answer is 429. Use
curl --retry 3so a blip on the network doesn’t read as a missed run. - The URL works like a password: anyone with it can ping. Keep it in your secrets, not in a public repository. If it gets out, make a new one from the heartbeat’s Ping URL; the old one stops at once.
States
| State | Means |
|---|---|
| Waiting for the first ping | Added, not pinged yet. Nothing alerts. |
| Up | The last run pinged, and the next isn't due yet. |
| Running | It sent /start and is within the grace time. |
| Late | The next run is due and hasn't pinged, but it's within the grace time. |
| Down | Past the grace time with no ping, or a /start with no end. You're alerted. |
| Failed | The job sent /fail. You're alerted, with its output. |
| Paused | Paused by you, or past the plan's heartbeats. Pings are kept; nothing alerts. |
Schedules
- Every so often: the next ping is due that long after the last one, so a job that drifts a little stays up.
- Cron: five fields (minute, hour, day of the month, month, day of the week) with ranges, lists, steps, names like
MON-FRI, and@dailyor@hourly. When both day fields are set, either matches, as in crontab. Each run is due at its scheduled time in the time zone you pick; a ping up to a minute early counts for that run. - On the night clocks go forward, a time that doesn’t exist (02:30 in New York) is taken an hour later; on the night they go back, a repeated time counts once.
Alerts
The alert pass runs every five minutes, so a missed run alerts within five minutes of its grace time ending, and a failure within five minutes of /fail. Alerts go where the others do: the app, email, browser notifications, and your channels, as heartbeat.down and heartbeat.recovered for webhooks. Each comes once, with an AI prompt that names the job, its schedule, the last success, and the failure’s output, and another note when it pings again. On Free, a heartbeat stopping and starting again is emailed like a site going down, within the monthly outage emails.
Examples
crontab
# Every night at 03:00: the ping runs only if the backup succeeded. 0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 https://ingest.pagehum.com/hb/YOUR-TOKEN > /dev/null
A wrapper with /start and /fail
#!/bin/sh # Tells PageHum when the job starts, and whether it succeeded, with the error's output if not. URL=https://ingest.pagehum.com/hb/YOUR-TOKEN curl -fsS -m 10 --retry 3 "$URL/start" > /dev/null if output=$(/usr/local/bin/backup.sh 2>&1); then curl -fsS -m 10 --retry 3 "$URL" > /dev/null else printf '%s' "$output" | tail -c 10000 | curl -fsS -m 10 --retry 3 --data-binary @- "$URL/fail" > /dev/null fi
Pass the exit code
/usr/local/bin/backup.sh; curl -fsS -m 10 --retry 3 "https://ingest.pagehum.com/hb/YOUR-TOKEN/$?" > /dev/null
GitHub Actions
on:
schedule:
- cron: "0 3 * * *" # UTC
jobs:
backup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/backup.sh
- if: success()
run: curl -fsS -m 10 --retry 3 "${{ secrets.PAGEHUM_HEARTBEAT }}"
- if: failure()
run: curl -fsS -m 10 --retry 3 "${{ secrets.PAGEHUM_HEARTBEAT }}/fail"Vercel Cron (Next.js)
// app/api/cron/sitemap/route.ts, with { "crons": [{ "path": "/api/cron/sitemap", "schedule": "0 4 * * *" }] } in vercel.json
export async function GET(request: Request) {
if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const heartbeat = process.env.PAGEHUM_HEARTBEAT!;
const started = Date.now();
try {
await rebuildSitemap();
await fetch(`${heartbeat}?ms=${Date.now() - started}`);
return Response.json({ ok: true });
} catch (error) {
await fetch(`${heartbeat}/fail`, { method: "POST", body: String(error) });
throw error;
}
}Node, any job
// Wrap any job: a worker, a queue consumer, a script run by a scheduler.
async function withHeartbeat(url, job) {
await fetch(`${url}/start`).catch(() => {});
const started = Date.now();
try {
const result = await job();
await fetch(`${url}?ms=${Date.now() - started}`).catch(() => {});
return result;
} catch (error) {
await fetch(`${url}/fail`, { method: "POST", body: String(error?.stack ?? error) }).catch(() => {});
throw error;
}
}
await withHeartbeat(process.env.PAGEHUM_HEARTBEAT, () => sendDigestEmails());On the status page
Tick Show on the status page on a heartbeat to list it under Scheduled jobs on your public status page: its name, whether it’s running, and when it last ran. Its schedule, output, and URL stay private. One that’s down makes a healthy page read “Worth a look”.