Documentation
Wrap any scheduled job so you know the moment it fails, goes missing, or drifts slow. One endpoint, three events, no agent to install. Most jobs are wired in under two minutes.
Overview
CronCanary works by having your job send three signals to a single HTTPS endpoint:
- start — the run began. Starts the duration clock and arms the missing-run timer.
- end — the run finished successfully. Records duration and checks for drift.
- fail — the run errored. Fires your alert channels immediately.
You can send all three, or just end/fail for fire-and-forget jobs — duration is then measured from the matching start when one exists. If you prefer not to touch your code at all, use one of the SDK helpers that emit these for you.
Get your API token
Open your dashboard, create a monitor, and copy two values from it:
- Your API token — shown once under Settings → API token. Store it as an environment variable named
CRONCANARY_TOKENand keep it secret. - The monitor's job id — shown on the monitor's page. You pass this as
job_idon every ping.
CRONCANARY_TOKEN is set in your environment and that you've replaced your-job-id with the real id from your dashboard.The ingest API
Everything routes through one endpoint. The SDKs are thin wrappers over this — you never need more than an HTTP client.
POST https://croncanary.dev/ingest
Authorization: Bearer $CRONCANARY_TOKEN
Content-Type: application/json
Request body
| Field | Required | Description |
|---|---|---|
job_id | yes | The monitor id from your dashboard. |
run_id | yes | A unique id for this run. Reuse the same value across the run's start and end/fail so they pair up. A UUID or $$ (PID) works well. |
event | yes | One of start, end, fail. |
duration_ms | no | Run duration in ms. If omitted on end/fail, it's computed from the matching start. |
region | no | Free-text label for where the job ran (e.g. us-east-1). Shown in the timeline. |
payload | no | Arbitrary JSON attached to the run — rows processed, file counts, etc. Surfaces inline in the timeline and alerts. |
Responses are 200 {"ok":true,...} on success, 400 for a malformed body, 401 for a bad token, and 404 if the job_id doesn't belong to your account.
bash / cron + curl
The universal integration — works in any crontab, CI step, or shell script. This wrapper reports success and failure, and pins a single run_id to both pings.
#!/usr/bin/env bash
# Wrap any command with start/end/fail reporting.
# Usage: ./monitored.sh your-actual-command --flags
set -uo pipefail
JOB_ID="your-job-id"
RUN_ID="$(date +%s)-$$" # unique per run
PING=https://croncanary.dev/ingest
ping() { curl -fsS -m 10 -X POST "$PING" \
-H "Authorization: Bearer $CRONCANARY_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"job_id\":\"$JOB_ID\",\"run_id\":\"$RUN_ID\",\"event\":\"$1\"}" >/dev/null || true; }
ping start
if "$@"; then ping end; else ping fail; fi
|| true means CronCanary never breaks your job even if our endpoint is briefly unreachable. Use -m 10 so a hung ping can't stall your cron.Minimal one-liner (success-only heartbeat)
# In crontab — only reports completion. Good for "did it run?" checks.
0 * * * * /path/to/job.sh && curl -fsS -m 10 -X POST https://croncanary.dev/ingest \
-H "Authorization: Bearer $CRONCANARY_TOKEN" \
-d '{"job_id":"your-job-id","run_id":"'"$(date +\%s)"'","event":"end"}'
Node.js
No dependencies — uses the built-in fetch (Node 18+).
const JOB_ID = 'your-job-id';
const runId = crypto.randomUUID();
const ping = (event, extra = {}) =>
fetch('https://croncanary.dev/ingest', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CRONCANARY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ job_id: JOB_ID, run_id: runId, event, ...extra }),
}).catch(() => {}); // never let monitoring break the job
async function main() {
const t0 = Date.now();
await ping('start');
try {
await doYourJob();
await ping('end', { duration_ms: Date.now() - t0 });
} catch (err) {
await ping('fail', { payload: { error: String(err) } });
throw err;
}
}
main();
Prefer one line? The croncanary npm package does all of this with monitor(name, token, fn).
Python
Standard-library only — no requests needed.
import json, os, time, uuid, urllib.request
JOB_ID = "your-job-id"
RUN_ID = uuid.uuid4().hex
URL = "https://croncanary.dev/ingest"
def ping(event, **extra):
body = json.dumps({"job_id": JOB_ID, "run_id": RUN_ID, "event": event, **extra}).encode()
req = urllib.request.Request(URL, data=body, method="POST", headers={
"Authorization": f"Bearer {os.environ['CRONCANARY_TOKEN']}",
"Content-Type": "application/json",
})
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass # monitoring must never break the job
t0 = time.time()
ping("start")
try:
run_your_job()
ping("end", duration_ms=int((time.time() - t0) * 1000))
except Exception as e:
ping("fail", payload={"error": str(e)})
raise
Or install the SDK: pip install croncanary and use the @monitor decorator.
GitHub Actions
Monitor any scheduled workflow. Store your token as a repo secret named CRONCANARY_TOKEN (Settings → Secrets → Actions).
name: nightly-job
on:
schedule:
- cron: '0 7 * * *'
jobs:
run:
runs-on: ubuntu-latest
env:
CRONCANARY_TOKEN: ${{ secrets.CRONCANARY_TOKEN }}
JOB_ID: your-job-id
RUN_ID: ${{ github.run_id }}
steps:
- name: Notify start
run: >
curl -fsS -m 10 -X POST https://croncanary.dev/ingest
-H "Authorization: Bearer $CRONCANARY_TOKEN" -H "Content-Type: application/json"
-d "{\"job_id\":\"$JOB_ID\",\"run_id\":\"$RUN_ID\",\"event\":\"start\"}"
- name: Do the work
run: ./your-task.sh
- name: Notify success
if: success()
run: >
curl -fsS -m 10 -X POST https://croncanary.dev/ingest
-H "Authorization: Bearer $CRONCANARY_TOKEN" -H "Content-Type: application/json"
-d "{\"job_id\":\"$JOB_ID\",\"run_id\":\"$RUN_ID\",\"event\":\"end\"}"
- name: Notify failure
if: failure()
run: >
curl -fsS -m 10 -X POST https://croncanary.dev/ingest
-H "Authorization: Bearer $CRONCANARY_TOKEN" -H "Content-Type: application/json"
-d "{\"job_id\":\"$JOB_ID\",\"run_id\":\"$RUN_ID\",\"event\":\"fail\"}"
github.run_id as the run_id links the CronCanary timeline entry directly to the Actions run.Vercel Cron
Wrap the handler your vercel.json cron points at. Add CRONCANARY_TOKEN in your Vercel project's environment variables.
// app/api/cron/daily/route.ts — triggered by vercel.json crons
const JOB_ID = 'your-job-id';
async function ping(runId: string, event: string, extra = {}) {
await fetch('https://croncanary.dev/ingest', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CRONCANARY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ job_id: JOB_ID, run_id: runId, event, ...extra }),
}).catch(() => {});
}
export async function GET() {
const runId = crypto.randomUUID();
const t0 = Date.now();
await ping(runId, 'start');
try {
await generateReport();
await ping(runId, 'end', { duration_ms: Date.now() - t0 });
return Response.json({ ok: true });
} catch (err) {
await ping(runId, 'fail', { payload: { error: String(err) } });
return new Response('failed', { status: 500 });
}
}
pg_cron (Postgres)
Report completion of a scheduled SQL job using the http extension. Store the token however your environment manages secrets; below it's passed inline for clarity — prefer a current_setting() or a secrets table in production.
-- requires: CREATE EXTENSION IF NOT EXISTS http;
SELECT cron.schedule(
'nightly-rollup',
'0 3 * * *',
$$
-- 1) do the work
CALL run_nightly_rollup();
-- 2) report success to CronCanary
SELECT http_post(
'https://croncanary.dev/ingest',
json_build_object(
'job_id', 'your-job-id',
'run_id', gen_random_uuid()::text,
'event', 'end'
)::text,
'application/json',
ARRAY[http_header('Authorization', 'Bearer ' || current_setting('app.croncanary_token'))]
);
$$
);
fail pings, wrap the body in a plpgsql block with an EXCEPTION clause.Cloudflare Workers
The cleanest path is the croncanary package's wrap() on your scheduled handler, but here's the dependency-free version:
export default {
async scheduled(event, env, ctx) {
const JOB_ID = 'your-job-id', runId = crypto.randomUUID(), t0 = Date.now();
const ping = (ev, extra = {}) =>
fetch('https://croncanary.dev/ingest', {
method: 'POST',
headers: { Authorization: `Bearer ${env.CRONCANARY_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: JOB_ID, run_id: runId, event: ev, region: event.cron, ...extra }),
}).catch(() => {});
await ping('start');
try {
await yourScheduledWork(env);
ctx.waitUntil(ping('end', { duration_ms: Date.now() - t0 }));
} catch (err) {
ctx.waitUntil(ping('fail', { payload: { error: String(err) } }));
throw err;
}
},
};
Add CRONCANARY_TOKEN with npx wrangler secret put CRONCANARY_TOKEN.
SDK helpers
If you'd rather not hand-write pings, the official SDKs emit start/end/fail (with duration and region) for you. They're thin — everything they do is the API above.
JavaScript / TypeScript — npm install croncanary
import { monitor, wrap } from 'croncanary';
// Node / Vercel: wrap any async function
await monitor('daily-report', process.env.CRONCANARY_TOKEN, async () => {
await generateReport();
});
// Cloudflare Workers: wrap the scheduled handler
export default { scheduled: wrap(myJob, { job: 'nightly-sync', token: env.CRONCANARY_TOKEN }) };
Python — pip install croncanary
from croncanary import monitor, CronCanary
@monitor(job='nightly-etl', token=os.environ['CRONCANARY_TOKEN'])
def nightly_etl():
load_to_warehouse(fetch_rows())
# context manager when you want to attach payload data
with CronCanary(job='etl', token=os.environ['CRONCANARY_TOKEN']) as run:
n = do_etl()
run.set_payload({'rows': n})
The SDK accepts the monitor by name for convenience; the raw API uses the monitor's job_id. Both resolve to the same monitor in your dashboard.
Missing-run alerts
Wiring pings tells CronCanary when a job does run. To also catch a job that silently stops running, set the monitor's expected schedule (a cron expression) in the dashboard. If no start/end arrives within the expected window plus its grace period, CronCanary fires a missing-run alert to your channels. This is the check a plain success-webhook can't do — it alerts on absence.
FAQ
Do I have to send all three events?
No. The minimum useful integration is a single end ping on success — that powers "did it run?" and missing-run detection. Add start to get accurate durations and drift detection, and fail to get instant failure alerts.
What if your endpoint is down when my job runs?
Every snippet swallows ping errors (|| true / .catch() / try/except pass) so monitoring can never break your job. A dropped ping just shows as a gap in the timeline.
How do I rotate my token?
Generate a new token in Settings → API token and update CRONCANARY_TOKEN wherever your jobs read it. The old token stops working immediately.
Can one token cover many jobs?
Yes — one account token authenticates all your monitors. The job_id in each request selects which monitor the ping belongs to.