cronhq
Documentation

Cron jobs that actually run.

Cronhq is a REST API for scheduled webhooks with exactly-once execution, automatic retries, and alerting. JSON in, JSON out — no SDK, no agent, no daemon on your side.

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_).

bash
# 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:

http
Authorization: Bearer chq_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys 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.

Base URL: https://api.cronhq.xyz for the managed service, or your own host when self-hosting.

Jobs

A job is defined by these fields:

FieldTypeDefaultNotes
namestringrequired1–200 chars.
schedulestringrequired5-field cron expression.
webhook_urlstringrequiredThe URL Cronhq calls. Must be publicly reachable.
http_methodstringPOSTGET · POST · PUT · PATCH · DELETE.
descriptionstringOptional human note.
headersobject{}String→string map sent with the request.
secret_headersobject{}Credential headers, encrypted at rest; values never returned.
bodystringRequest body (any content type).
timeout_secsint301–300. Per-attempt request timeout.
max_retriesint30–10 retries after first try.
retry_delay_secsint601–3600. Delay between attempts.
timezonestringUTCIANA 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.

bash
# 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:

text
┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12)
│ │ │ │ ┌───────────── day of week (0–6, Sun=0)
│ │ │ │ │
* * * * *
ExpressionMeaning
*/5 * * * *Every 5 minutes
0 * * * *Every hour, on the hour
0 9 * * 1-509:00 on weekdays
30 2 * * 002: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.

Not sure your endpoint is wired up right? Use Send test request on the create form (or 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:

bash
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.

bash
# 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:

HeaderValue
X-Cronhq-TimestampUnix seconds when the request was signed.
X-Cronhq-Signaturev1=<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:

javascript
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.

bash
# 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:

json
{ "error": "validation", "message": "invalid cron: ..." }
StatuserrorWhen
400bad_requestMalformed input the schema can't pin down.
401unauthorizedMissing or invalid Bearer key.
402job_limit_reachedActive-job cap hit for your tier.
404not_foundNo such resource (or not yours).
409conflictState conflict, e.g. revoking your own key.
422validationA field failed validation.
500internalSomething 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

POST/v1/jobsCreate a job
GET/v1/jobsList jobs
GET/v1/jobs/:idFetch one job
PATCH/v1/jobs/:idUpdate a job
DELETE/v1/jobs/:idDelete a job
POST/v1/jobs/:id/triggerRun now
POST/v1/jobs/testFire a webhook once, save nothing
GET/v1/jobs/:id/historyExecution history

Keys

POST/v1/keysMint a key
GET/v1/keysList keys
DELETE/v1/keys/:idRevoke a key

Alerts

POST/v1/alertsAdd a destination
GET/v1/alertsList destinations
DELETE/v1/alerts/:idRemove a destination

Account

GET/v1/statsUsage & tier
GET/healthLiveness probe (no auth)

Sign-in (no auth)

POST/v1/auth/signupEmail a magic link
POST/v1/auth/verifyExchange a link token for a key

Self-hosting

Cronhq is MIT-licensed and ships as a single container. The image we run is the image you run — bring your own Postgres:

bash
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 pro

The 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 →