Scheduled Job Heartbeat and Silence Ledger
Pricing
from $20.00 / 1,000 run starteds
Scheduled Job Heartbeat and Silence Ledger
Your cron job sends one curl ping after each run. This Actor keeps the ledger in a named key-value store and reports every job that went silent, with an optional webhook alert. HTTP only, no browser, no proxy, no database.
Pricing
from $20.00 / 1,000 run starteds
Rating
0.0
(0)
Developer
kingii98
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
2 days ago
Last modified
Categories
Share
An uptime monitor tells you that a URL answers. It cannot tell you that your nightly backup stopped to run, because silence is the absence of an event and there is no URL to check.
This Actor gives you a heartbeat ledger with one curl call and no new vendor:
- Your cron job, backup script, ETL step or queue worker sends one ping after each successful run.
- The Actor writes the ping into a named Apify key-value store.
- A scheduled evaluate run compares the ledger against the gap that you declared for each job, and reports every job that went silent.
No Redis. No database. No monitoring SaaS. No browser and no proxy.
How it works
The Actor has two modes.
| Mode | How you start it | What it does |
|---|---|---|
receive | Apify Standby | Holds an HTTP server open and accepts POST /ping/<slug>. |
evaluate | Apify Scheduler, every 15 minutes | Reads the ledger, judges every job, writes the dataset and sends the alerts. |
Both modes must use the same ledgerStoreName, because that named store
is the ledger. A run in Standby always uses the receive mode, whatever the
mode field holds.
1. Turn on Standby and send the pings
Open Settings → Standby of your Actor build and switch Standby on. Apify then gives you a Standby URL. Add one line to the end of your job:
# crontab: run the backup, and ping only when it succeeded0 2 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 \"https://<your-standby-url>/ping/nightly-backup?secret=$PING_SECRET"
GET and POST are both accepted, so a plain curl -fsS <url> also works.
The secret may travel as ?secret=…, as the header X-Ping-Secret, or as
Authorization: Bearer ….
| Answer | Meaning |
|---|---|
200 | The ping is in the ledger. |
401 | The secret is wrong or missing. |
404 | The slug is not in the jobs list of the Standby run. |
405 | The method is not GET or POST. |
413 | The request passes maxRequestBytes. |
GET / answers a small JSON health record with the list of known slugs. It
needs no secret, so the Apify Standby probe can reach it.
2. Schedule the evaluate run
Add an Apify schedule that starts this Actor every 15 minutes with
"mode": "evaluate" and the same jobs and ledgerStoreName.
Put every job into one schedule, because run-started is charged for each
evaluate run and not for each job. The cadence sets both the bill and the
alert latency. Read Pricing before you choose it.
Status rules
For each job, with gap = expected_max_gap_minutes and
grace = grace_minutes:
| Silence since the last ping | Status |
|---|---|
<= gap | ok |
> gap and <= gap + grace | late |
> gap + grace | missed |
A job that the ledger sees for the first time stays ok until its own first
deadline runs out, so a job that you added one minute ago does not alert at
once. consecutiveMisses counts up on every missed evaluation, holds its
value on late, and returns to 0 on the first new ping.
Input
| Field | Type | Default | Description |
|---|---|---|---|
jobs | array | three sample jobs | 1 to 50 records. Each holds slug, name, expected_max_gap_minutes and the optional grace_minutes. |
mode | select | evaluate | evaluate or receive. A Standby run always receives. |
ledgerStoreName | string | heartbeat-ledger | The named key-value store that holds the ledger. |
pingSecret | string (secret) | none | Optional shared secret for the ping caller. |
alertWebhookUrl | string | empty | Optional public HTTPS URL. One JSON POST for each job that becomes late or missed. |
defaultGraceMinutes | integer | 5 | The grace for a job record without grace_minutes. |
suppressRepeatAlerts | boolean | true | One alert for each status change, and not one on every tick. |
timeoutSeconds | integer | 15 | Timeout for one alert POST. |
receiveMaxSeconds | integer | 60 | How long a manual receive run stays open. Standby ignores it. |
maxRequestBytes | integer | 16384 | Byte cap for one ping request. |
A slug holds 1 to 64 characters out of a-z, 0-9, ., - and _, and
it starts with a letter or a digit. It travels in the ping URL and in the
store key, so no other character is accepted.
Example input
{"jobs": [{"slug": "nightly-backup","name": "Nightly database backup","expected_max_gap_minutes": 1440,"grace_minutes": 30},{"slug": "queue-worker","name": "Queue worker heartbeat","expected_max_gap_minutes": 15,"grace_minutes": 5}],"mode": "evaluate","ledgerStoreName": "heartbeat-ledger","alertWebhookUrl": "https://hooks.example.com/cron-silence"}
Set expected_max_gap_minutes a little above the real cadence of the job. A
nightly job that starts at 02:00 is well served by 1440 with a grace of
30, because a slow run must not raise an alert.
Output
One job record for each job in each evaluate run:
| Field | Description |
|---|---|
slug, name | Identity of the job. |
status | ok, late or missed. |
lastPingAt, minutesSinceLastPing, pingCount | What the ledger holds. |
expectedMaxGapMinutes, graceMinutes | The rule that the status came from. |
consecutiveMisses | How many evaluations in a row saw this job as missed. |
alert, alertSuppressed | Whether this run raised an alert for this job. |
firstSeenAt, note, checkedAt | When the ledger first saw the job, and why. |
One alert record for each alert:
| Field | Description |
|---|---|
slug, name, status, minutesSinceLastPing, consecutiveMisses | The job that went silent. |
webhookStatus | The HTTP status code of the POST, or null when no webhook URL is set. |
delivered, note, dispatchedAt | The outcome of the POST. |
One summary record for each run:
{"recordType": "summary","mode": "evaluate","jobCount": 3,"okCount": 2,"lateCount": 0,"missedCount": 1,"allOk": false,"alertsRaised": 1,"alertsDispatched": 1,"jobDaysCharged": 3,"note": "","finishedAt": "2026-09-03T12:00:00Z"}
Alert payload
{"source": "scheduled-job-heartbeat-and-silence-ledger","event": "job-silence-alert","slug": "nightly-backup","name": "Nightly database backup","status": "missed","lastPingAt": "2026-09-02T02:03:11Z","minutesSinceLastPing": 1980.4,"expectedMaxGapMinutes": 1440,"graceMinutes": 30,"consecutiveMisses": 2,"checkedAt": "2026-09-03T11:03:33Z"}
Pricing
This Actor uses the pay-per-event model. Four events are charged:
| Event | Unit | Price (USD) | When the Actor charges it |
|---|---|---|---|
run-started | one evaluate run | 0.02 | Once for each evaluate run, after the input is read. A receive run never charges it. |
heartbeat-received | one accepted ping | 0.0008 | Once for each ping that the receive mode wrote into the ledger. A refused ping (401, 404, 413) is not charged. |
job-day-monitored | one job for one calendar day | 0.08 | Once for each job on its first evaluate run of a UTC day. The ledger holds the last charged day, so 96 evaluations in a day charge one job once. |
alert-dispatched | one webhook POST | 0.05 | Once for each alert POST that your webhook answered. An alert without a webhook URL, and a POST that could not reach the host, are not charged. |
What one month costs
run-started is charged for each evaluate run, and not for each job. A 15
minute schedule makes 96 evaluate runs for each day, so this event sets the
floor of the bill. Every job that you add to the same run shares that floor.
One job that pings every hour, evaluated every 15 minutes, across 30 days:
| Event | Count | Price (USD) | Cost (USD) |
|---|---|---|---|
run-started | 96 runs x 30 days = 2880 | 0.02 | 57.60 |
job-day-monitored | 1 job x 30 days = 30 | 0.08 | 2.40 |
heartbeat-received | 24 pings x 30 days = 720 | 0.0008 | 0.58 |
alert-dispatched | 0 | 0.05 | 0.00 |
| Total | 60.58 |
The schedule cadence sets both the bill and the alert latency, because the Actor finds a silent job on its next evaluate run. A slower schedule costs much less, and more jobs in one run make each job cheaper:
| Evaluate schedule | Runs for each day | 1 job | 10 jobs | For each job, at 10 jobs |
|---|---|---|---|---|
| every 15 minutes | 96 | 60.58 | 87.36 | 8.74 |
| every hour | 24 | 17.38 | 44.16 | 4.42 |
| every 6 hours | 4 | 5.38 | 32.16 | 3.22 |
All numbers are USD for each month of 30 days, with one hourly ping for each job and no alert. Put every job into one schedule, and choose the slowest cadence that still finds a silent job soon enough for you.
A business verdict is not a failure
A late job, a missed job, a refused webhook URL and a run that finds nothing are all dataset records plus a status message. The run ends SUCCEEDED. Only a real malfunction, such as an input that cannot be read, ends FAILED.
Limits
- HTTP only. No browser, no proxy, no CAPTCHA service, no paid API and no external database.
- The alert webhook must be a public HTTPS URL. Loopback, private and reserved addresses are refused, and the POST never follows a redirect.
- 1 to 50 jobs for each run.
- The receive mode answers one request for each connection and then closes it.
Development
uv syncuv run pytestuv run ruff check .


