A tiny dead-man's switch for cron jobs and background scripts, running on Cloudflare Workers.
Your job pings a unique URL after each successful run. Miss the window and Pingly sends a Telegram alert. When pings resume, it sends a recovery message. That's the whole product.
No web UI, no database, no servers to babysit — just one Worker and one KV namespace.
- You register a check and get back a unique
pingUrl. - Your cron job hits that URL at the end of each successful run.
- Every minute, Pingly sweeps for overdue pings.
- Miss the window → 🔴 "… is DOWN" in Telegram.
- Next successful ping → 🟢 "… is UP".
- Zero ops — deploy once, then forget about it.
- Free-tier friendly — fits inside Cloudflare Workers' free plan for typical use.
- Self-hosted — your monitoring data lives in your own Cloudflare account.
- One small file — short, plain JavaScript. Easy to read, fork, and adapt.
You'll need:
- A Cloudflare account (free tier is fine)
- A Telegram bot token from @BotFather
- Your Telegram chat id (DM @userinfobot to get it)
- Node.js with
npx
git clone <this-repo>
cd pingly
# 1. Copy the example wrangler config. This file is gitignored so your
# KV ids and chat id stay local.
cp wrangler.toml.example wrangler.toml
# 2. Create the KV namespaces. Wrangler prints the new ids — paste them
# into the [[kv_namespaces]] block in wrangler.toml.
npx wrangler kv namespace create PINGLY_KV
npx wrangler kv namespace create PINGLY_KV --preview
# 3. Add your secrets (interactive prompts).
npx wrangler secret put TELEGRAM_BOT_TOKEN
npx wrangler secret put ADMIN_TOKEN # any long random string
# 4. Set your default Telegram chat id in wrangler.toml under [vars].
# 5. Deploy.
npx wrangler deployYou'll end up with a live URL like https://pingly.<your-subdomain>.workers.dev.
export ADMIN_TOKEN='the-token-you-set-above'
export PINGLY_URL='https://pingly.<your-subdomain>.workers.dev'
curl -s -X POST $PINGLY_URL/checks \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"backup-job","intervalSec":3600,"gracePeriodSec":300}'The response includes a pingUrl. Drop it at the end of your cron script:
0 * * * * /path/to/backup.sh && curl -fsS https://pingly.example.workers.dev/ping/<uuid>That's it — if the script runs successfully, Pingly sees the ping. If it doesn't, you get a Telegram message within a minute or two of the deadline.
cd pingly
npx wrangler devFor local secrets, create pingly/.dev.vars (gitignored):
TELEGRAM_BOT_TOKEN=your_bot_token
ADMIN_TOKEN=your_admin_tokenTrigger the cron sweep manually instead of waiting a minute:
curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*/1+*+*+*+*&time=$(date +%s)"| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/ |
none | Liveness probe — { ok, version } |
GET HEAD POST |
/ping/<id> |
none | Record a heartbeat |
GET |
/checks |
admin | List all registered checks |
POST |
/checks |
admin | Create a check |
PUT |
/checks/<id> |
admin | Update a check (partial) |
DELETE |
/checks/<id> |
admin | Remove a check |
Admin endpoints require Authorization: Bearer <ADMIN_TOKEN>. The ping URL itself is unauthenticated — its UUID is the secret, so don't share it.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | yes | — | Label for the check (max 80 chars) |
intervalSec |
number | yes | — | How often you expect a ping, in seconds (min 10) |
gracePeriodSec |
number | no | 60 |
Extra slack added to intervalSec before alerting |
chatId |
string | no | null |
Per-check Telegram chat override |
Keep
gracePeriodSec ≥ 60to absorb KV's eventual-consistency window (reads can be up to ~60s stale). Tighter values risk false alarms.
Partial update — send only the fields you want to change. An empty body returns 400.
| Field | Type | Description |
|---|---|---|
name |
string | New label |
intervalSec |
number | New expected interval |
gracePeriodSec |
number | New grace window |
chatId |
string | null |
String to set a per-check chat; null to clear |
Operational fields (id, createdAt, lastPingAt, lastPingIp, alerted) are not editable.
# List checks
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $PINGLY_URL/checks
# Update interval
curl -s -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"intervalSec": 600}' \
$PINGLY_URL/checks/<id>
# Delete
curl -s -X DELETE \
-H "Authorization: Bearer $ADMIN_TOKEN" \
$PINGLY_URL/checks/<id>Telegram messages use HTML formatting; durations and IPs render in a monospace block.
Down
🔴 backup-job is DOWN
Last Ping: 62min ago
IP: 203.0.113.42
Expected: every 1hr
Up
🟢 backup-job is UP
Last Ping: 62min ago
IP: 203.0.113.42
Each outage produces exactly one DOWN and one UP message — no spam, no re-alerts until recovery.
Telegram is the default. The notification layer uses a factory pattern so you can swap in any HTTP-callable channel — email, SMS, Slack, Discord, generic webhook, etc.
To add one, look at the Notification factory section in worker.js:
- Implement
create<Name>Notifier(env)returning{ send(target, message) }. - Register it in the
switchinsidecreateNotifier(env). - Set
NOTIFIER=<name>inwrangler.tomlunder[vars](or per-environment). - Add any required secrets via
wrangler secret put.
Stubs for email and sms are already in the file as starting points. Each notifier receives the same structured Message ({ level, title, fields }) and decides how to render it for its channel.
Recovery fires either:
- On the next ping — instantly, the moment the watched job comes back.
- In the cron sweep — fallback for the rare case where KV reads return a stale snapshot.
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "backup-job",
"intervalSec": 3600,
"gracePeriodSec": 300,
"chatId": null,
"lastPingAt": 1715300000000,
"lastPingIp": "203.0.113.42",
"alerted": false,
"createdAt": 1715290000000
}lastPingAt,lastPingIp— populated on the first ping. Until then the check is "armed but quiet" and won't alert.alerted—truewhile an outage is active. Only the ping handler and the cron sweep can flip it.chatId— overrides the globalTELEGRAM_CHAT_IDfor this check only.
- No web UI, no ping history.
- Telegram is the only notification channel.
- One alert per outage; no re-alerts until recovery.
- A
DELETEracing with the cron sweep can briefly resurrect the deleted record. Delete again to clear.
npx wrangler tail pinglyUseful log lines:
ping received: <name> [<id>] from <ip>
ping received (recovery): <name> [<id>] from <ip>
check overdue: <name> [<id>]
check recovered (sweep): <name> [<id>]
check updated: <name> [<id>] fields=<csv>
check deleted: <name> [<id>]
telegram notifier: sent to <chat_id>
telegram notifier: non-2xx <status>: <body>
npx wrangler triggers listOr in the Cloudflare dashboard: Workers → pingly → Triggers → confirm */1 * * * * is listed.
