How to build a cron job monitor
How to build a cron job monitor with a secret ping URL, a missed-window sweep, and one email. Follow the prompts, check each step, then skip uptime.
You can learn how to build a cron job monitor as a secret ping URL, a missed-window sweep, and one email. Cronitor is that product. The catalog line is “Your cron jobs, finally tattling on themselves.”
You pay $10–$49 a month. That is $120–$588 a year. The verdict is “Heartbeats are easy”. The first estimate is 6 hours: ping urls, missed-run detection, email alert.
There is no standalone teardown yet. The hosting post already uses a Worker cron for later work. This guide is the walk. Prompts in order, then what to look at.
The audience is a developer running a dozen scheduled jobs across a few machines. If you need probes in five regions, or a phone call at 3am, keep paying. The same v1 also replaces Healthchecks.io.
UptimeRobot is the cousin that goes the other way, $24–$69 a month, about 6 hours. That app pings your URL. This app waits for your job to ping it. I walked that as How to build an uptime monitor. A public board customers can read is the status page guide.
How to build a cron job monitor without an uptime mesh
The Cronitor page splits the work into three rows.
The first row is the one you want. A unique ping URL per job. A sweep that notices a missed window. One email. That is the 6-hour v1.
The second row is 2 days. Configurable grace windows, a richer incident timeline, recovery-notice toggles, mute. Do that after Ada has received one real alert.
The third row is 2 weeks and up. Uptime checks, status pages, on-call rotations. Skip it.
If you tell an agent to “clone Cronitor”, it aims at the third row, or it starts building UptimeRobot. Pin every prompt to the first estimate.
v1 does this. Flavio opens Jobwatch and registers nightly-backup. The expected interval is 24 hours. He copies a long random URL and pastes it after /Users/flavio/backup.sh in crontab. The first success ping lands. The next night the script dies before the curl. The sweep, running on a Worker that is not that laptop, opens one incident and mails Ada once. A later success ping closes it. Grace pickers, mute, and a public status board stay off the page.
The parts and where a ping goes
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Cronitor is Astro, Workers, and D1. This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. The Worker still has to run the sweep. A dashboard in React does not replace scheduled().
The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why the uptime mesh waits.
Open Build your own Cronitor. The guardrail is the useful sentence: treat ping URLs as secrets, make them unguessable, revocable, and rate-limited, and never execute anything a ping sends. The non-goal sits next to it. No uptime checks, status pages, on-call rotations, or agent-based server monitoring.
Register, copy, revoke, and ping are ordinary HTTP. The email starts after an incident opens or closes. If mail is down, the incident row still exists. If the plan invents an outgoing GET to https://flaviocopes.com/health, send it back. That is the other product.
The data the job carries
The card lists Job, Ping, Incident, and AlertRule. Tonight you need the first three. AlertRule is Ada’s address in the environment. Mute and a recovery-notice toggle wait for 2 days.
A job has a name, an expected interval, a secret token, a last ping time, the next expected ping, and a status. Status is ok, late, failing, or paused. The token is the public credential. The job id stays off the ping URL.
A ping belongs to that job. It carries received-at, success or failure, and a short failure message you capped and stored as inert text. You never run that text.
An incident belongs to that job. Cause is missed-window or reported-failure. There is at most one open incident per job. A success ping, or an explicit resolve, is what closes it.
Every owner query is scoped to Flavio. Unknown and revoked tokens get the same response, so probing does not reveal which jobs exist.
Compute the next expected ping on the server from the stored interval. A manual extra ping at 11:00 does not move a 03:00 nightly job to 11:00 tomorrow.
The screens you ship
Three surfaces. Keep them boxes and labels. If the agent starts matching Cronitor’s live UI, send it back.
The jobs list is Flavio’s home. Name, schedule, last ping, expected next ping, status, and an open-incident mark. A register action. Pause sits on a row so a job you are rewriting stops alerting.
The job detail is where the secret lives. The ping URL with copy and regenerate. Schedule and a fixed five-minute grace you do not expose as a picker. A recent ping timeline. Captured failure output, escaped. Pause and delete.
The incidents page is the mail you already got, as rows. Open and resolved tabs. Affected job, cause, started and resolved times. Acknowledge is a human write. It does not close the incident. Only a success ping or an explicit resolve does that.
Register, ping, and miss a window
The first journey on the card is this walk. Register the job. Copy the URL into crontab. Watch the first success. The useful failure is the one that never calls.
A public HTTP check is a different job. That is the uptime monitor guide. PagerDuty is $21–$25 a person a month, about 12 hours, when the question is who wakes up.
Prompt 1. Scaffold Jobwatch and stop
Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it. If you picked TanStack Start, keep the same routes and rules. Only the adapters change.
Ship the 6-hour row only.
Ping urls, missed-run
detection, email alert.
Project name: Jobwatch.
Public URL is
https://jobs.flaviocopes.com.
Owner is Flavio. Ada gets
the mail at
[email protected].
Seed job: nightly-backup
Expected interval: 24 hours.
Also seed site-hourly at
60 minutes so we can test
the sweep the same day.
Do not add grace pickers,
mute, recovery-notice
toggles, or start/finish
pairing.
Do not add uptime checks,
status pages, on-call
rotations, or server agents.
When the plan is done, stop.
Do not write code until I say go.
The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.
You want /jobs for the list, /jobs/nightly-backup for the editor and the token, /incidents for open and resolved rows, and /p/:token for the ping. Tables for jobs, pings, and incidents. No monitors. No probe_regions. No oncall_schedules.
If the plan includes an outgoing fetch to a health URL, Playwright, or Twilio, send it back.
When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.
What to check after the scaffold
Start the app. Sign in as Flavio. You should see an empty jobs list and a register action.
Open the migrations. Jobs store an interval, a token, last ping, and next expected ping. Incidents have a uniqueness rule so only one row per job can be open. There is no url column the app will fetch.
If the README is a placeholder, stop. It has to name the secret token, the independent sweep, the one-incident rule, and the crontab line.
Prompt 2. Register a job and mint a secret URL
The third mini-prompt on the card is the useful one. Anyone who finds the URL can fake the job’s health.
Build the Jobwatch register
form and the ping URL.
Flavio types a name and an
interval. Intervals in v1:
60 minutes and 24 hours.
Bake a 5 minute grace into
the sweep. Do not show a
grace picker.
Mint the ping URL from at
least 128 bits of randomness.
Path is /p/:token, not
/p/nightly-backup. Copy sits
on the detail page.
Regenerate issues a new
token. The old one dies on
the next request.
Paused jobs keep history and
stop alerting. Delete is a
confirm. Tokens from a
deleted job match unknown
tokens: same response, no
hint that the job existed.
Login required for every
owner page. A guessed job
id from a signed-out window
returns nothing useful.
Do not accept pings yet.
Do not run the sweep yet.
Do not send email yet.
Done when I can register
nightly-backup, copy
https://jobs.flaviocopes.com/p/…
and regenerate so the old
token is dead.
What to check after the URL exists
Register nightly-backup. The list shows ok, last ping empty, next expected empty until the first ping.
Copy the URL. It is long and random. It does not contain nightly-backup.
Regenerate. Hit the old URL. Same response as a junk token. The new URL is the only live one.
Open /jobs/nightly-backup with no cookie. Login wall. Guess the id from a private window. Nothing useful.
Prompt 3. Accept success and failure pings
The job calls in. You record what you decided to keep. You do not run what it sent.
Build ping intake at
/p/:token for Jobwatch.
GET or POST is a success
ping. POST with a failure
flag is a reported failure.
Record received-at, success
or fail, an optional
duration, and a message
capped at 2 KB. Truncate
on the server. Store the
text as inert. Escape it
on the timeline. Never
execute it. Never render
it as HTML.
Rate-limit per token so a
stuck retry loop cannot
flood D1. Unknown and
revoked tokens get the
same tiny refusal.
A success ping on a healthy
job updates last ping and
recomputes next expected
from the stored interval,
not from the arrival time.
An off-schedule extra ping
still counts as success. It
does not slide the 03:00
window to 11:00.
A failure ping opens exactly
one incident with cause
reported-failure if none is
open. Further failures
attach. They do not mail
yet. That wait is prompt 4.
Seed a first success on
nightly-backup so the
timeline is not empty.
Do not add a start endpoint.
Do not pair durations.
Do not fetch any URL the
job mentioned.
Done when curl of the live
token writes a success row,
a failure with
"disk full" shows escaped
on the timeline, and a
revoked token matches a
junk token.
The crontab line the job will use later looks like this.
0 3 * * * /Users/flavio/backup.sh && curl -fsS "$PING"
$PING is the copied URL. The script has to finish before the curl.
What to check after the first ping
Curl the live token. Refresh the detail page. Last ping is now. Status is ok. For site-hourly, next expected stays on the hour, not on a random extra ping.
POST a failure with <script>alert(1)</script> disk full. The timeline shows the words. View source. The script is escaped. No alert.
Hit the token 50 times in a second. You get refusals after the cap. D1 did not grow by 50 rows.
If the agent stored the raw body as HTML, send prompt 3 back.
Prompt 4. Sweep for missed windows and mail Ada
The first mini-prompt on the card is the product. Detecting the job that did not run requires code that definitely runs.
Build the missed-window
sweep and email for
Jobwatch.
wrangler.jsonc has a Cron
Trigger every minute.
scheduled() walks enabled
jobs. A job is late when
now is past expected next
ping plus the baked
5 minute grace.
The sweep opens exactly
one incident with cause
missed-window. Start time
is the expected ping, not
the sweep time. Send one
email to
[email protected] with
job name, expected time,
last ping, and the public
job URL.
Further late sweeps attach
to that open incident.
They do not open a sibling
and they do not mail again.
A success ping closes the
incident, stores duration,
sends one recovery email,
and sets status ok.
A paused job is invisible
to the sweep. An
off-schedule extra ping
never rewrites the next
expected time.
Key each alert on
incident id plus opened or
closed. A retried sweep
cannot send the failure
mail twice.
The sweep runs on this
Worker. It must not SSH
to the backup laptop. It
must not live as a cron
next to backup.sh.
Local email adapter prints
the message. .env.example
lists EMAIL_FROM and
EMAIL_TO. No live provider
in v1.
Do not add Slack.
Do not add SMS.
Do not add mute.
Done when site-hourly with
no ping for 65 minutes
creates one incident and
one printed mail, minute
66 prints nothing, and the
next success prints one
recovery mail and closes
the row. At 64 minutes
there is still no incident.
Cron Triggers map that expression to scheduled(). They run on UTC. Cloudflare says changes can take several minutes to spread, so do not debug a missing sweep by redeploying every thirty seconds.
What to check after Ada gets mail
Set site-hourly last ping 70 minutes ago. Trigger scheduled() twice.
/incidents has one open row. Cause is missed-window. The terminal printed one late mail. The second trigger printed nothing.
Curl the live token. The incident closes. One recovery line. Status is ok.
Register a second hourly job and pause it. Same 70-minute gap. The sweep ignores it.
Send a manual extra ping on nightly-backup at 11:00. Next expected stays 03:00.
If /jobs says late and /incidents is empty, the writes split. Send prompt 4 back.
Failures the agent ships anyway
A sweep next to backup.sh
If the laptop is asleep, both the job and the monitor are dead. The capability line is Workers and Cloudflare Cron Triggers. The hosting post is the binding, then the cron, then the domain. You notice when you unplug the machine and no mail arrives.
One email per missed minute
A uniqueness rule on one open incident per job is the lock. Without it, overlapping sweeps both open a row and Ada gets sixty mails. Replay the late hour twice. Still one row.
A guessable token
/p/nightly-backup is a name, not a secret. You notice when you can invent the path. Regenerate has to kill the old one on the next request, not on a later sweep.
Running the ping body
A failure message is a note from a script you do not control. If the timeline executes it, you built a webhook runner. Cap it. Escape it. The IFTTT post is the cousin that is allowed to call out. This app is not.
Fetching a site because the form had a URL field
That is UptimeRobot. The uptime monitor guide already walks cron HTTP checks. If monitors.url appears in the plan, you left Cronitor.
An off-schedule ping that moves the window
A retry at 11:00 must not become the new 11:00 job. You notice the next morning when 03:00 never ran and the row still says ok.
A public status note is a different job. That is Statuspage, $29–$399 a month, about 3 hours. I wrote the teardown as Build a Statuspage alternative.
If the crash is in the code that did run, that is Sentry, $26–$89 a month, about 3 days. The Sentry walkthrough is that post.
Deploy the monitor
Jobwatch wants Astro, Workers, and D1. Put it on a subdomain you already own, jobs.flaviocopes.com. The Cron Trigger is the sweep. Email is the adapter with a local print path.
The hosting post is that path: the adapter, then the bindings, then the custom domain. Wrangler exposes /cdn-cgi/local/scheduled during wrangler dev. Hit that before you wait for a real minute on the network.
Attach the domain on the Worker. Add the one-minute cron.
[triggers]
crons = ["* * * * *"]
Point EMAIL_TO at an inbox you read.
Ship with nightly-backup enabled and the curl on a machine you control. Skip one run on purpose, or pause the script before the curl. Wait out the interval plus grace. Confirm Ada got one late mail. Run the script. Confirm one recovery mail. Confirm the next late minute did not mail again.
Take a D1 backup before you delete the paid account. The ping timeline is the history you will miss.
Use it on a real job for a week. If you miss a grace picker, a mute, or a start-and-finish duration, those are the 2-day row. Add them after Ada has trusted one alert.
Then cancel Cronitor, or Healthchecks.io, whichever one you pay for.
What to skip on purpose
Leave out uptime checks, status pages, on-call rotations, and anything that looks like an agent on the server.
A start ping paired with a finish ping is the fourth mini-prompt on the card. Useful after you care how long the backup ran.
A queue that publishes later is a different cron. That is the Buffer guide.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own Cronitor. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate only: ping urls, missed-run detection, email alert. Six hours.
Put the curl after /Users/flavio/backup.sh and wait for one real night.