# Scheduled Job Heartbeat and Silence Ledger (`kingii98/scheduled-job-heartbeat-and-silence-ledger`) Actor

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.

- **URL**: https://apify.com/kingii98/scheduled-job-heartbeat-and-silence-ledger.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 run starteds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Scheduled Job Heartbeat and Silence Ledger

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:

1. Your cron job, backup script, ETL step or queue worker sends one ping after
   each successful run.
2. The Actor writes the ping into a named Apify key-value store.
3. 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:

```bash
## crontab: run the backup, and ping only when it succeeded
0 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](#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

```json
{
  "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:

```json
{
  "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

```json
{
  "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

```bash
uv sync
uv run pytest
uv run ruff check .
```

# Actor input Schema

## `jobs` (type: `array`):

1 to 50 records. Each record holds slug (the identifier in the ping URL, for example "nightly-backup"), name (the human name of the job), expected\_max\_gap\_minutes (the longest silence that is still normal between two runs) and the optional grace\_minutes (the extra time before a late job counts as missed).

## `mode` (type: `string`):

"evaluate" reads the ledger and reports the silent jobs. Run it on the Apify scheduler every 15 minutes. "receive" starts the HTTP server that accepts the pings. A run in Apify Standby always uses the receive mode, whatever this field holds.

## `ledgerStoreName` (type: `string`):

The named Apify key-value store that holds one record for each slug: last ping time, last status and the count of consecutive misses. The receive mode and the evaluate mode must use the same name.

## `pingSecret` (type: `string`):

Optional shared secret. The ping caller sends it as the query value "?secret=" or as the header "X-Ping-Secret". An empty value accepts every ping.

## `alertWebhookUrl` (type: `string`):

Optional. One public HTTPS URL. The Actor sends one JSON POST for each job that becomes late or missed. Loopback, private and reserved addresses are refused. Leave it empty to read the alerts in the dataset only.

## `defaultGraceMinutes` (type: `integer`):

The grace value for a job record that holds no grace\_minutes. A job is "late" after the expected gap and "missed" after the expected gap plus the grace.

## `suppressRepeatAlerts` (type: `boolean`):

Send one alert when the status of a job changes, and not on every schedule tick. Switch it off to receive one alert for each evaluate run while the job stays silent.

## `timeoutSeconds` (type: `integer`):

Timeout for one alert POST to your webhook.

## `receiveMaxSeconds` (type: `integer`):

How long a manual receive run stays open. A run in Apify Standby ignores this limit, because the platform controls the life of a Standby container.

## `maxRequestBytes` (type: `integer`):

Hard cap on the bytes that the receive mode reads from one ping request. A larger request is refused with HTTP 413.

## Actor input object example

```json
{
  "jobs": [
    {
      "slug": "nightly-backup",
      "name": "Nightly database backup",
      "expected_max_gap_minutes": 1440,
      "grace_minutes": 30
    },
    {
      "slug": "hourly-etl",
      "name": "Hourly ETL step",
      "expected_max_gap_minutes": 60,
      "grace_minutes": 10
    },
    {
      "slug": "queue-worker",
      "name": "Queue worker heartbeat",
      "expected_max_gap_minutes": 15,
      "grace_minutes": 5
    }
  ],
  "mode": "evaluate",
  "ledgerStoreName": "heartbeat-ledger",
  "alertWebhookUrl": "",
  "defaultGraceMinutes": 5,
  "suppressRepeatAlerts": true,
  "timeoutSeconds": 15,
  "receiveMaxSeconds": 60,
  "maxRequestBytes": 16384
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "jobs": [
        {
            "slug": "nightly-backup",
            "name": "Nightly database backup",
            "expected_max_gap_minutes": 1440,
            "grace_minutes": 30
        },
        {
            "slug": "hourly-etl",
            "name": "Hourly ETL step",
            "expected_max_gap_minutes": 60,
            "grace_minutes": 10
        },
        {
            "slug": "queue-worker",
            "name": "Queue worker heartbeat",
            "expected_max_gap_minutes": 15,
            "grace_minutes": 5
        }
    ],
    "mode": "evaluate",
    "ledgerStoreName": "heartbeat-ledger",
    "alertWebhookUrl": "",
    "defaultGraceMinutes": 5,
    "suppressRepeatAlerts": true,
    "timeoutSeconds": 15,
    "receiveMaxSeconds": 60,
    "maxRequestBytes": 16384
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/scheduled-job-heartbeat-and-silence-ledger").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "jobs": [
        {
            "slug": "nightly-backup",
            "name": "Nightly database backup",
            "expected_max_gap_minutes": 1440,
            "grace_minutes": 30,
        },
        {
            "slug": "hourly-etl",
            "name": "Hourly ETL step",
            "expected_max_gap_minutes": 60,
            "grace_minutes": 10,
        },
        {
            "slug": "queue-worker",
            "name": "Queue worker heartbeat",
            "expected_max_gap_minutes": 15,
            "grace_minutes": 5,
        },
    ],
    "mode": "evaluate",
    "ledgerStoreName": "heartbeat-ledger",
    "alertWebhookUrl": "",
    "defaultGraceMinutes": 5,
    "suppressRepeatAlerts": True,
    "timeoutSeconds": 15,
    "receiveMaxSeconds": 60,
    "maxRequestBytes": 16384,
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/scheduled-job-heartbeat-and-silence-ledger").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "jobs": [
    {
      "slug": "nightly-backup",
      "name": "Nightly database backup",
      "expected_max_gap_minutes": 1440,
      "grace_minutes": 30
    },
    {
      "slug": "hourly-etl",
      "name": "Hourly ETL step",
      "expected_max_gap_minutes": 60,
      "grace_minutes": 10
    },
    {
      "slug": "queue-worker",
      "name": "Queue worker heartbeat",
      "expected_max_gap_minutes": 15,
      "grace_minutes": 5
    }
  ],
  "mode": "evaluate",
  "ledgerStoreName": "heartbeat-ledger",
  "alertWebhookUrl": "",
  "defaultGraceMinutes": 5,
  "suppressRepeatAlerts": true,
  "timeoutSeconds": 15,
  "receiveMaxSeconds": 60,
  "maxRequestBytes": 16384
}' |
apify call kingii98/scheduled-job-heartbeat-and-silence-ledger --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/scheduled-job-heartbeat-and-silence-ledger"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/twNXWQTInUxCdJZCo/builds/bKcWeGwv4rn1PMb7z/openapi.json
