Introduction
Every Cronhq job is a schedule plus a webhook. On each tick that matches the schedule, Cronhq calls your endpoint. The promise is simple: your job runs when it should, exactly once, and you hear about it if it ever stops.
- Exactly-once across any number of workers — coordinated through a Postgres lock, so two workers can never fire the same scheduled run.
- Retries with backoff — configurable per job, with the terminal outcome and last error preserved in history.
- Alerts that aren't noise — one notification per failure breach, one on recovery, routed to email or Slack.
Quickstart
Create your first scheduled webhook in under a minute. First, sign up and copy your API key (it starts with chq_).
# Your key from the dashboard
export CRONHQ_KEY="chq_your_key_here"
# Create a job that POSTs your endpoint every 5 minutes
curl -X POST https://api.cronhq.xyz/v1/jobs \
-H "Authorization: Bearer $CRONHQ_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "nightly-rollup",
"schedule": "*/5 * * * *",
"webhook_url": "https://api.example.com/cron/rollup",
"http_method": "POST"
}'That's it — the job is live on the next tick. Use Run now on the job page (or POST /v1/jobs/:id/trigger) to fire it immediately, and watch executions stream in.
Authentication
Every request outside of GET /health and the sign-in endpoints requires a Bearer token:
Authorization: Bearer chq_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys are chq_ followed by 32 random characters. Cronhq stores only a SHA-256 hash of the key — it is shown once at creation and can never be retrieved again, so store it somewhere safe. Signing in from the dashboard mints a fresh key each time; manage or revoke them on the Keys page.
https://api.cronhq.xyz for the managed service, or your own host when self-hosting.Jobs
A job is defined by these fields:
| Field | Type | Default | Notes |
|---|---|---|---|
| name | string | required | 1–200 chars. |
| schedule | string | required | 5-field cron expression. |
| webhook_url | string | required | The URL Cronhq calls. Must be publicly reachable. |
| http_method | string | POST | GET · POST · PUT · PATCH · DELETE. |
| description | string | — | Optional human note. |
| headers | object | {} | String→string map sent with the request. |
| secret_headers | object | {} | Credential headers, encrypted at rest; values never returned. |
| body | string | — | Request body (any content type). |
| timeout_secs | int | 30 | 1–300. Per-attempt request timeout. |
| max_retries | int | 3 | 0–10 retries after first try. |
| retry_delay_secs | int | 60 | 1–3600. Delay between attempts. |
| timezone | string | UTC | IANA name, e.g. Europe/Paris. |
A body is sent with Content-Type: application/json unless you set your own. Create returns the full job, including its computed next_run_at.
# List your jobs
curl https://api.cronhq.xyz/v1/jobs -H "Authorization: Bearer $CRONHQ_KEY"
# Pause a job without deleting it
curl -X PATCH https://api.cronhq.xyz/v1/jobs/JOB_ID \
-H "Authorization: Bearer $CRONHQ_KEY" \
-H "Content-Type: application/json" \
-d '{ "is_active": false }'Schedules & cron
Schedules use standard 5-field cron syntax, evaluated in the job's timezone:
┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12)
│ │ │ │ ┌───────────── day of week (0–6, Sun=0)
│ │ │ │ │
* * * * *| Expression | Meaning | |
|---|---|---|
| */5 * * * * | Every 5 minutes | |
| 0 * * * * | Every hour, on the hour | |
| 0 9 * * 1-5 | 09:00 on weekdays | |
| 30 2 * * 0 | 02:30 every Sunday | |
| 0 0 1 * * | Midnight on the 1st of each month |
The dashboard's schedule builder renders any expression in plain English as you type, so you can confirm it before saving.
Retries & timeouts
Each run gets timeout_secs per attempt. A non-2xx response or a transport error is retried up to max_retries times, waiting retry_delay_secs between attempts. The outcome is classified as:
success— the endpoint returned 2xx.failed— non-2xx or transport error after all retries.timed_out— the timeout was hit on every attempt.
Exactly-once: before a run is dispatched, Cronhq claims it with a Postgres lock carrying an expires_at deadline and advances the job's next run time. If a worker dies mid-execution its lock expires and a peer takes over — but two workers can never fire the same scheduled slot.
POST /v1/jobs/test) to fire the webhook once and inspect the live response — nothing is saved.Executions
Every attempt is recorded. Fetch a job's history — newest first, paginated with page and page_size:
curl "https://api.cronhq.xyz/v1/jobs/JOB_ID/history?page=1&page_size=20" \
-H "Authorization: Bearer $CRONHQ_KEY"Each execution carries its status, attempt, http_status, duration_ms, and a truncated response_body or error_message. Free accounts retain 7 days of history, Developer 30, Pro 365.
Alerts
Configure email or Slack destinations and Cronhq notifies you when a job starts failing — or recovers. Alerts are deduplicated: a failure alert fires once, on the run that crosses the threshold within the trailing hour, and a recovery alert fires on the first success after a failing streak.
# Email me when jobs fail (and when they recover)
curl -X POST https://api.cronhq.xyz/v1/alerts \
-H "Authorization: Bearer $CRONHQ_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "email",
"destination": "oncall@example.com",
"on_failure": true,
"on_recovery": true
}'
# ...or post to a Slack incoming webhook
curl -X POST https://api.cronhq.xyz/v1/alerts \
-H "Authorization: Bearer $CRONHQ_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "slack", "destination": "https://hooks.slack.com/services/…" }'Signed webhooks
Every request Cronhq sends is signed so your endpoint can prove it genuinely came from us — not a spoofed caller who found your URL. Each job has its own signing_secret, shown once when you create the job (and re-issuable with rotate-signing-secret). Two headers ride along on every call:
| Header | Value |
|---|---|
| X-Cronhq-Timestamp | Unix seconds when the request was signed. |
| X-Cronhq-Signature | v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")> |
Recompute the HMAC over `${timestamp}.${rawBody}` with your secret and compare — constant-time — against the v1= digest. Reject stale timestamps to defeat replays:
const crypto = require("crypto");
// Express example. Use the raw request body — not a re-serialized object.
function verifyCronhq(req, signingSecret) {
const ts = req.header("X-Cronhq-Timestamp");
const sig = req.header("X-Cronhq-Signature"); // "v1=<hex>"
const expected =
"v1=" +
crypto
.createHmac("sha256", signingSecret)
.update(`${ts}.${req.rawBody}`)
.digest("hex");
// Reject anything older than 5 minutes to stop replay attacks.
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
return (
fresh &&
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
}Monitors (dead-man's switch)
A monitor is the inverse of a job: instead of Cronhq calling out on a schedule, your system calls in on a cadence. If a ping doesn't arrive within period_secs + grace_secs, the monitor flips to down and fires the same alerts as a failing job. Perfect for the work you can't see from here — backups, data pipelines, other people's crons.
# Create a monitor that expects a ping every hour (+5 min grace)
curl -X POST https://api.cronhq.xyz/v1/monitors \
-H "Authorization: Bearer $CRONHQ_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "nightly-backup", "period_secs": 3600, "grace_secs": 300 }'
# → { "ping_url": "https://api.cronhq.xyz/ping/<token>", "status": "new", ... }
# Then, at the end of your own job, check in (no auth needed):
curl https://api.cronhq.xyz/ping/<token>The token in the ping URL is the credential — anyone who can hit it can check the monitor in, so keep it out of public logs. A monitor stays new until its first ping, then rides up/down from there.
Errors & limits
Errors are JSON with a stable machine-readable code:
{ "error": "validation", "message": "invalid cron: ..." }| Status | error | When |
|---|---|---|
| 400 | bad_request | Malformed input the schema can't pin down. |
| 401 | unauthorized | Missing or invalid Bearer key. |
| 402 | job_limit_reached | Active-job cap hit for your tier. |
| 404 | not_found | No such resource (or not yours). |
| 409 | conflict | State conflict, e.g. revoking your own key. |
| 422 | validation | A field failed validation. |
| 500 | internal | Something on our side — never leaks details. |
A 402 includes current, limit, tier, and an upgrade_url so clients can deep-link to a plan change. Limits gate active jobs — pause one to free a slot.
API reference
All endpoints are under the base URL. JSON in, JSON out.
Jobs
/v1/jobsCreate a job/v1/jobsList jobs/v1/jobs/:idFetch one job/v1/jobs/:idUpdate a job/v1/jobs/:idDelete a job/v1/jobs/:id/triggerRun now/v1/jobs/testFire a webhook once, save nothing/v1/jobs/:id/historyExecution historyKeys
/v1/keysMint a key/v1/keysList keys/v1/keys/:idRevoke a keyAlerts
/v1/alertsAdd a destination/v1/alertsList destinations/v1/alerts/:idRemove a destinationAccount
/v1/statsUsage & tier/healthLiveness probe (no auth)Sign-in (no auth)
/v1/auth/signupEmail a magic link/v1/auth/verifyExchange a link token for a keySelf-hosting
Cronhq is MIT-licensed and ships as a single container. The image we run is the image you run — bring your own Postgres:
git clone https://github.com/cronhq/cronhq && cd cronhq
cp .env.example .env
docker compose up -d
# Mint your first key (the API's key routes are behind auth)
docker compose run --rm cronhq create-key --name owner --tier proThe scheduler, worker, and API run in one binary (cronhq serve) and coordinate purely through Postgres, so you scale horizontally just by running more replicas of the same image.
Ready to schedule your first job? Get an API key →