Skip to content

Heartbeat & Cron Monitoring

Monitor cron jobs, scheduled tasks, and any periodic process. Create a monitor, get a unique URL, add one curl to your script. maintenant tracks start/finish times, durations, exit codes, and alerts you when a job misses its deadline.


How It Works

  1. Create a heartbeat monitor through the API or dashboard: give it a name, an interval (how often the job runs, from 60 seconds to 7 days) and a grace period (extra time allowed, from 0 up to the interval).
  2. Get a unique ping URL: maintenant generates a random, secret ping token for this monitor and builds the URL from it.
  3. Ping the URL from your cron job or script: maintenant records the ping and moves the deadline to now + interval + grace.
  4. Get alerted if the deadline is missed: the job did not report in on time.

Deadlines are checked every 15 seconds. A new heartbeat has the status new and is not watched until its first ping, since the deadline starts from a ping: a job that never runs at all does not raise an alert. Send one ping when you create the monitor, or run the job once.


Ping URL Format

Every heartbeat monitor gets a unique URL:

{BASE_URL}/ping/{token}

Where {BASE_URL} is your MAINTENANT_BASE_URL environment variable. The ready-made snippets (curl, wget, Python, Go, Bash and a Docker healthcheck) shown in a heartbeat's detail panel, and returned by GET /api/v1/heartbeats/{id}, are built from it. It defaults to http:// plus MAINTENANT_ADDR (http://127.0.0.1:8080), so set it to the public address of your instance or the snippets will not work from another machine.

The Ping Token Is a Secret

The {token} part of the URL is the heartbeat's ping token. It is distinct from the heartbeat id used by the admin API, and it is the only access control of the ping URL: anyone who knows it can report runs for that job, and so hide a failure or raise a false alert. Keep it out of public repositories and CI/CD logs.

The token appears in the ping_token field of the heartbeat object returned by the admin API, on the heartbeat card (the shortened URL and the Copy URL button), and in the heartbeat detail panel with the snippets. /ping/{id} never resolves a heartbeat: only the token does.

After the upgrade that introduced the token, every existing heartbeat received its former id as its ping token, so the ping URLs already deployed keep working without any change. Heartbeats created afterwards get a token that differs from their id.

If a token leaks, rotate it (see Rotate the Ping Token).

Simple Ping

Report that the job ran successfully:

curl -fsS -o /dev/null https://now.example.com/ping/{token}

Ping with Exit Code

Report the job's exit code so maintenant can track failures:

curl -fsS -o /dev/null https://now.example.com/ping/{token}/$?
  • Exit code 0 = success
  • Any other exit code (an integer from 1 to 255) = failure
  • Anything else answers 400 INVALID_EXIT_CODE

Start/Finish Pings

Track job duration by sending a start ping before the job and a finish ping after:

# Signal job start
curl -fsS -o /dev/null https://now.example.com/ping/{token}/start

# Run the actual job
/usr/local/bin/my-backup.sh
EXIT_CODE=$?

# Signal job finish with exit code
curl -fsS -o /dev/null https://now.example.com/ping/{token}/${EXIT_CODE}

maintenant calculates the duration between start and finish pings. A start ping also moves the deadline, so a job that starts and never finishes raises an alert once the deadline passes. Starting again while a run is still open closes the previous run as timeout.

All ping routes accept GET and POST. A POST body of up to 10 KB is accepted but not stored. An unknown token answers 404 HEARTBEAT_NOT_FOUND.


Cron Job Examples

Basic Cron Entry

# Run backup every day at 2 AM, report to maintenant
0 2 * * * /usr/local/bin/backup.sh && curl -fsS -o /dev/null https://now.example.com/ping/{token}/$?

With Duration Tracking

# Report start and finish with exit code
0 2 * * * curl -fsS -o /dev/null https://now.example.com/ping/{token}/start; /usr/local/bin/backup.sh; curl -fsS -o /dev/null https://now.example.com/ping/{token}/$?

Systemd Timer

# In your service ExecStartPost or a wrapper script
ExecStartPost=/usr/bin/curl -fsS -o /dev/null https://now.example.com/ping/{token}/0

What maintenant Tracks

For each heartbeat monitor, maintenant records:

Metric Description
Last ping Timestamp of the most recent ping
Exit code Exit code reported by the job (0 = success)
Duration Time between start and finish pings
Status new (no ping yet), started (a start ping is waiting for its finish), up (pinging on time), down (deadline missed), paused
Next deadline Last ping (or start ping) + interval + grace
Source address Address of the client that sent each ping (source_ip in GET /api/v1/heartbeats/{id}/pings)
Execution history Past executions with their outcome: success, failure, timeout or in_progress

A ping that reports a non-zero exit code leaves the status at up, since the job did report in, and raises the exit_code_failure alert instead. Only a missed deadline turns the status to down.

The source address is the address of the connection, unless the request comes from a proxy listed in MAINTENANT_TRUSTED_PROXIES: maintenant then reads it from X-Forwarded-For (or X-Real-IP). Behind a reverse proxy, set that variable, otherwise every ping carries the proxy's address.

Pings, executions and pauses are kept for 30 days.

Uptime

GET /api/v1/heartbeats/{id}/uptime/daily?days=90 returns one entry per UTC day, most recent first, for up to 365 days (days defaults to 90). Each entry has a date, an uptime_percent and an incident_count.

Uptime is weighted by time. A heartbeat counts as up from a successful ping (a plain ping or exit code 0) until its deadline lapses without another ping, or until a ping reports a non-zero exit code. A start ping moves the deadline but does not change the state. uptime_percent is the share of the day spent up, and incident_count is the number of times the heartbeat went from up to down that day. A day before the first ping has no value (uptime_percent is null), and neither has a day spent entirely paused. Paused time counts neither as up nor as down: on a day with a pause, uptime_percent is the share of the rest of the day spent up.

Completed days are aggregated by the retention job and kept for 365 days, which is what keeps the full range available after the raw pings are purged.


Deadline Missed Alerts

When a heartbeat monitor does not receive a ping within its configured deadline, maintenant fires a deadline_missed alert with Critical severity. A ping reporting a non-zero exit code fires an exit_code_failure alert instead, also Critical. Both resolve automatically, with a recovery notification, on the next successful ping; deleting or pausing a heartbeat clears its active alerts too.

This means your cron job either:

  • Failed to run at all
  • Ran but crashed before reaching the curl ping
  • Is taking longer than expected

Set reasonable deadlines

The deadline is the interval plus the grace period. Set the grace slightly longer than your expected job duration: a job that runs every 5 minutes with a 1-minute runtime should have a grace of 1 to 2 minutes to avoid false positives.


Managing Heartbeats

Pause and Resume

Temporarily disable a heartbeat monitor during planned maintenance:

# Pause: stops deadline checking and clears active alerts
POST /api/v1/heartbeats/{id}/pause

# Resume: back to up, with a new deadline counted from now
POST /api/v1/heartbeats/{id}/resume

Pausing a heartbeat that is already paused, or resuming one that is not paused, answers 400 INVALID_INPUT. An unknown heartbeat answers 404. Every pause is recorded and kept with the ping history. A ping that arrives while the heartbeat is paused puts it back to up.

Edit a Heartbeat

Change the name, the interval or the grace period from the Edit button of the detail panel, or with PUT /api/v1/heartbeats/{id}. The heartbeat keeps its id, its ping URL and its whole history, so the jobs that ping it need no change.

When the interval or the grace period changes, the deadline of a heartbeat that is up or started is recomputed from the same starting point as before: the last ping, the start of the current run, or the resume after a pause when no ping has arrived since. The new deadline is that starting point plus the new interval plus the new grace period. A heartbeat that is new or paused has no deadline and keeps none; a heartbeat that is down stays down until its next ping. Editing never raises nor resolves an alert by itself: if the recomputed deadline is already past, the next deadline check marks the heartbeat down and raises the usual deadline_missed alert.

The grace period must stay between 0 and the interval. Lowering the interval under the current grace period answers 400 INVALID_INPUT, as does any value outside the limits given for creation.

Rotate the Ping Token

Replace the ping token of a heartbeat, from the Rotate token button of its detail panel or through the API:

POST /api/v1/heartbeats/{id}/rotate-token

The response is the heartbeat with its new ping_token. The old token answers 404 HEARTBEAT_NOT_FOUND from the next request on, so update every job that pings it. The heartbeat keeps its id, its status and its whole history. An unknown heartbeat answers 404 HEARTBEAT_NOT_FOUND, and a demo build answers 403 DEMO_MODE.

CRUD Operations

Method Endpoint Description
GET /api/v1/heartbeats List all heartbeat monitors (filters status, agent_id)
POST /api/v1/heartbeats Create a new heartbeat monitor (name, interval_seconds, grace_seconds)
GET /api/v1/heartbeats/{id} Get a specific monitor, with its snippets
PUT /api/v1/heartbeats/{id} Update a monitor
POST /api/v1/heartbeats/{id}/rotate-token Replace the ping token; the old one answers 404 at once
DELETE /api/v1/heartbeats/{id} Delete a monitor and its history for good: its ping URL answers 404 from then on
GET /api/v1/heartbeats/{id}/executions Execution history
GET /api/v1/heartbeats/{id}/pings Raw pings
GET /api/v1/heartbeats/{id}/uptime/daily Daily uptime

Community is limited to 5 heartbeats; Personal and Pro have no cap. Above the cap, creation answers 403 QUOTA_EXCEEDED. The cap counts every heartbeat, paused ones included, and GET /api/v1/edition reports the current count and the limit under quotas.heartbeats.


Outbound Heartbeats

A monitoring tool cannot report its own outage. Outbound heartbeats solve this by letting another maintenant instance watch this one: this instance pings a heartbeat monitor on the other instance at a fixed interval, and if the pings stop, the other instance raises a deadline_missed alert.

Setting It Up

  1. On the other instance, create a heartbeat monitor with an interval that matches the one you will use below, plus some grace time. Copy its ping URL.
  2. On this instance, open Outbound heartbeats in the sidebar menu and click New target.
  3. Paste the ping URL, give the target a name and choose an interval.
  4. Click Send now to check the target right away. The row shows the HTTP status it received, or the error.

The first send happens within seconds of creating the target. Two instances can watch each other this way, each with one incoming and one outgoing heartbeat.

How Sends Work

  • Each target is called with a GET request, with a 10-second timeout and the User-Agent maintenant/<version> outbound-heartbeat.
  • The interval goes from 30 seconds to 24 hours.
  • A 2xx response counts as a success. Any other status, or a network error, is recorded as the target's last error and logged as a warning.
  • A disabled target keeps its settings but is no longer called.
  • An extra root CA set with MAINTENANT_CA_CERT applies, so the other instance may use an internal PKI.
  • In demo mode the page is read-only and no ping is sent.

HTTPS and public addresses only

Targets must use https:// and resolve to a public address. Loopback, private, link-local, carrier-grade NAT, unspecified and multicast ranges are refused when the target is saved and again at every connection, including redirects and DNS answers that change between the two, so the feature cannot be used to reach services on your internal network. MAINTENANT_ALLOW_PRIVATE_WEBHOOKS does not lift this restriction. The other instance therefore has to be reachable over HTTPS from this one.

API

Method Endpoint Description
GET /api/v1/outbound-heartbeats List all targets
POST /api/v1/outbound-heartbeats Create a target
PUT /api/v1/outbound-heartbeats/{id} Update a target
DELETE /api/v1/outbound-heartbeats/{id} Delete a target
POST /api/v1/outbound-heartbeats/{id}/send Send a ping now
{
  "name": "Watched by monitoring.example.com",
  "url": "https://monitoring.example.com/ping/4f7c2d9e-1b3a-4c5d-8e6f-0a1b2c3d4e5f",
  "interval_seconds": 60,
  "enabled": true
}

Public Ping Endpoints

The /ping/ routes are designed to be publicly accessible. maintenant applies no authentication of its own to them, since your cron jobs and external services need to reach them directly.

Each ping token is limited to 60 pings per minute, whatever the address it comes from. Pings with an unknown token are limited to 10 requests per second per client address, with a burst of 20, so a flood of invalid tokens never blocks a valid one. A request over the limit answers 429 with a Retry-After header and is not recorded. Behind a reverse proxy, set MAINTENANT_TRUSTED_PROXIES: without it every caller with an unknown token counts as the proxy and shares its single limit. A demo build does not accept pings: the /ping/ routes answer 403 DEMO_MODE.

Reverse proxy configuration

Make sure your reverse proxy allows unauthenticated access to /ping/ paths. See Security for details.