Getting Started

WatchCron monitors your cron jobs and scheduled tasks using a Dead Man's Switch pattern. Your job pings a unique URL on each run. If a ping is missed, WatchCron alerts you immediately through your configured notification channels.

Setting up monitoring takes less than a minute:

1
Create a check — Go to your dashboard and click "New Check". Give it a descriptive name (e.g., "Nightly DB Backup") and set the expected schedule. You can use a cron expression like 0 2 * * * or a simple period like "every 5 minutes".
2
Set the grace period — The grace period is extra time WatchCron waits before marking a check as "down". For example, if your backup takes up to 10 minutes to run, set a grace period of 15 minutes so normal run-time variance doesn't trigger false alerts.
3
Copy the ping URL — Each check gets a unique URL like:
https://watchcron.com/ping/your-uuid-here
4
Add the ping to your job — Append an HTTP request to the end of your script or task. See the integration examples below for your language or platform.
5
Set up notifications — Configure email, Slack, Telegram, or webhook channels in your project settings. Then assign them to your check so you get alerted when something fails.

How It Works

WatchCron uses a reverse monitoring approach — instead of actively polling your servers, your jobs report in by sending HTTP pings. This is simpler to set up and works through firewalls without opening any ports.

Check lifecycle

Every check cycles through the following statuses:

Status Meaning
New Check was just created and hasn't received a ping yet. No alerts are sent in this state.
Up Pings are arriving on schedule. Everything is working.
Grace A ping is overdue, but the grace period hasn't expired yet. No alert is sent — this accounts for normal timing variance.
Down The grace period has expired without a ping, or a /fail signal was received. Notifications are sent.
Paused Monitoring is manually paused. No alerts, no tracking. Useful during deployments or maintenance windows.

Schedule types

When creating a check, choose one of two schedule types:

  • Cron expression — Use standard crontab syntax (e.g., */5 * * * * for every 5 minutes, 0 2 * * * for daily at 2 AM). WatchCron calculates the next expected ping automatically based on the expression and timezone.
  • Period — Set a simple interval in seconds (e.g., 300 for every 5 minutes, 3600 for hourly). The expected next ping is calculated from the last received ping.

Grace period

The grace period is a buffer that prevents false alerts caused by minor timing differences. For example, a cron job scheduled at */5 * * * * might actually fire a few seconds late due to system load. A grace period of 60 seconds accommodates this.

A good rule of thumb: set the grace period to at least the typical duration of your job, plus a small margin. For a backup that usually takes 5 minutes, a grace period of 10 minutes works well.

Ping URL

Every check has a unique UUID-based ping URL. You can send pings using any HTTP method (GET, POST, or HEAD). The response is always a simple OK with a 200 status code.

Base URL
https://watchcron.com/ping/{uuid}

You can include a request body (up to 100 KB) with POST requests. The body is stored as part of the ping log, which is useful for debugging — you can send output, error messages, or any diagnostic data.

Sending diagnostic data
curl -fsS -X POST \
     --data-raw "Processed 1,423 records in 12s" \
     https://watchcron.com/ping/your-uuid
The ping endpoint is designed to be fast and resilient. It responds in under 50 ms and accepts requests even under high load. If a request fails, use --retry 3 with curl to automatically retry.

Signal Types

WatchCron supports three signal types for fine-grained monitoring. Using all three gives you the best visibility into your job's behavior:

Endpoint Signal Description
/ping/{uuid} success Default signal. Reports that the job completed successfully. This is the only required signal — everything else is optional.
/ping/{uuid}/start start Reports that the job has started. Enables run duration tracking in the dashboard. Helps distinguish between "job didn't start" and "job started but got stuck".
/ping/{uuid}/fail fail Reports an explicit failure. Triggers immediate notification without waiting for the grace period to expire. Use this in your error handling or catch blocks.

Recommended pattern

For maximum reliability, use all three signals:

Full monitoring pattern
# 1. Signal start — job is beginning
curl -fsS https://watchcron.com/ping/your-uuid/start

# 2. Run your actual task
/usr/local/bin/my-task.sh

# 3. Signal success or failure based on exit code
if [ $? -eq 0 ]; then
    curl -fsS https://watchcron.com/ping/your-uuid
else
    curl -fsS https://watchcron.com/ping/your-uuid/fail
fi

Crontab Integration

The simplest way to monitor cron jobs is appending a curl call to your crontab entry.

Basic — ping after job runs
# m h  dom mon dow   command
*/5 * * * * /path/to/job.sh && curl -fsS --retry 3 https://watchcron.com/ping/your-uuid
Curl flags explained: -f fails silently on HTTP errors, -s suppresses the progress bar, -S still shows errors when -s is used, and --retry 3 retries up to 3 times on transient failures.

Using && ensures the ping is only sent if the job exits with code 0 (success). If the job fails, the ping is skipped and WatchCron will flag the check as "down" after the grace period.

With start signal — track duration
*/5 * * * * curl -fsS https://watchcron.com/ping/your-uuid/start && /path/to/job.sh && curl -fsS https://watchcron.com/ping/your-uuid
With failure reporting
*/5 * * * * /path/to/job.sh && curl -fsS https://watchcron.com/ping/your-uuid || curl -fsS https://watchcron.com/ping/your-uuid/fail
Capture job output for debugging
*/5 * * * * OUT=$(/path/to/job.sh 2>&1); curl -fsS --data-raw "$OUT" https://watchcron.com/ping/your-uuid
Capturing and sending job output to WatchCron makes it easy to debug failures — you can see the exact output in the ping details on your dashboard.

Bash Scripts

Add ping calls directly in your shell scripts for more control over error handling:

backup.sh
#!/bin/bash
set -e

PING_URL="https://watchcron.com/ping/your-uuid"

# Signal start
curl -fsS --retry 3 "$PING_URL/start"

# Your actual work
/usr/bin/backup-database.sh
/usr/bin/cleanup-logs.sh

# Signal success
curl -fsS --retry 3 "$PING_URL"
With error handling and trap
#!/bin/bash

PING_URL="https://watchcron.com/ping/your-uuid"

# Send failure signal on any error
trap 'curl -fsS "$PING_URL/fail"' ERR

curl -fsS "$PING_URL/start"

# If any command below fails, the trap fires automatically
/usr/bin/backup-database.sh
/usr/bin/upload-to-s3.sh
/usr/bin/cleanup-temp.sh

curl -fsS "$PING_URL"
Using trap ... ERR automatically sends a fail signal if any command in the script exits with a non-zero code. This is more robust than manually checking each step.

PHP

Send pings from PHP scripts using file_get_contents for simplicity or cURL for more control:

Simple — file_get_contents
<?php
$pingUrl = 'https://watchcron.com/ping/your-uuid';

// Your work here
processData();

// Ping on success
file_get_contents($pingUrl);
With cURL and error handling
<?php
function ping(string $url): void {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);
    curl_exec($ch);
    curl_close($ch);
}

$pingUrl = 'https://watchcron.com/ping/your-uuid';

ping("$pingUrl/start");

try {
    processData();
    ping($pingUrl);
} catch (Exception $e) {
    ping("$pingUrl/fail");
    throw $e;
}
Using Guzzle
<?php
use GuzzleHttp\Client;

$client = new Client(['timeout' => 10]);
$pingUrl = 'https://watchcron.com/ping/your-uuid';

$client->get("$pingUrl/start");

try {
    processOrders();
    $client->get($pingUrl);
} catch (Throwable $e) {
    $client->post("$pingUrl/fail", [
        'body' => $e->getMessage()
    ]);
    throw $e;
}

Python

Using requests
import requests

PING_URL = "https://watchcron.com/ping/your-uuid"

# Signal start
requests.get(f"{PING_URL}/start", timeout=10)

try:
    # Your work here
    run_backup()

    # Signal success
    requests.get(PING_URL, timeout=10)
except Exception as e:
    # Signal failure with error message
    requests.post(f"{PING_URL}/fail", data=str(e), timeout=10)
    raise
Using urllib (no dependencies)
import urllib.request

PING_URL = "https://watchcron.com/ping/your-uuid"

urllib.request.urlopen(PING_URL, timeout=10)
Reusable decorator
import functools, requests

def monitor(ping_url):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            requests.get(f"{ping_url}/start", timeout=10)
            try:
                result = func(*args, **kwargs)
                requests.get(ping_url, timeout=10)
                return result
            except Exception:
                requests.get(f"{ping_url}/fail", timeout=10)
                raise
        return wrapper
    return decorator

@monitor("https://watchcron.com/ping/your-uuid")
def nightly_backup():
    # Your logic here
    ...

Node.js

Using fetch (Node 18+)
const PING_URL = "https://watchcron.com/ping/your-uuid";

await fetch(`${PING_URL}/start`);

try {
    await runMyTask();
    await fetch(PING_URL);
} catch (err) {
    await fetch(`${PING_URL}/fail`, {
        method: "POST",
        body: err.message
    });
    throw err;
}
Using https module (no dependencies)
const https = require('https');

function ping(url) {
    return new Promise((resolve) => {
        https.get(url, resolve).on('error', resolve);
    });
}

const PING_URL = "https://watchcron.com/ping/your-uuid";

await ping(`${PING_URL}/start`);
await runMyTask();
await ping(PING_URL);

Ruby

Using Net::HTTP (no dependencies)
require 'net/http'
require 'uri'

PING_URL = "https://watchcron.com/ping/your-uuid"

def ping(url)
  Net::HTTP.get(URI(url))
rescue => e
  $stderr.puts "Ping failed: #{e.message}"
end

ping("#{PING_URL}/start")

begin
  run_backup
  ping(PING_URL)
rescue => e
  ping("#{PING_URL}/fail")
  raise
end
Rake task
task :cleanup do
  ping("https://watchcron.com/ping/your-uuid/start")

  ActiveRecord::Base.connection.execute(
    "DELETE FROM sessions WHERE updated_at < NOW() - INTERVAL '30 days'"
  )

  ping("https://watchcron.com/ping/your-uuid")
end

Go

main.go
package main

import (
    "log"
    "net/http"
    "time"
)

var pingURL = "https://watchcron.com/ping/your-uuid"
var client = &http.Client{Timeout: 10 * time.Second}

func ping(url string) {
    resp, err := client.Get(url)
    if err != nil {
        log.Printf("ping failed: %v", err)
        return
    }
    resp.Body.Close()
}

func main() {
    ping(pingURL + "/start")

    if err := runBackup(); err != nil {
        ping(pingURL + "/fail")
        log.Fatal(err)
    }

    ping(pingURL)
}

PowerShell

For Windows scheduled tasks or PowerShell-based automation:

backup.ps1
$PingUrl = "https://watchcron.com/ping/your-uuid"

Invoke-RestMethod "$PingUrl/start"

try {
    # Your task here
    Backup-SqlDatabase -ServerInstance "localhost" -Database "mydb"

    Invoke-RestMethod $PingUrl
}
catch {
    Invoke-RestMethod "$PingUrl/fail" -Method POST -Body $_.Exception.Message
    throw
}
Windows Task Scheduler (one-liner)
powershell -Command "C:\scripts\backup.ps1; Invoke-RestMethod 'https://watchcron.com/ping/your-uuid'"

Laravel Scheduled Tasks

Laravel has built-in support for health check pings via the pingBefore and thenPing methods on the scheduler:

routes/console.php
use Illuminate\Support\Facades\Schedule;

$pingUrl = 'https://watchcron.com/ping/your-uuid';

Schedule::command('app:process-orders')
    ->hourly()
    ->pingBefore("$pingUrl/start")
    ->thenPing($pingUrl)
    ->pingOnFailure("$pingUrl/fail");
Laravel's pingBefore, thenPing, and pingOnFailure are the cleanest integration — no extra code needed. They work with Artisan commands, closures, and job dispatches.
Multiple tasks with separate checks
use Illuminate\Support\Facades\Schedule;

// Each task gets its own WatchCron check
Schedule::command('app:process-orders')
    ->everyFiveMinutes()
    ->thenPing('https://watchcron.com/ping/uuid-orders');

Schedule::command('app:send-reports')
    ->dailyAt('08:00')
    ->pingBefore('https://watchcron.com/ping/uuid-reports/start')
    ->thenPing('https://watchcron.com/ping/uuid-reports')
    ->pingOnFailure('https://watchcron.com/ping/uuid-reports/fail');

Schedule::command('app:cleanup-temp')
    ->daily()
    ->thenPing('https://watchcron.com/ping/uuid-cleanup');
Make sure your Laravel scheduler is actually running. Add * * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1 to your server's crontab, or use php artisan schedule:work in development.

WordPress (WP-Cron)

WordPress uses its own cron system (WP-Cron) that runs on page visits. You can add WatchCron pings to any scheduled WordPress hook:

functions.php or custom plugin
// Schedule the event on activation
if (!wp_next_scheduled('my_daily_cleanup')) {
    wp_schedule_event(time(), 'daily', 'my_daily_cleanup');
}

// Hook the function
add_action('my_daily_cleanup', function() {
    $pingUrl = 'https://watchcron.com/ping/your-uuid';

    wp_remote_get("$pingUrl/start");

    try {
        // Clean up expired transients, revisions, etc.
        global $wpdb;
        $wpdb->query(
            "DELETE FROM {$wpdb->options}
             WHERE option_name LIKE '_transient_timeout_%'
             AND option_value < UNIX_TIMESTAMP()"
        );

        wp_remote_get($pingUrl);
    } catch (Exception $e) {
        wp_remote_get("$pingUrl/fail");
    }
});
WP-Cron only runs when someone visits your site. On low-traffic sites, tasks may be delayed. For reliable scheduling, disable WP-Cron (define('DISABLE_WP_CRON', true); in wp-config.php) and trigger it with a real cron job: */5 * * * * wget -qO- https://yoursite.com/wp-cron.php

Docker

Monitor containerized cron jobs by adding curl to your container and pinging from the entrypoint or crontab:

Dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y curl cron
COPY crontab /etc/cron.d/app-cron
RUN chmod 0644 /etc/cron.d/app-cron && crontab /etc/cron.d/app-cron
CMD ["cron", "-f"]
crontab
*/5 * * * * /app/run-task.sh && curl -fsS https://watchcron.com/ping/your-uuid > /dev/null 2>&1
docker-compose.yml
services:
  worker:
    build: .
    environment:
      - WATCHCRON_PING_URL=https://watchcron.com/ping/your-uuid
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
Pass the ping URL as an environment variable so you can change it without rebuilding the image. Reference it in your scripts with $WATCHCRON_PING_URL.

Systemd Timers

For systemd-based scheduling, add the ping to your service unit using ExecStartPost:

/etc/systemd/system/backup.service
[Unit]
Description=Database Backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
ExecStartPost=/usr/bin/curl -fsS https://watchcron.com/ping/your-uuid

ExecStartPost only runs when ExecStart exits successfully (code 0), so the ping is only sent on success.

/etc/systemd/system/backup.timer
[Unit]
Description=Run backup every 6 hours

[Timer]
OnCalendar=*-*-* 00/6:00:00
Persistent=true

[Install]
WantedBy=timers.target
Persistent=true ensures the timer fires after a missed run (e.g., if the server was rebooted). Enable the timer with systemctl enable --now backup.timer.

REST API

WatchCron provides a full REST API for managing checks programmatically. All API requests require a Bearer token (API key), which you can generate in your dashboard under API Keys.

Authentication
curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://watchcron.com/api/v1/checks

Endpoints

Method Endpoint Description
GET/api/v1/checksList all checks in the current project
POST/api/v1/checksCreate a new check
GET/api/v1/checks/{uuid}Get check details and current status
PUT/api/v1/checks/{uuid}Update check configuration
DELETE/api/v1/checks/{uuid}Delete a check and all its ping data
POST/api/v1/checks/{uuid}/pausePause monitoring
GET/api/v1/checks/{uuid}/pingsList recent pings (paginated)
GET/api/v1/checks/{uuid}/flipsList status changes (up/down events)
GET/api/v1/channelsList notification channels

Create a check

POST /api/v1/checks
curl -X POST https://watchcron.com/api/v1/checks \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "name": "Nightly Backup",
       "schedule_type": "cron",
       "cron_expression": "0 2 * * *",
       "timezone": "UTC",
       "grace_seconds": 600
     }'

Example response

JSON response
{
  "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "name": "Nightly Backup",
  "status": "new",
  "schedule_type": "cron",
  "cron_expression": "0 2 * * *",
  "timezone": "UTC",
  "grace_seconds": 600,
  "ping_url": "https://watchcron.com/ping/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "last_ping_at": null,
  "next_expected_at": null,
  "created_at": "2026-03-22T10:00:00Z"
}

List pings with filtering

GET /api/v1/checks/{uuid}/pings
curl -H "Authorization: Bearer YOUR_API_KEY" \
     "https://watchcron.com/api/v1/checks/YOUR-UUID/pings?per_page=50"

Status Badges

Embed live status badges in your README, wiki, or internal dashboards. Badges update automatically as check statuses change.

Per-check badge

Show the status of an individual check:

Markdown
![Status](https://watchcron.com/badge/CHECK-UUID.svg)

Per-tag badge

Aggregate status of all checks sharing a tag (e.g., "production" or "staging"):

Markdown
![Production](https://watchcron.com/badge/YOUR-API-KEY/production.svg)

Project summary badge

Show the overall health of your entire project:

Markdown
![Summary](https://watchcron.com/badge/YOUR-API-KEY/summary.svg)

HTML embed

Use HTML if you need to control the badge size or link it to your status page:

HTML
<a href="https://watchcron.com/status/your-slug">
  <img src="https://watchcron.com/badge/YOUR-API-KEY/summary.svg"
       alt="WatchCron Status" />
</a>
Badges are cached for 30 seconds and use standard SVG format. They work everywhere images are supported — GitHub READMEs, wikis, Notion pages, and more.

Notification Channels

WatchCron supports four types of notification channels. Configure them in your project settings under Integrations, then assign them to individual checks.

Email

Sends an email notification to the specified address when a check changes status. Emails include the check name, old and new status, and a direct link to the check details.

Configuration
Email Address: [email protected]

Slack

Posts a color-coded message to a Slack channel via Incoming Webhook. Messages include the check name, status change, and a timestamp.

Configuration
Webhook URL: https://hooks.slack.com/services/T.../B.../xxx

To set up:

  1. Go to api.slack.com/apps and create a new app.
  2. Enable Incoming Webhooks and add one to your workspace.
  3. Select the channel where alerts should appear.
  4. Copy the webhook URL and paste it into WatchCron.

Telegram

Sends a formatted message to a Telegram chat via Bot API. Supports both private chats and group chats.

Configuration
Bot Token: 123456:ABC-DEF...
Chat ID:   -1001234567890

To set up:

  1. Message @BotFather on Telegram and create a new bot.
  2. Copy the bot token.
  3. Add the bot to your group chat (or start a private chat with it).
  4. To get the chat ID, send a message in the chat, then visit https://api.telegram.org/bot<TOKEN>/getUpdates and look for the chat.id field.

Webhook

Sends a JSON POST request to any URL you specify. This is the most flexible option — use it for PagerDuty, Opsgenie, Discord, Microsoft Teams, or your own custom integrations.

Payload example
{
  "check": "Nightly Backup",
  "status": "down",
  "previous_status": "up",
  "changed_at": "2026-03-22T10:30:00Z",
  "project": "Production",
  "ping_url": "https://watchcron.com/ping/a1b2c3d4-..."
}
You can test any channel directly from the dashboard by clicking the "Test" button next to it. This sends a sample notification so you can confirm everything is wired up correctly.

Best Practices

Use descriptive check names

Name checks after what they monitor, not how they run. Nightly DB Backup is better than Cron Job #3. When you get an alert at 3 AM, a clear name saves time.

Set appropriate grace periods

Don't set the grace period too tight — account for network latency, server load, and normal job duration variance. A grace period that's 2x your typical job duration is a good starting point.

Use start + success + fail signals

Using all three signal types gives you the most visibility:

  • Start — tells you the job began, and enables run duration tracking.
  • Success — confirms successful completion.
  • Fail — triggers an immediate alert without waiting for the grace period.

Always use --retry with curl

Network hiccups happen. Adding --retry 3 makes your pings resilient to momentary connectivity issues without adding complexity.

Don't let ping failures break your job

The monitoring call should never cause your actual task to fail. Use timeout flags (--max-time 10 with curl) and handle errors gracefully. Your backup is more important than the ping.

Monitor the scheduler itself

If you use a central scheduler (like Laravel's schedule:run or a cron daemon), create a dedicated check that pings every minute. If the scheduler itself stops, all your other checks will eventually alert — but a dedicated "scheduler heartbeat" catches it immediately.

Use tags to organize checks

Group related checks with tags like production, staging, backups, or payments. Tags make it easy to filter your dashboard and create aggregate status badges.

Pause checks during maintenance

Before a planned deployment or maintenance window, pause the affected checks from your dashboard. This prevents false alerts while you work. Don't forget to resume them afterward.

Troubleshooting

Check stays in "New" status

The check hasn't received its first ping yet. Verify that:

  • The ping URL is correct (check for typos in the UUID).
  • Your server can reach https://watchcron.com — try curl -v https://watchcron.com/ping/your-uuid manually.
  • There's no firewall or proxy blocking outbound HTTPS requests.

False "down" alerts

If you're getting alerts but your job is running fine:

  • Grace period too short — increase it to accommodate your job's typical duration plus a buffer.
  • Timezone mismatch — make sure the check's timezone matches the server running the cron job. A cron expression of 0 2 * * * in UTC runs at a different time than in America/New_York.
  • Ping not reaching WatchCron — check your crontab for output redirection that might suppress curl errors. Use curl -fsS (not curl -s) so errors are visible in cron mail.

Ping returns 404

The UUID in the ping URL doesn't match any active check. This usually means the check was deleted or the UUID was copied incorrectly. Go to your dashboard and copy the URL again.

Curl command times out

If curl hangs, add an explicit timeout: curl -fsS --max-time 10 --retry 3 URL. This ensures the curl call completes within 10 seconds even if there's a network issue, so it doesn't block your cron job.

Notifications not arriving

Check the following:

  • The notification channel is enabled (not disabled) in your project settings.
  • The channel is assigned to the check — creating a channel doesn't automatically connect it to all checks.
  • For email: check your spam folder. For Slack/Telegram: verify the webhook URL and bot token are still valid.
  • Use the Test button on the channel to send a sample notification and confirm it arrives.

API returns 401 Unauthorized

Your API key is missing, invalid, or expired. Generate a new key from your dashboard under API Keys. Make sure the Authorization header uses the format Bearer YOUR_API_KEY (with a space after "Bearer").