# cronpad cronpad is an open-source job scheduler built for reliability. You describe *when* (a cron expression, a Quartz expression, or plain English like `second tuesday of every month at 9am`) and *what* (an HTTP webhook, or a shell command on self-hosted installs). cronpad fires it on time, retries it when it fails, alerts you when something is wrong, and records how late every single run started. It ships as one Go binary with no other services to run: SQLite files on a local volume hold everything. Use the hosted service for free, or run it yourself. ## What makes it different - **Measured, not promised.** Every run records when it was scheduled, claimed, started and finished. "How late was my 09:00 job?" has an exact answer, and the SLA definition is public: [Delivery guarantees](concepts/delivery-guarantees.md). - **Schedules people can read.** Cron, Quartz (`L`, `W`, `#`), RFC 5545 RRULE, or natural language, all with a live preview of the next fire times. See [Schedules](schedules/index.md). - **Built to survive failure.** Two SQLite databases are the source of truth, the queue is a rebuildable SQLite index, and every failure mode has a documented recovery path. See [How cronpad works](concepts/how-cronpad-works.md). - **AI-native.** A built-in MCP server and an agent skill let AI assistants create, inspect and debug jobs. See [AI integration](ai/index.md). - **Free and open source.** No feature gates. The hosted service charges (later) only for larger workloads. ## Start here | I want to… | Go to | |---|---| | Schedule my first webhook in two minutes | [Get started on cronpad Cloud](get-started/cloud.md) | | Run cronpad on my own server | [Self-host cronpad](get-started/self-hosted.md) | | Understand the architecture | [How cronpad works](concepts/how-cronpad-works.md) | | Write a schedule | [Schedules](schedules/index.md) | | Receive and verify webhooks | [Webhooks](sinks/webhooks.md) | | Automate with code | [API reference](api/index.md) · [CLI](cli/index.md) | | Let an AI assistant manage jobs | [MCP server](ai/mcp-server.md) | | Configure every knob | [Configuration reference](self-hosting/configuration.md) | ## For AI agents These docs are written to be read by people and by agents. Every page has a one-paragraph summary at the top. Machine-friendly versions: - `/llms.txt`: index of every page with a one-line description. - `/llms-full.txt`: all documentation as a single Markdown file. - Append `.md` to any docs URL to get that page as raw Markdown. --- # Get started There are two ways to run cronpad. Both run the same scheduling engine; the differences are who operates it and which safety limits apply. | | cronpad Cloud | Self-hosted | |---|---|---| | Setup | Sign in with Google or GitHub | One container (or binary) and a data volume | | Cost | Free, with [generous limits](../platform/limits.md) | Free, unlimited | | Sinks | Webhooks | Webhooks and shell commands | | Webhooks to private networks (`10.x`, `localhost`) | Blocked | Allowed | | Who runs upgrades, backups, monitoring | Us | You | - [Get started on cronpad Cloud](cloud.md) - [Self-host cronpad](self-hosted.md) Not sure? Start on Cloud. Jobs are plain JSON, so you can export them and create them on a self-hosted instance later with the [API](../api/jobs.md). --- # Get started on cronpad Cloud In this tutorial you create a job that sends an HTTP request every five minutes, trigger it once by hand, and read its run history. It takes about two minutes. ## Before you start You need a URL that accepts `POST` requests. If you don't have one, use any request-inspection service, or a tiny server of your own. ## 1. Sign in Open the cronpad dashboard at [app.cronpad.com](https://app.cronpad.com) and sign in with Google or GitHub. A personal **workspace** is created for you. Workspaces hold your jobs, API keys, secrets and alert settings. ## 2. Create a job 1. Select **New job**. 2. **Name:** `heartbeat`. 3. **Schedule:** type `every 5 minutes`. The preview under the field shows the next fire times in your timezone. If the text can't be parsed, the problem is underlined with a suggestion. 4. **Timezone:** defaults to your browser's timezone. 5. **Sink:** choose **Webhook**, method `POST`, and paste your URL. 6. Select **Send test**. cronpad makes one real request and shows the status code and response body. Nothing is recorded as a run. 7. Select **Create**. The job page shows a **signing secret** (`whsec_…`) once. Copy it if you want to verify that requests really come from cronpad. See [Verify signatures](../sinks/webhooks.md#verify-signatures). ## 3. Trigger it now Select **Run now**. A run appears in the **Runs** table within a second, with its status, how late it started (`start lag`), how long the request took, and the number of attempts. Select the run to see each attempt's request summary, status code and response. Manual runs don't change the schedule. The next scheduled run still happens at its normal time. ## 4. Do the same from the command line Create an API key under **Settings → API keys**, then: ```sh export CRONPAD_URL=https://app.cronpad.com export CRONPAD_API_KEY=cp_… curl -sS "$CRONPAD_URL/api/v1/jobs" \ -H "Authorization: Bearer $CRONPAD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "heartbeat-api", "schedule": {"expr": "every 5 minutes", "timezone": "UTC"}, "sink": {"kind": "webhook", "config": {"url": "https://example.com/hook"}} }' ``` ## Next steps - Learn every schedule format: [Schedules](../schedules/index.md). - Make failures loud: [Alerts](../alerts/index.md). - Tune retries: [Retries](../retries-and-failures/retries.md). - Let your AI assistant manage jobs: [MCP server](../ai/mcp-server.md). --- # Self-host cronpad This tutorial runs cronpad on one machine with Docker Compose, configures GitHub sign-in, and creates a first job. For production hardening, read the [production checklist](../self-hosting/production-checklist.md) afterwards. ## Before you start - Docker with the Compose plugin. - A GitHub account (to create an OAuth app), or use development login for a local trial. ## 1. Get the Compose file ```sh mkdir cronpad && cd cronpad curl -fsSLO https://raw.githubusercontent.com/gowthamgts/cronpad/main/deploy/docker-compose.yml ``` It starts one container, `cronpad` (port 8080), with everything it stores in the `cronpad-data` volume. There is no database or queue server to run. ## 2. Configure sign-in For a quick local trial, enable development login (never use this on a server reachable by others): ```sh cat > .env <<'ENV' CRONPAD_AUTH__DEV_LOGIN=true ENV ``` For real use, create a GitHub OAuth app with callback URL `http://localhost:8080/auth/github/callback` and put its credentials in `.env`: ```sh cat > .env <<'ENV' CRONPAD_SERVER__PUBLIC_URL=http://localhost:8080 CRONPAD_AUTH__GITHUB__CLIENT_ID=Iv1.xxxxxxxx CRONPAD_AUTH__GITHUB__CLIENT_SECRET=xxxxxxxx ENV ``` Google works the same way. See [Authentication](../self-hosting/authentication.md). Create a persistent encryption key before saving jobs, including with development login, and tell Compose to pass `.env` into the server: ```sh printf 'CRONPAD_SECRETS__ENCRYPTION_KEY=%s\n' "$(openssl rand -base64 32)" >> .env cat > docker-compose.override.yml <<'YAML' services: cronpad: env_file: .env YAML ``` Keep that encryption key when restarting or upgrading. See [Secrets](../secrets/index.md) for rotation. ## 3. Start it ```sh docker compose up -d docker compose logs -f cronpad ``` Wait for the `server started` log line, then open `http://localhost:8080`. A new server opens on a setup page: sign in to create the first account, and you land on the dashboard at `/`. Sign-up then closes; see [First sign-in](../self-hosting/authentication.md#first-sign-in) to let others in. :::note cronpad refuses to start if no sign-in method is configured, so a misconfigured server is never left open. ::: ## 4. Schedule a job Create a webhook job in the dashboard at `/jobs/new`, using an HTTPS endpoint you control. The default Compose service disables the command sink unless you set `CRONPAD_SINKS__COMMAND__ENABLED=true`. Or create it with the CLI after making an API key in Settings: ```sh export CRONPAD_URL=http://localhost:8080 export CRONPAD_API_KEY=cp_… cronpad jobs create --name report --schedule 'every day at 2am' --tz UTC \ --url https://example.com/your-hook ``` To run a command job, set `CRONPAD_SINKS__COMMAND__ENABLED=true` (overriding the default Compose value), then install or mount an executable at `/usr/local/bin/backup.sh` inside the container. On that configured server, command creation is: ```sh cronpad jobs create --name backup --schedule 'every day at 2am' --tz UTC \ --command /usr/local/bin/backup.sh ``` `--arg` adds individual arguments without shell interpolation. On self-hosted installs, jobs can run **shell commands** inside the cronpad container in addition to webhooks. See [Commands](../sinks/commands.md). ## 5. Check health ```sh curl -s localhost:8080/readyz # {"dispatcher":"active","heartbeat":"…","queue":true,"sqlite_writable":true,"writer_backpressure":false} ``` ## Next steps - Back up your data continuously: [Backups with Litestream](../self-hosting/backups.md). - Read every setting: [Configuration reference](../self-hosting/configuration.md). - Scrape metrics: [Observability](../observability/index.md). - Before you rely on it: [Production checklist](../self-hosting/production-checklist.md). --- # Concepts Read these pages to understand how cronpad behaves under load and under failure, not just how to use it. - [How cronpad works](how-cronpad-works.md): the components inside the binary and how a job becomes an HTTP request. - [Jobs, runs and attempts](jobs-runs-attempts.md): the data model and every state a job or run can be in. - [Delivery guarantees](delivery-guarantees.md): at-least-once delivery, idempotency, what "on time" means, and when duplicates can happen. - [Scheduling engine](scheduling-engine.md): the SQLite queue, its atomic transactions and the claim loop, in detail. - [Storage](storage.md): why SQLite, what each of the three files holds, and how far one node scales. --- # How cronpad works cronpad is a single Go binary that serves the dashboard, the API, the MCP server, and also runs the scheduler. It keeps durable state in two SQLite files and a third, rebuildable SQLite file as the queue of what to run next. Nothing else needs to run. A single dispatcher goroutine pulls due work out of the queue and hands it to a pool of workers that deliver webhooks or run commands, recording every attempt. The public website and documentation are a separate deployment at [cronpad.com](https://cronpad.com). The hosted dashboard runs at [app.cronpad.com](https://app.cronpad.com/); self-hosted installations serve it at the root of their configured public URL. ## Components ``` ┌──────────────────────────── cronpad (one binary, one port) ─────────────────────────┐ Browser ─────▶ │ / dashboard │ API / CLI ───▶ │ /api/v1 REST API /auth Google, GitHub sign-in │ AI agents ───▶ │ /mcp MCP server │ │ │ │ claim loop ──claim──▶ queue: due, inflight (SQLite) ──▶ workers ──▶ webhook/command│ │ ▲ │ │ │ reconciler, reaper result writer, notifier ──▶ alerts └─────┬───────────────────────────────────────────────────────┬──────────────────────┘ │ │ cronpad.db (jobs, users, config) history.db (runs, attempts, audit) └─────────── Litestream ──▶ S3 / GCS / Azure / SFTP ─────┘ ``` | Component | What it does | |---|---| | HTTP server | Dashboard, REST API, MCP server, OAuth sign-in and health checks. Works even while the scheduler is starting. | | Claim loop | One goroutine. Atomically claims due occurrences from the queue and hands them to workers. Only runs while it holds the dispatcher lock. | | Workers | A pool (64 by default) that executes sinks, classifies results, schedules retries and records attempts. | | Result writer | Commits database writes as soon as it is free, grouping whatever arrives meanwhile (up to 500 commands) into one transaction, so the hot path never waits on many small transactions. | | Reaper | Returns occurrences whose worker stopped renewing its lease (crash, hang) to the queue. | | Reconciler | Makes sure the queue matches the databases: repairs lost entries, rebuilds a deleted or replaced queue file, handles missed runs after downtime. | | Notifier | Evaluates alert rules and sends email, Slack, Discord or webhook alerts from a durable outbox. | | Janitor | Deletes old history according to the [retention policy](../platform/data-retention.md) and maintains daily rollups. | ## The life of one run Take a job `nightly-report` scheduled `every day at 9am` in `Asia/Kolkata`. 1. **Create.** The API validates the schedule, computes the next fire time (09:00 IST = 03:30 UTC), saves the job in `cronpad.db` with `next_run_at`, and adds an entry to the queue's `due` table at 03:30 UTC. 2. **Claim.** At 03:30:00 the claim loop wakes (it sleeps exactly until the earliest due time), and one transaction moves the entry from `due` to `inflight` with a 60-second lease. 3. **Schedule the next one.** In durable mode, before delivering anything, the dispatcher computes tomorrow's 09:00, writes it to `cronpad.db` and adds it to `due`. A slow webhook can never delay tomorrow's run. 4. **Deliver.** A worker creates the run record (fast mode can start while its commit is pending; see [Delivery guarantees](delivery-guarantees.md#durable-and-fast-delivery)), sends the signed `POST`, and renews the lease while waiting. 5. **Record.** The response is classified as success, retryable or fatal. History commits before job counters, and both commit before acknowledging the occurrence. The attempt is stored; on a retryable failure, a retry entry is added to `due` in the same atomic step that removes the in-flight entry. 6. **Measure.** Start lag (03:30:00.006 − 03:30:00.000 = 6 ms), duration and end-to-end time go into metrics and the run record. If start lag exceeds the SLA threshold, the run is flagged and an alert can fire. ## Source of truth and index - **`cronpad.db` and `history.db` are the source of truth.** Jobs, their next fire time, users and history live there. Litestream backs them up continuously. - **The queue file is an index.** It holds only "what runs when" and "what is running now". It is never backed up: if it is lost, deleted or corrupt, cronpad rebuilds it from `cronpad.db` automatically. See [Scheduling engine](scheduling-engine.md#recovery). Read [Storage](storage.md) for why SQLite was chosen and its limits. ## One binary, one entry point There is no separate frontend to deploy. API-only mode is a setting (`[ui] dashboard = false`), not a different build. See [Editions](../platform/editions.md) for how the open-source and cloud editions differ. --- # Jobs, runs and attempts A **job** says what to do and when. Each time it's due, that is an **occurrence**, recorded as one **run**. Each try at delivering a run is an **attempt**. A run that fails twice and then succeeds is one run with three attempts. ## Workspaces Everything belongs to a **workspace**: jobs, API keys, secrets, alert channels and rules. Every user gets a personal workspace when they first sign in. API keys are bound to one workspace, so a key can never see another workspace's jobs. ## Jobs A job has: | Part | Example | Docs | |---|---|---| | Schedule + timezone | `every weekday at 9am`, `Europe/London` | [Schedules](../schedules/index.md) | | Sink | Webhook to `https://api.example.com/reports` | [Sinks](../sinks/index.md) | | Retry policy | 3 attempts, exponential backoff from 10 s | [Retries](../retries-and-failures/retries.md) | | Timeout | `30s` per attempt | | | Misfire and overlap policies | `fire_once`, `allow` | [Misfires and overlap](../schedules/misfires-and-overlap.md) | | Auto-disable threshold | 20 consecutive failed runs | [Auto-disable](../retries-and-failures/auto-disable.md) | | Tags | `["reports","billing"]` | Used by alert rules and filters | ### Job states | State | Meaning | How it gets there | How it leaves | |---|---|---|---| | `active` | Scheduled and firing | Created, resumed or re-enabled | Pause, auto-disable, schedule exhausted | | `paused` | Not firing; you paused it | `POST /jobs/{id}/pause` | `POST /jobs/{id}/resume` | | `disabled` | Not firing; cronpad stopped it after too many consecutive failures | [Auto-disable](../retries-and-failures/auto-disable.md) | `POST /jobs/{id}/enable` (resets the failure counter) | | `completed` | Schedule has no more occurrences (one-off job fired, `count` or `until` reached) | Automatic | Edit the schedule | Deleting a job removes it from scheduling immediately. Its run history stays until it ages out under the [retention policy](../platform/data-retention.md). Every edit increments the job's `version`. Occurrences already queued with an older version are dropped, so an edit takes effect from the next fire time, never with a mix of old and new settings. ## Runs A run is one occurrence: "this job, scheduled for this moment". Runs are created for scheduled fires, for **Run now** (`trigger = manual`) and for manual retries of a failed run (`trigger = retry_manual`). ### Run states ``` claim success scheduled ──────▶ running ─────────────────────▶ succeeded │ retryable failure, attempts left ├──────────────────────────▶ deferred ── backoff ──▶ running … │ fatal failure, or no attempts left └──────────────────────────▶ failed scheduled ── missed during downtime, policy says skip ──▶ skipped scheduled ── job paused, edited or deleted first ─────▶ cancelled ``` ### Run timing fields | Field | Meaning | |---|---| | `scheduled_at` | The moment the run was supposed to start. Never changes, even across retries. | | `claimed_at` | When the dispatcher took it from the queue. | | `started_at` | When the first attempt began. | | `finished_at` | When the final attempt ended. | | `start_lag_ms` | `started_at − scheduled_at`. The number cronpad's reliability is judged on. | | `e2e_ms` | `finished_at − scheduled_at`, including retries. | | `sla_breached` | `true` if `start_lag_ms` exceeded the SLA threshold. See [Delivery guarantees](delivery-guarantees.md#on-time). | ## Attempts An attempt records one delivery try: start and end time, duration, outcome (`success`, `retryable`, `fatal`, `timeout`), HTTP status or exit code, error message, a request summary (`POST https://api.example.com/reports (412 bytes)`) and a truncated response body. By default, response bodies are kept only for attempts that didn't succeed, and only for 7 days. See [Data retention](../platform/data-retention.md). ### Why runs and attempts are separate Reliability questions are about runs ("did the 09:00 run succeed, and was it on time?"). Debugging questions are about attempts ("what did the second try get back?"). Keeping them apart keeps run queries fast for dashboards and alerts, and lets the bulky attempt data expire sooner. --- # Delivery guarantees cronpad guarantees **at-least-once** delivery: every scheduled run is attempted, and in rare failure cases it may be delivered twice. It never silently drops a run while its database is intact. Every run records how late it started. The Cloud Pro on-time objective is 99.9% within **5 seconds** per calendar month, with the exclusions defined below. Each job can also set its own SLA threshold (5 seconds by default) to flag and alert on individual late runs. ## At least once A run is delivered more than once only in these situations: | Situation | Why it duplicates | |---|---| | cronpad stops (crash, `kill -9`, power loss) after your endpoint received the request but before cronpad recorded the result | cronpad can't know the request arrived, so the next process delivers it again. | | The queue file can't be written for longer than the lease (60 s) while a request is in flight (for example, a full disk) | The lease can't be renewed; the work is considered abandoned and is redelivered. | | A retry after a timeout | Your endpoint may have done the work but responded too slowly. The retry is a new attempt of the same run. | ### Making your endpoint idempotent Every request for a run carries the same identifier on every attempt and every redelivery: | Header | Value | Stable across | |---|---|---| | `Idempotency-Key` | Run ID, e.g. `run_01J9ZM1A…` | Retries and redeliveries | | `webhook-id` | Same run ID (Standard Webhooks) | Retries and redeliveries | | `Cronpad-Attempt` | `0`, `1`, `2`… | Changes per attempt | Store the run ID when you finish the work, and skip work you've already done for that ID. If your endpoint already supports `Idempotency-Key` (many payment and email APIs do), point cronpad at it directly. ## Durable and fast delivery `scheduler.delivery_mode = "durable"` is the default in both self-hosted and hosted mode. The run is recorded before the delivery starts, and the result is recorded before cronpad acknowledges it. Opt-in `"fast"` starts the first attempt as soon as the run is queued for recording, so delivery overlaps the database commits. If cronpad crashes in that short window, the receiver may already have acted on a request that was never recorded; after recovery the run is delivered again with the same `Idempotency-Key` and `webhook-id`. If the job was deleted in between, recovery records the run as `cancelled` with the reason `job deleted before recovery; delivery may have occurred` and does not redeliver. In-flight work survives the crash in the queue file, so recovery finds it on the next start. **Receivers must deduplicate with `Idempotency-Key` or `webhook-id` before you enable fast mode.** In fast mode the recorded start time of a replayed run is the replay's, since the original delivery's timing and response are unknown. Runs that are subject to a monthly quota, a concurrency cap or `skip_if_running` always wait for admission, even in fast mode, so limits and overlap policy hold. A slow endpoint never blocks the schedule. Shutdown drains in-flight runs and flushes pending records in both modes. ## Never silently dropped - Each active job's next fire time is stored in `cronpad.db` before cronpad relies on the queue for it. - If the queue entry for a job disappears for any reason, the reconciler notices that the job is overdue and re-queues it. If the whole queue file is deleted, emptied or replaced, cronpad detects the missing epoch and rebuilds the queue. Pending and in-progress runs are stored durably before the schedule advances, so a rebuild also restores work whose lease disappeared; a run invalidated by a job edit is not revived. - Runs missed while cronpad was down are handled by the job's [misfire policy](../schedules/misfires-and-overlap.md): fire once now (default), fire each, or skip. Skipped runs are still recorded with status `skipped`, so the gap is visible. ## On time Each run records `start_lag_ms`: the time between `scheduled_at` and the start of its first attempt. - A run **breaches its SLA** when `start_lag_ms` is greater than the job's SLA threshold (job setting `sla_threshold`, or the server default of `5s`). - Breaches are recorded on the run (`sla_breached: true`), counted in the `cronpad.runs.sla_breached` metric, and can trigger a `run.sla_breached` [alert](../alerts/index.md). - Retries don't count against start lag; they're measured by `e2e_ms`. Start lag includes timer slack and the durable database commits that record the start (a single job typically starts 2–4 ms after its scheduled time). Lag grows when more work is due at once than there are workers (for example thousands of jobs at exactly midnight); cronpad still runs everything, and the metrics show the lag honestly rather than hiding it. ### Cloud SLO and SLA A **service-level objective (SLO)** is a published performance target, without service credits. A **service-level agreement (SLA)** is a contractual commitment with defined service credits. [Contact us](mailto:contact@cronpad.com) for availability and terms. - **Cloud Free:** best effort. - **Cloud Pro:** at least **99.9%** of scheduled runs per UTC calendar month start on time, excluding announced maintenance windows and runs delayed by the workspace's own concurrency cap. There are no service credits. - **Cloud Enterprise:** a contractual on-time SLA with service credits on **dedicated single-tenant instances**. The exact target and credit terms are agreed with the customer. - No credit-bearing SLA is offered on shared infrastructure. **On time** means `start_lag ≤ 5s` for the first attempt. The start is the outbound request being sent or the command starting; endpoint response time is excluded. Retries are measured separately with `e2e_ms`. A job's `sla_threshold` remains its individual alert threshold, not a credit-bearing contract. Self-hosted timing depends on your hardware and configuration; benchmark figures are not Cloud commitments. ### Maintenance Announced maintenance windows are excluded from the monthly SLO calculation. Affected runs still fire late and are never silently lost. Deploys can pause scheduling for a few seconds. ### Visible admission exceptions Licensing, plan downgrades and billing problems never stop the scheduler. An exceeded Free-plan quota (or an optional Pro spend cap) records new occurrences as `skipped` with `quota_exceeded`; an operator suspension for abuse records skipped runs with `workspace_suspended`. These apply only to admission; runs that were admitted are delivered normally. Retries never consume monthly quota. ## Ordering - No ordering is guaranteed between different jobs that are due at the same moment. - For one job, the next occurrence fires on schedule even if the previous run is still retrying, unless you set `overlap_policy` to `skip_if_running`. See [Misfires and overlap](../schedules/misfires-and-overlap.md). ## What cronpad does not guarantee - **Exactly-once.** No scheduler can guarantee it over HTTP; use the idempotency key. - **Delivery to an endpoint that is down for longer than your retry policy allows.** After the last attempt the run is `failed`, you get an alert if you configured one, and after enough consecutive failures the job is [auto-disabled](../retries-and-failures/auto-disable.md). - **Uptime during a self-hosted deploy.** A restart pauses scheduling for a few seconds; runs due in that window start late (and are marked as SLA breaches) but are not lost. --- # Scheduling engine cronpad keeps every upcoming fire in a queue table (`due`) ordered by the time it should run. One goroutine, the claim loop, sleeps until the earliest entry is due, then runs a single transaction that moves due entries to a second table (`inflight`) with a lease expiry. Workers deliver the work, renew the lease while they're busy, and remove the entry (scheduling a retry in the same step if needed). A reaper returns expired leases, and a reconciler repairs anything the queue lost, using the databases as the source of truth. This page is the detailed reference. It's useful if you operate cronpad, want to reason about its failure modes, or want to contribute to it. ## The queue file The queue is its own SQLite file, separate from `cronpad.db` and `history.db`. By default it is named after the control database and sits beside it: `cronpad.db` gets `cronpad-queue.db`. Set `queue.path` to put it elsewhere. | Table | Row | Ordered by | |---|---|---| | `due` | occurrence ID, when it should run (Unix ms) | `(at, member)` | | `inflight` | occurrence ID, when its lease expires (Unix ms) | `(at, member)` | | `meta` | `lock:dispatcher` (token of the process allowed to dispatch, with an expiry), `epoch` (random value written at startup), `heartbeat` (last claim-loop tick) | — | The file is an index, not a record: everything in it can be rebuilt from `cronpad.db` and `history.db`. That's why it is never backed up, and why a damaged file is moved aside (`…-queue.db.corrupt-`) and recreated at startup instead of stopping the server. ### Why a separate file SQLite allows one writer per file. The queue takes two or three small writes per run, and `cronpad.db` already has a single writer shared by the API and the run recorder. Keeping the queue in its own file gives it its own write lock, and keeps the one file you must not lose small and quiet. ### Occurrence IDs Each entry is a self-describing string: ``` {job_id}|{scheduled_at_ms}|{attempt}|{job_version}|{trigger} job_01J9ZK3Q…|1794281400000|0|3|s ``` | Part | Meaning | |---|---| | `job_id` | The job. | | `scheduled_at_ms` | The logical fire time. It stays the same across retries, so lag is always measured from the original schedule. | | `attempt` | `0` for the first try, `1+` for retries. | | `job_version` | Incremented on every edit. Entries with an old version are dropped when they come up. | | `trigger` | `s` scheduled, `m` manual "run now", `r` manual retry. Only `s` entries with attempt `0` advance the schedule. | Because the ID is deterministic and is the table's primary key, adding the same occurrence twice is harmless: the second insert just updates its time. Every repair path relies on this: when in doubt, re-add. **Invariant:** every active job has exactly one attempt-0 scheduled entry for its next fire time, either in `due` or in `inflight`, plus any pending retries. The reconciler enforces this. ## The queue operations Each operation that changes the queue is one SQLite transaction that takes the write lock up front (`BEGIN IMMEDIATE`). Nothing else can write between its steps. That's what makes "read what's due, then move it" safe: two claimers can't take the same entry, and a crash halfway through rolls the whole step back. ### claim Moves up to `limit` due entries into `inflight` with a lease and returns them with their due times. ```sql -- in one immediate transaction DELETE FROM due WHERE member IN (SELECT member FROM due WHERE at <= :now ORDER BY at, member LIMIT :limit) RETURNING member, at; -- then, for each returned member: INSERT INTO inflight(member, at) VALUES(:member, :now + :lease) ON CONFLICT(member) DO UPDATE SET at = excluded.at; ``` The `(at, member)` index means the cost depends on how many entries are claimed, not on how many jobs exist. ### ack Finishes an entry and, in the same transaction, schedules its retry if there is one. ```sql DELETE FROM inflight WHERE member = :member; DELETE FROM due WHERE member = :member; -- in case the reaper re-queued it meanwhile -- only when the attempt failed and attempts remain: INSERT INTO due(member, at) VALUES(:retry_member, :retry_at) ON CONFLICT(member) DO UPDATE SET at = excluded.at; ``` There is never a moment when a failed run is neither in flight nor queued for retry. ### extend Workers call this every `lease_duration / 3` while a webhook or command is still running, so long calls don't look abandoned. ```sql UPDATE inflight SET at = :new_expiry WHERE member = :member; -- 1 row: still ours. -- 0 rows: if the reaper had put it back in due and nobody took it yet, move it -- back to inflight (reclaimed); otherwise it's lost: another worker has it, or -- it was already finished. ``` A worker that loses its lease can't take back a request it already sent. It finishes, records the attempt, logs a warning and counts `cronpad.leases.lost`. The run record deduplicates the result. ### reap Returns entries whose lease expired (the worker crashed or hung) to `due`, ready immediately. It's the same move as `claim`, in the other direction: delete expired rows from `inflight` and insert them into `due` with the current time. ### the dispatcher lock The lock is a row in `meta`. Acquiring it inserts the row, or takes it over only if the previous owner's expiry has passed. Renew and release only touch the row if it still holds this process's token and hasn't expired, so a process can never extend or release a lock it has already lost. The lock lives in the queue file, so a second cronpad started on the same data directory waits in standby instead of dispatching. Startup recovery also moves every orphaned in-flight entry back to `due` in bounded batches, without waiting for their lease to expire. ## Startup 1. Open the databases and the queue file, and start the HTTP server (the API works while scheduling starts). 2. Acquire the dispatcher lock. If another process holds it, wait in **standby** and retry every second. This prevents two processes from scheduling from the same data directory, for example during an overlapping deploy. 3. **Requeue everything in `inflight`.** Only one process dispatches, and it no longer holds the lock, so anything it left in flight is abandoned. Requeueing now avoids waiting up to a full lease after a crash. 4. Write a fresh `epoch` and run a full reconcile, including [misfire handling](../schedules/misfires-and-overlap.md). A new or replaced queue file is rebuilt here. 5. Start the reaper, reconciler, workers and claim loop. 6. Renew the lock every 5 s. If renewal fails, stop claiming immediately, let workers finish, and go back to step 2. On `SIGTERM`, cronpad stops claiming, waits up to `shutdown_grace` (30 s) for workers, flushes pending writes, releases the lock and exits. ## The claim loop ``` loop: sleep until: the earliest due time | a job was created/edited with an earlier time | shutdown if the lock isn't held: continue free := free worker slots if free == 0: count backpressure; sleep tick_interval; continue batch := claim(now, min(claim_batch, free), lease_duration) for each entry: parse it (malformed entries are acked, logged and counted — never fatal) if it's a scheduled first attempt: advance the job to its next occurrence hand it to a worker (never blocks: the batch was sized to free slots) if the batch was full: loop again immediately else: sleep until the next earliest due time (at most tick_interval) ``` Design choices: - **Claim only what workers can start now.** A claimed entry starts within milliseconds, so lease time measures real work, not queueing, and a backlog stays visible in `due` where metrics can see it. - **Sleep until the earliest due time.** No busy polling: claiming takes a timer wake-up plus one indexed query. Recording a start then takes a few database commits; the result writer commits as soon as it is free and groups whatever arrives meanwhile into the next transaction (`results_flush_interval = "0s"`, the default; `results_max_batch`, default 500). A single job firing once a second starts 2–4 ms after its scheduled time. At 200 jobs/s (12,000 runs/min) start lag measured 37 ms median / 54 ms p99 on 15 CPUs and 43 ms / 60 ms on two (median of three runs); at 500 jobs/s, 67 ms / 139 ms. The older fixed 5 ms flush window measured about 12 ms for the single job and 47 ms / 80 ms at 200 jobs/s; the 50 ms window before it, about 165 ms median. - **Advance independently of delivery.** The next occurrence is computed from the *scheduled* time (not "now", so schedules never drift) and queued before delivery in durable mode; opt-in fast mode overlaps these commits and delivery, so a slow endpoint never delays the next run. See [Durable and fast delivery](delivery-guarantees.md#durable-and-fast-delivery). ## Workers For each entry a worker: 1. Loads the job from cache. If the job is paused, deleted, or the entry's version is old, it drops the entry. 2. Creates the run record (unique per job, scheduled time and trigger). If that run already finished (a redelivery), it acks and stops. 3. Executes the sink with the job's timeout, renewing the lease in the background. 4. Classifies the result and calls `ack`, passing a retry entry when the failure is retryable and attempts remain. 5. Records the attempt and run status, updates the job's failure counter, emits events for alerting, and records metrics. Every log line written during a run carries `job_id`, `run_id`, `occ`, `attempt`, `workspace_id` and the trace ID. See [Logging](../observability/logging.md). ## Recovery | What happened | What cronpad does | Effect on your jobs | |---|---|---| | Worker crashed or hung | Lease stops being renewed → reaper returns the entry to `due` | Redelivered within `lease_duration` | | cronpad process crashed | In-flight entries survive in the queue file; the next process requeues them at startup | Redelivered within seconds of restart | | One queue entry lost | Reconciler's overdue check (every 10 s) finds an active job past its `next_run_at` with no entry, and re-adds it | Late by at most ~40 s, then counted in `cronpad.reconciler.repaired` | | Queue file deleted, emptied or corrupt | The `epoch` is missing → immediate full rebuild from the databases (a corrupt file is moved aside first) | Future runs fire on time | | Queue file can't be written (disk full, read-only volume) | Claim loop pauses; `/readyz` reports `queue: false` | Runs start late once it recovers; nothing lost | | Job edited while queued | Old-version entry is dropped when it comes up | New settings apply from the next fire | ### Why the overdue check is enough If a job's queue entry disappears, nothing advances the job's `next_run_at` in `cronpad.db`. So that job *must* eventually appear in the indexed query "active jobs whose `next_run_at` is more than `overdue_grace` in the past". The check costs nothing when everything is healthy, and its cost grows with the number of broken jobs, not the total number of jobs. A full sweep of every job still runs at startup, when queue data loss is detected, and every 6 hours. ## Alternatives we didn't use | Option | Why not | |---|---| | Polling `jobs.next_run_at` in `cronpad.db` | Works, but puts a hot query and every lease update on the control database's single writer, competing with the API and run recording. A separate queue file keeps that traffic off it. | | The queue tables inside `cronpad.db` | Would let an ack and the job update commit together, but the same writer contention applies, and the critical file would churn constantly. | | An external queue server (Redis, which earlier releases used) | Another service to install, size and secure, for no measurable gain on one node: the SQLite queue matched it within a few milliseconds at every load we tested. Removed. | Readiness reports database writability, queue availability, dispatcher state (`starting`, `standby`, `active`, `stopped`) and heartbeat freshness. A full worker pool or a blocked result writer pauses claiming; a backwards clock jump is logged for operators. ## Tuning | Setting | Default | Raise it when | Lower it when | |---|---|---|---| | `workers.count` | 64 | Many slow endpoints; `cronpad.dispatcher.backpressure` is rising | Memory-constrained | | `scheduler.claim_batch` | 500 | Very large bursts at the same second | — | | `scheduler.lease_duration` | 60s | Endpoints routinely take close to a minute | You want faster redelivery after crashes | | `scheduler.reconcile_interval` | 10s | — | You want faster repair of lost entries | | `scheduler.tick_interval` | 100ms | — | Rarely needed; the loop already wakes at the exact due time | All settings: [Configuration reference](../self-hosting/configuration.md#scheduler). ## Workspace admission Before handing off a claim, the dispatcher checks workspace suspension, the monthly run quota and the workspace concurrency cap. Suspension and a blocking quota record a skipped occurrence with a `skip_reason` while the schedule keeps advancing. A concurrency cap holds the occurrence in the due queue for 250ms and tries again; it is not skipped and the schedule does not advance twice. Both delivery modes use these checks. Automatic retries do not use another quota slot. Lateness caused by a cap is visible in history but excluded from SLA breach counts. Misfires and overlap skips also record a `skip_reason` (`misfire`, `previous_run_in_progress`) rather than a delivery error. See [Limits](../platform/limits.md). --- # Storage cronpad stores everything durable in two SQLite files and keeps its delivery queue in a third, rebuildable one. Nothing else needs to run. `cronpad.db` is small and holds everything needed to schedule. `history.db` is large and holds runs, attempts and logs of what happened. Litestream streams those two to object storage continuously; the queue is never backed up. One node comfortably handles on the order of a thousand runs per second; the first real limit is high availability, not throughput. ## What lives where | Store | Contents | If it's lost | |---|---|---| | `cronpad.db` (control) | Users, workspaces, API keys, secrets, jobs (including each job's next fire time), alert channels and rules | Restore from Litestream. Restores in seconds because it stays small. | | `history.db` (history) | Runs, attempts, audit log, alert outbox, daily statistics | Restore from Litestream in the background, or start empty. Scheduling doesn't depend on it. | | `cronpad-queue.db` (queue) | The `due` and `inflight` queues, the dispatcher lock | Nothing to restore. cronpad rebuilds the queue from `cronpad.db` automatically at startup. | ## Why SQLite - **Self-hosting stays simple.** No database or queue server to install, tune, upgrade or back up separately. - **The workload suits it.** Writes are small, mostly appends, and cronpad groups them into as few transactions as possible (whatever arrived while the previous commit ran, up to 500 commands) through a single writer connection per file, which is exactly how SQLite is fastest. - **Reads are local.** Dashboard and API queries are indexed lookups on a local file, with no network hop. - **Backups are continuous.** Litestream ships every change to S3-compatible storage within about a second and supports point-in-time restore. See [Backups](../self-hosting/backups.md). ## Why three files SQLite allows one writer per file, so each file is its own write queue. - **Control and history.** Recording a burst of run results never delays a job edit, and the file you need to resume scheduling after a disaster stays tiny. History can be pruned, restored later, or discarded without touching jobs. - **The queue.** It takes two or three small writes per run (claim, next occurrence, acknowledgement). In its own file those never compete with the API or run recording, and the critical control file doesn't churn. Because it can always be rebuilt, it is left out of backups entirely. ## How far it scales These are planning numbers. The `just loadtest` tool measures your hardware; the cronpad repository records real results. | Dimension | Planning figure | What it depends on | |---|---|---| | Sustained runs per second | ~1,000 (≈86 M per day) | Disk fsync latency. Local NVMe is far faster than network block storage. | | Writes per run | ~4 rows, batched | Run start, run finish, attempt, job counters | | History size | ~300–500 bytes per run without response bodies | [Retention](../platform/data-retention.md) keeps it bounded | | Jobs | Millions | Each active job costs one queue row (~100 bytes) and one job row | ### Measured local workload These runs measure the authenticated API → queue → worker → local HTTP echo pipeline on an Apple M5 Pro with 15 logical CPUs, 24 GiB RAM and a local SSD, with the default group-commit result writer. Each run created jobs firing once per second for 60 seconds. Every delivery succeeded with zero duplicates; schedules extended beyond the measurement so the numbers measure sustained delivery rather than a simultaneous job-completion burst. Loadgen uses 256 workers; the pipeline fixture below uses 1024. The shipped worker-count default is 64, so these are tuned load scenarios. | Mode | CPU budget | Runs/s | Runs/min | Start lag p50 / p95 / p99 | |---|---|---:|---:|---| | Durable | Default (15) | 1 | 60 | 2 / 3 / 4 ms | | Durable | Default (15) | 200 | 12,000 | 37 / 49 / 54 ms | | Durable | `GOMAXPROCS=2` | 200 | 12,000 | 43 / 58 / 60 ms | | Fast | Default (15) | 200 | 12,000 | 28 / 37 / 39 ms | | Fast | `GOMAXPROCS=2` | 200 | 12,000 | 21 / 35 / 40 ms | | Durable | Default (15) | 500 | 30,000 | 67 / 115 / 139 ms | The 200 runs/s rows are the median of three runs each, with each percentile taken separately. Single runs varied by up to ±10 ms; one two-CPU durable run reached a p99 of 89 ms. SQLite + WAL growth across all three files was about 11 MB per 12,000 runs without response bodies; it is not a per-row storage estimate. A two-CPU limit on this machine does not reproduce a two-vCPU cloud VM's disk or contention, and network block storage adds latency to every commit. The separate pipeline integration test delivered 1,000 jobs/s for 30 seconds: all 30,000 runs, zero missed or duplicate occurrences, start lag p99 161–192 ms in durable mode and 56–141 ms in fast mode across its quota profiles and two separate runs. That isolates the delivery pipeline rather than job-creation/API throughput. Earlier measurements, taken while the queue was still a Redis server and results were flushed on a fixed ticker, are kept below for the comparisons they made. The fixed 5 ms window measured 47 / 64 / 80 ms at 200 jobs/s, and a single job waited about 12 ms; the 50 ms window before it measured about 165 ms median. Opt-in fast mode measured 13 / 24 / 29 ms by default and 19 / 36 / 43 ms with two CPUs at 200 jobs/s. Daily quota checks use durable constant-time counters. Read [Delivery guarantees](delivery-guarantees.md#durable-and-fast-delivery) before enabling fast mode. In an earlier 50ms-flush comparison, with 50,000 expired rows prefilled and the janitor running every second during the same 60-second workload, throughput remained 200/s with no missed or duplicate runs. Lag p50/p95/p99 was **106/162/165 ms** by default and **95/164/167 ms** with two CPUs. All 50,000 old rows were rolled up and pruned; net SQLite + WAL size fell by 3,050,872 and 3,026,632 bytes respectively. Maintenance flushes pending results before its own short transaction, which can also reduce batch wait time. The automated comparison allows at most 100 ms percentile noise and 5% throughput noise; both comparisons passed. ### Higher load The same machine (durable mode, default CPUs, 30-second runs) at higher offered load. "Aligned" means every job is due at the same instant each second, the worst case; "spread" uses `-stagger`. | Runs/min | Delivered | Duplicates | Start lag p50 / p95 / p99, aligned | p99, spread | |---:|---|---:|---|---:| | 30,000 | all 15,000 | 0 | 64 / 106 / 111 ms | | | 60,000 | all 30,000 | 0 | 115 / 200 / 211 ms | 2 ms | | 120,000 | all 60,000 | 0 | 208 / 368 / 393 ms | 2 ms | The aligned 120,000/min run briefly reported dispatcher backpressure: cronpad slowed admission and delivered everything late rather than dropping work. In spread runs, loadgen's run count included a few runs that fired after its measurement window closed, so their count check is not a duplicate measurement. On the earlier Redis queue the aligned p99 was 151, 320 and 599 ms at the same loads. `go run ./tools/loadgen -stagger` spreads job anchors across each second; `-complete-at-end` includes a simultaneous schedule-completion burst. Use `-janitor -prefill 50000` to overlap retention with delivery, and `-dir PATH` to retain the scratch databases for inspection. Use `-flush 5ms` or `-flush 50ms` to compare a fixed flush window with the default group commit, and `-delivery-mode fast` for opt-in delivery measurements. ### The limits, in the order you'd reach them 1. **No high availability.** One process owns the database files. A restart or deploy pauses scheduling for a few seconds. Runs due in that window start late; none are lost. 2. **Restore time.** Litestream restore time grows with file size. The control/history split keeps the critical file small. 3. **Single-writer throughput**, beyond a few thousand runs per second. When you need zero-downtime deploys or much more throughput, the path is moving the control database to Postgres and running several cronpad nodes; the dispatcher lock already ensures only one of them schedules at a time. Data access goes through a single storage interface, so this is an addition, not a rewrite. ## Rules that follow from this - Run exactly **one** cronpad process per database file. On Kubernetes: one replica, `strategy: Recreate`, a `ReadWriteOnce` volume. - Keep the database on local or block storage. **Never on NFS or SMB**: SQLite file locking is unreliable on network filesystems. - Keep the queue file on the same volume as the databases (the default). Don't back it up or restore it; cronpad rebuilds it. See the [production checklist](../self-hosting/production-checklist.md). --- # Schedules A schedule tells cronpad when a job fires. You can write it in plain English (`every weekday at 9am`), standard cron (`0 9 * * 1-5`), Quartz cron (`0 0 9 ? * MON-FRI`), an iCalendar RRULE (`FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9`), or as a single moment (`2026-12-25T07:00:00+05:30`). Every schedule is evaluated in the job's timezone. ## Formats | Format | Example | Best for | Page | |---|---|---|---| | Natural language | `second tuesday of every month at 9am` | Readable schedules | [Natural language](natural-language.md) | | Cron | `*/15 * * * *` | Familiar, compact | [Cron and Quartz](cron-and-quartz.md) | | Quartz | `0 0 18 L * ?` | "Last day", "nearest weekday", "third Friday" | [Cron and Quartz](cron-and-quartz.md) | | RRULE | `FREQ=MONTHLY;BYDAY=-1FR;BYHOUR=17` | Calendar-style rules, generated by code | [RRULE and one-off](rrule-and-one-off.md) | | One-off | `in 20 minutes`, `2026-12-25T07:00:00Z` | Run once | [RRULE and one-off](rrule-and-one-off.md) | ## Automatic detection By default (`"kind": "auto"`) cronpad works out the format, in this order: 1. An RFC 3339 timestamp → **one-off**. 2. Five or six space-separated cron fields, or an `@` descriptor (`@daily`) → **cron**. 3. Contains `?`, `L`, `W` or `#`, or has seven fields → **Quartz**. 4. Contains `FREQ=` → **RRULE**. 5. Anything else → **natural language**. Set `kind` explicitly (`cron`, `quartz`, `rrule`, `natural`, `once`) when you want an error instead of a guess. ## Validate and preview before saving The public [cron schedule validator](/cron) previews any supported expression without signing in. Its expression and timezone are stored in the URL so you can share a preview, and the preview updates as you type. ```schedule every weekday at 9am ``` Every place you can enter a schedule shows what it means and when it will fire next: ```sh curl -sS https://app.cronpad.com/api/v1/schedules/preview \ -H "Content-Type: application/json" \ -d '{"expr": "2nd tuesday of every month at 9am", "timezone": "Asia/Kolkata", "count": 3}' ``` ```json { "kind_detected": "natural", "canonical": "FREQ=MONTHLY;BYDAY=2TU;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "description": "At 09:00 on the second Tuesday of every month", "timezone": "Asia/Kolkata", "next": ["2026-11-10T03:30:00Z", "2026-12-08T03:30:00Z", "2027-01-12T03:30:00Z"] } ``` The same check works offline with the CLI: `cronpad schedule next "2nd tuesday of every month at 9am" --tz Asia/Kolkata -n 3`. See [Schedules API](../api/schedules.md) and [CLI](../cli/index.md). When a schedule can't be parsed, the error says where and suggests a fix: ```json { "valid": false, "errors": [{"kind": "natural", "position": 6, "message": "unknown word \"tuesdy\"", "suggestion": "every tuesday at 9am"}] } ``` ## Bounds Any schedule can be limited with these job fields: | Field | Meaning | |---|---| | `starts_at` | Don't fire before this time. | | `ends_at` | Don't fire after this time; the job becomes `completed`. | | `count` | Fire at most this many times; the job becomes `completed`. | ## Related - [Timezones and DST](timezones-and-dst.md): what happens at 02:30 on the night clocks change. - [Misfires and overlap](misfires-and-overlap.md): what happens to runs missed during downtime, and to runs that would overlap. - [Limits](../platform/limits.md): the minimum interval between runs on cronpad Cloud. --- # Natural language schedules cronpad understands schedules written in plain English, like `every 15 minutes`, `every weekday at 6:30pm` or `second tuesday of every month at 9am`. The parser is deterministic (no AI involved): the same text always means the same thing. It is converted to an [RRULE](rrule-and-one-off.md), which is stored as the job's canonical schedule and shown back to you in plain English. ## Examples ### Intervals | You write | Fires | |---|---| | `every 30 seconds` | Every 30 s (subject to the [minimum interval](../platform/limits.md)) | | `every 15 minutes` | Every 15 minutes | | `every hour`, `hourly`, `every 1 hour` | Every hour | | `every 6 hours` | Every 6 hours | | `every 10 minutes for 2 hours` | Every 10 minutes, stopping 2 hours after the start | ### Days and times | You write | Fires | |---|---| | `every day at 9am`, `daily at 09:00` | 09:00 every day | | `every day at 9am and 6pm` | 09:00 and 18:00 every day | | `every weekday at 6:30pm` | 18:30 Monday–Friday | | `every weekend at 10am` | 10:00 Saturday and Sunday | | `every monday and thursday at 10:00` | 10:00 on Mondays and Thursdays | | `every 2 weeks on friday at noon` | 12:00 every other Friday | | `every hour between 9am and 5pm on weekdays` | 09:00, 10:00 … 17:00 Monday–Friday | ### Days of the month | You write | Fires | |---|---| | `second tuesday of every month at 9am` | 09:00 on the 2nd Tuesday | | `last friday of the month at 5pm` | 17:00 on the last Friday | | `last day of every month at 23:59` | 23:59 on the 28th–31st, whichever is last | | `first weekday of the month` | 00:00 on the first Monday–Friday | | `on the 1st and 15th of every month at 8am` | 08:00 on the 1st and 15th | | `every 3 months on the 1st`, `quarterly` | The 1st of every third month | | `every year on march 31 at 10am` | 10:00 every 31 March | ### Limits | You write | Effect | |---|---| | `every day at 9am 10 times` | Fires 10 times, then the job is `completed` | | `every monday at 8am until 2027-01-01` | Stops after 1 January 2027 | ### One-off | You write | Fires once | |---|---| | `in 20 minutes` | 20 minutes after you save | | `tomorrow at 8am` | 08:00 tomorrow in the job's timezone | | `at 2026-12-25 07:00` | That moment in the job's timezone | ## Grammar Words are case-insensitive. Clauses after the frequency may appear in any order. Filler words (`the`, `of`, `on`, `at`, `each`, `and`, commas) are accepted wherever they read naturally. ``` schedule := oneshot | recurring oneshot := "in" duration | "at"? datetime | relday "at" time recurring := frequency dayclause? timeclause? window? bound? frequency := "every" [N] unit | hourly | daily | weekly | monthly | quarterly | yearly unit := second(s) | minute(s) | hour(s) | day(s) | week(s) | month(s) | year(s) dayclause := weekday-list | ordinal weekday ["of" "every"? "month"] | ordinal "day" | monthday-list | "weekday" | "weekend" timeclause := "at" time ("and" time)* window := "between" time "and" time | "from" time "to" time bound := N "times" | "until" date | "for" duration ordinal := first | 1st | second | 2nd | third | 3rd | fourth | 4th | fifth | 5th | last | second to last time := 9am | 9:30am | 09:30 | 21:00 | noon | midnight relday := today | tomorrow | weekday-name ``` ## Defaults - No time given for a day-based schedule → `00:00` in the job's timezone. - `every N days/weeks/months` counts from the job's start time (`starts_at`, or when you created it). - A month that doesn't have the requested day (the 31st in April, the 5th Friday) is skipped, as in RRULE. Use `last day of every month` for month-end. ## When parsing fails The error points at the word it didn't understand and, when it can, suggests a correction (`tuesdy` → `tuesday`). If a schedule is too unusual for the grammar, write it as [cron, Quartz](cron-and-quartz.md) or [RRULE](rrule-and-one-off.md). ## Parsing limits and canonical form Expressions are limited to 4096 UTF-8 bytes, recurrence intervals to 1,000,000 units, and occurrence counts to 100,000. These parser bounds keep invalid input and finite-count checks bounded. Relative durations accept positive decimal numbers with seconds, minutes, hours, days, or weeks; a day is 24 elapsed hours. Time windows include their endpoints and must not end before their start on the same day. Multiple times with different minute/second values, and partial-hour windows, compile to a union of RRULE lines so they do not introduce unintended cross-products of hours and minutes. --- # Cron and Quartz expressions cronpad accepts standard Unix cron (five fields, optionally a sixth for seconds, plus `@daily`-style descriptors) and Quartz cron (six or seven fields with the special characters `?`, `L`, `W` and `#`). Both are evaluated in the job's timezone. ## Standard cron ``` ┌──────────── second (optional, 0–59) │ ┌────────── minute (0–59) │ │ ┌──────── hour (0–23) │ │ │ ┌────── day of month (1–31) │ │ │ │ ┌──── month (1–12 or JAN–DEC) │ │ │ │ │ ┌── day of week (0–6 or SUN–SAT; 0 = Sunday) │ │ │ │ │ │ * * * * * * ``` | Syntax | Meaning | Example | |---|---|---| | `*` | Every value | `* * * * *` every minute | | `,` | List | `0 9,18 * * *` at 09:00 and 18:00 | | `-` | Range | `0 9 * * 1-5` 09:00 Monday–Friday | | `/` | Step | `*/15 * * * *` every 15 minutes | | Names | Months and weekdays | `0 0 * JAN,JUL MON` | ### Examples | Expression | Fires | |---|---| | `*/5 * * * *` | Every 5 minutes | | `0 * * * *` | At the start of every hour | | `30 2 * * *` | 02:30 every day | | `0 9 * * 1-5` | 09:00 on weekdays | | `0 0 1 * *` | Midnight on the 1st of every month | | `*/30 * * * * *` | Every 30 seconds (six fields: seconds first) | ### Descriptors | Descriptor | Equivalent | |---|---| | `@yearly`, `@annually` | `0 0 1 1 *` | | `@monthly` | `0 0 1 * *` | | `@weekly` | `0 0 * * 0` | | `@daily`, `@midnight` | `0 0 * * *` | | `@hourly` | `0 * * * *` | | `@every 90m` | Every 90 minutes, measured as a fixed duration (not wall-clock aligned) | :::caution When both day-of-month and day-of-week are restricted, classic cron fires when **either** matches: `0 9 13 * FRI` fires on every 13th *and* every Friday. For "Friday the 13th", use natural language or RRULE. ::: ## Quartz cron Quartz expressions always include seconds and may include a year: ``` seconds minutes hours day-of-month month day-of-week [year] ``` Differences from standard cron: - Day of week is `1–7` with **1 = Sunday** (names `SUN–SAT` also work and are clearer). - Exactly one of day-of-month and day-of-week must be `?` ("no specific value"), unless both fields are `*`. - Extra special characters: | Character | Field | Meaning | Example | |---|---|---|---| | `?` | day-of-month, day-of-week | No specific value | `0 0 9 ? * MON` | | `L` | day-of-month | Last day of the month | `0 0 18 L * ?` | | `L-n` | day-of-month | n days before the last day | `0 0 9 L-3 * ?` | | `W` | day-of-month | Nearest weekday to the given day | `0 0 9 15W * ?` | | `LW` | day-of-month | Last weekday of the month | `0 0 17 LW * ?` | | `nL` | day-of-week | Last given weekday of the month | `0 0 17 ? * 6L` (last Friday) | | `n#k` | day-of-week | k-th given weekday of the month | `0 0 9 ? * 6#3` (third Friday) | ### Examples | Expression | Fires | |---|---| | `0 0 18 L * ?` | 18:00 on the last day of every month | | `0 0 9 ? * 3#2` | 09:00 on the second Tuesday | | `0 0 9 15W * ?` | 09:00 on the weekday nearest the 15th | | `0 0 17 LW * ?` | 17:00 on the last weekday of the month | | `0 30 10 ? * MON-FRI 2027` | 10:30 on weekdays, during 2027 only | cronpad searches at most five years ahead for the next match. An expression that never matches within five years (for example `0 0 0 30 FEB ?`) is rejected when you save it. ## How cronpad tells them apart A six-field expression without `?`, `L`, `W` or `#` is standard cron with seconds. Seven fields, or any of those characters, means Quartz. Set `"kind": "cron"` or `"kind": "quartz"` to be explicit. --- # RRULE and one-off schedules An RRULE is the recurrence format from the iCalendar standard (RFC 5545), used by calendar apps. It's the most expressive format cronpad supports, and every natural-language schedule is stored as one. A one-off schedule fires exactly once and then marks the job `completed`. ## RRULE Write the rule without the `RRULE:` prefix. The start (`DTSTART`) is the job's `starts_at`, or its creation time, in the job's timezone. | Rule | Fires | |---|---| | `FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0` | 09:00 every day | | `FREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=7;BYMINUTE=30` | 07:30 Monday, Wednesday, Friday | | `FREQ=WEEKLY;INTERVAL=2;BYDAY=FR;BYHOUR=12` | 12:00 every other Friday | | `FREQ=MONTHLY;BYDAY=2TU;BYHOUR=9;BYMINUTE=0` | 09:00 on the second Tuesday | | `FREQ=MONTHLY;BYDAY=-1FR;BYHOUR=17;BYMINUTE=0` | 17:00 on the last Friday | | `FREQ=MONTHLY;BYMONTHDAY=-1;BYHOUR=23;BYMINUTE=59` | 23:59 on the last day of the month | | `FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1` | The last weekday of the month | | `FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13` | Every Friday the 13th | | `FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=31;BYHOUR=10` | 10:00 every 31 March | | `FREQ=HOURLY;BYHOUR=9,10,11,12,13,14,15,16,17;BYDAY=MO,TU,WE,TH,FR` | Hourly 09:00–17:00 on weekdays | | `FREQ=DAILY;COUNT=10` | Daily, 10 times | | `FREQ=DAILY;UNTIL=20270101T000000Z` | Daily until 1 January 2027 | Supported parts: `FREQ` (`SECONDLY` to `YEARLY`), `INTERVAL`, `COUNT`, `UNTIL`, `BYSECOND`, `BYMINUTE`, `BYHOUR`, `BYDAY`, `BYMONTHDAY`, `BYYEARDAY`, `BYWEEKNO`, `BYMONTH`, `BYSETPOS`, `WKST`. `FREQ` is required. Expressions are limited to 4096 UTF-8 bytes, `INTERVAL` to 1,000,000, and `COUNT` to 100,000. RRULE `COUNT` includes `DTSTART` when it matches; querying strictly after that instant omits that already-counted occurrence. A monthly rule requesting the 31st skips months without a 31st, and a yearly February 29 rule skips non-leap years, including century years such as 2100. ## One-off Use an RFC 3339 timestamp, or natural language: | Schedule | Fires once at | |---|---| | `2026-12-25T07:00:00+05:30` | That exact instant | | `in 20 minutes` | 20 minutes after the job is saved | | `tomorrow at 8am` | 08:00 tomorrow in the job's timezone | | `at 2026-12-25 07:00` | That wall-clock time in the job's timezone | A one-off time in the past is rejected when you save the job. After it fires (including retries), the job's state becomes `completed`; its run stays in history. --- # Timezones and daylight saving time Every job has an IANA timezone (`Europe/London`, `America/New_York`, `Asia/Kolkata`). Wall-clock schedules like `every day at 9am` fire at 09:00 local time all year, even as the UTC offset changes. When daylight saving time creates a missing hour, cronpad fires at the first valid moment after it; when it creates a repeated hour, cronpad fires once. ## Choosing a timezone - The dashboard defaults to your browser's timezone. The API defaults to `UTC`. - Use IANA names, not abbreviations: `America/Los_Angeles`, not `PST`. - Times in API responses are always UTC (RFC 3339 with `Z`). The dashboard shows both the job's timezone and yours. ## Daylight saving rules ### Clocks go forward (a time doesn't exist) In `America/New_York` on 8 March 2026, clocks jump from 02:00 to 03:00. A job at `every day at 2:30am` would fire at a time that doesn't exist that night. **cronpad fires at 03:00**, the first valid instant after the gap. It never silently skips a day. ### Clocks go back (a time happens twice) On 1 November 2026, 01:00–02:00 happens twice in `America/New_York`. A job at `every day at 1:30am` fires **once**, at the first 01:30 (EDT). ### Fixed intervals ignore DST Interval schedules measure real elapsed time: `every 1 hour`, `every 15 minutes`, `@every 90m`. They fire exactly every hour across a DST change, so the local clock time of each run shifts by an hour. | Schedule | Across a DST change | |---|---| | `every day at 9am`, `0 9 * * *` | Stays at 09:00 local time | | `@hourly`, `0 * * * *` | On the hour, local time. The missing hour has no run; the repeated hour runs once. | | `every 1 hour`, `@every 1h` | Exactly every 60 minutes of real time | | `every 24 hours` | Exactly every 24 h of real time, so it drifts from 09:00 to 10:00 (or 08:00) local | Tip: use `every day at …` rather than `every 24 hours` when you mean a time of day. ## Timezones without DST Zones like `Asia/Kolkata` or `UTC` have no transitions; none of the above applies. ## Timezone database cronpad embeds the IANA timezone database in its binary, so results don't depend on the host system. Upgrading cronpad picks up timezone rule changes. --- # Misfires and overlap A **misfire** is a run that should have fired while cronpad couldn't fire it, usually because the server was down. The job's `misfire_policy` decides whether to run it late, run every missed one, or skip them. **Overlap** is when a new run is due while the previous run of the same job is still running or retrying; `overlap_policy` decides whether both proceed. ## Misfire policy When cronpad starts, it checks every active job whose next fire time is in the past by more than the SLA threshold. | `misfire_policy` | Behavior | Use it for | |---|---|---| | `fire_once` (default) | Run once now for the most recent missed occurrence. Older missed ones are recorded as `skipped`. | Most jobs: "make sure it ran at least once" (reports, syncs, cleanups) | | `fire_all` | Run every missed occurrence, oldest first, up to 10 (`scheduler.misfire_fire_all_cap`); the rest are `skipped`. | Jobs where each occurrence matters (per-hour billing aggregation) | | `skip` | Run none of them; record them as `skipped`; wait for the next future occurrence. | Jobs that are useless when late (a "good morning" notification at 15:00) | Example: a job runs `every hour`; cronpad is down from 10:05 to 13:20. | Policy | What happens at 13:20 | |---|---| | `fire_once` | One run for 13:00 starts now; 11:00 and 12:00 are `skipped`. Next run 14:00. | | `fire_all` | Runs for 11:00, 12:00 and 13:00 start now, in order. Next run 14:00. | | `skip` | 11:00, 12:00, 13:00 are `skipped`. Next run 14:00. | Skipped runs appear in the run history so gaps are visible, and each late run is marked as an SLA breach. :::note A run that is late because cronpad is busy (more work due than workers) is not a misfire. It still runs, and its lag is recorded. ::: ## Overlap policy | `overlap_policy` | Behavior | |---|---| | `allow` (default) | The new run starts on time, even if the previous run is still running or waiting to retry. | | `skip_if_running` | If the previous run hasn't reached a final state (`succeeded` or `failed`), the new run is recorded as `skipped` with reason `previous_run_in_progress`. | Use `skip_if_running` for jobs that must not run concurrently with themselves, such as a long data export that would conflict with itself. --- # Sinks A sink is the action a job performs when it fires. cronpad has two: **webhooks**, which send an HTTP request to a URL you choose, and **commands**, which run a program on the cronpad server (self-hosted only). Each attempt's result is classified as success, retryable failure or fatal failure, which drives [retries](../retries-and-failures/retries.md) and [alerts](../alerts/index.md). | Sink | Available on | Page | |---|---|---| | Webhook | Cloud and self-hosted | [Webhooks](webhooks.md) | | Command | Self-hosted only (disabled in `mode = "hosted"`) | [Commands](commands.md) | ## Outcomes | Outcome | Meaning | What happens next | |---|---|---| | `success` | The sink reported success (2xx, or exit code 0 by default) | Run `succeeded` | | `retryable` | A failure that might succeed later (5xx, 429, network error) | Retry with backoff, if attempts remain | | `timeout` | The attempt exceeded the job's `timeout` | Treated as retryable | | `fatal` | A failure that won't fix itself (most 4xx, bad command) | Run `failed` immediately, no retries | ## Templates Sink configs can include placeholders that are filled in at execution time: | Placeholder | Value | |---|---| | `{{run.id}}` | Run ID, e.g. `run_01J9ZM1A…` | | `{{run.scheduled_at}}` | Scheduled time, RFC 3339 UTC | | `{{job.id}}`, `{{job.name}}` | The job | | `{{attempt}}` | Attempt number (0 for the first try) | | `{{secret.NAME}}` | A workspace [secret](../secrets/index.md). Never stored in run history or logs. | ## Why there is no MCP sink cronpad doesn't call MCP tools directly. MCP is designed for an AI application talking to tools interactively, with session setup and user-approved OAuth tokens that expire, which doesn't suit unattended jobs, and it has no idempotency conventions. Anything you'd call on a schedule can be reached with a webhook. For the other direction, letting AI assistants manage cronpad, see the [MCP server](../ai/mcp-server.md). --- # Webhooks A webhook job sends an HTTP request to your URL every time it fires. Requests are signed using the [Standard Webhooks](https://www.standardwebhooks.com/) specification, carry an idempotency key that stays the same across retries, and are classified by status code: 2xx succeeds, 5xx/408/429 and network errors are retried, other 4xx fail immediately. ## Configuration ```json { "kind": "webhook", "config": { "url": "https://api.example.com/jobs/nightly-report", "method": "POST", "headers": {"Authorization": "Bearer {{secret.REPORTS_TOKEN}}"}, "body": "{\"report\": \"daily\", \"run\": \"{{run.id}}\"}", "success_codes": "2xx", "retry_on": ["5xx", 408, 429, "network", "timeout"], "follow_redirects": false } } ``` | Field | Default | Description | |---|---|---| | `url` | required | `http` or `https` URL. On cronpad Cloud it must resolve to a public address. | | `method` | `POST` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. | | `headers` | `{}` | Extra headers (up to 50). Values may use [templates](index.md#templates). | | `body` | standard payload | Request body as a string. Omit it to send the standard JSON payload below. | | `success_codes` | `"2xx"` | `"2xx"` or a list of codes, e.g. `[200, 202]`. | | `retry_on` | `["5xx", 408, 429, "network", "timeout"]` | Which failures are retryable. Status classes, codes, `"network"` and `"timeout"`. | | `follow_redirects` | `false` | Follow up to 5 redirects; each hop is re-checked against network rules. | Transport, signing, idempotency, tracing and `Cronpad-*` headers are reserved and cannot be replaced through `headers`. Custom headers are removed when a redirect changes hosts. The job's `timeout` (default `30s`, maximum `120s`) applies to each attempt. ## The request ``` POST /jobs/nightly-report HTTP/1.1 Host: api.example.com Content-Type: application/json User-Agent: cronpad/1.0.0 Idempotency-Key: run_01J9ZM1A… webhook-id: run_01J9ZM1A… webhook-timestamp: 1794281400 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= Cronpad-Attempt: 1 Cronpad-Job-Id: job_01J9ZK3Q… Cronpad-Scheduled-At: 2026-11-10T03:30:00Z {"run_id":"run_01J9ZM1A…","job_id":"job_01J9ZK3Q…","job_name":"nightly-report", "scheduled_at":"2026-11-10T03:30:00Z","fired_at":"2026-11-10T03:30:00.112Z","attempt":1} ``` | Header | Meaning | |---|---| | `Idempotency-Key`, `webhook-id` | The run ID. Identical on every retry and redelivery of this run; use it to deduplicate. See [Delivery guarantees](../concepts/delivery-guarantees.md). | | `webhook-timestamp` | Unix seconds when this attempt was sent. | | `webhook-signature` | `v1,`; may contain several space-separated signatures during secret rotation. | | `Cronpad-Attempt` | `0` for the first try, then `1`, `2`… | | `Cronpad-Job-Id`, `Cronpad-Scheduled-At` | Which job and which occurrence this is. | ## Idempotency with fast delivery To use opt-in `scheduler.delivery_mode = "fast"`, your receiver must persist and deduplicate the `Idempotency-Key` (or identical `webhook-id`) when applying its side effect. The key stays stable on crash replay in fast mode even if cronpad's first history write never committed. Record the key atomically with the work; a request can arrive again after cronpad crashes before recording the response. See [Durable and fast delivery](../concepts/delivery-guarantees.md#durable-and-fast-delivery). ## Responses | Response | Outcome | |---|---| | Matches `success_codes` | success | | Matches `retry_on` (by default 5xx, 408, 429) | retryable | | Any other status (e.g. 400, 401, 404) | fatal: no retries | | Connection refused, DNS failure, TLS error | retryable (`"network"`) | | No response within the job's timeout | retryable (`"timeout"`) | On `429` or `503`, cronpad honors a `Retry-After` header (seconds or HTTP date), capped at the retry policy's `max_interval`. cronpad reads at most 64 KiB of the response body and stores it with the attempt (for non-successful attempts by default; see [Data retention](../platform/data-retention.md)). ## Verify signatures Each job has a signing secret (`whsec_…`), shown once when you create the job. Rotate it with `POST /api/v1/jobs/{id}/rotate-signing-secret`; for 24 hours afterwards both the old and the new secret sign each request, so you can deploy the new one without dropping requests. The signed content is `{webhook-id}.{webhook-timestamp}.{raw body}`, signed with HMAC-SHA256 using the base64-decoded part of the secret after `whsec_`. Always verify against the **raw** request body, before parsing JSON, and reject timestamps more than five minutes old to block replays. You can also use any official Standard Webhooks library instead of the code below. ### Go ```go package webhook import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "net/http" "strconv" "strings" "time" ) func Verify(secret string, h http.Header, body []byte) error { key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return err } id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp") sec, err := strconv.ParseInt(ts, 10, 64) if err != nil { return errors.New("bad timestamp") } if d := time.Since(time.Unix(sec, 0)); d > 5*time.Minute || d < -5*time.Minute { return errors.New("timestamp outside tolerance") } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, sig := range strings.Fields(h.Get("webhook-signature")) { if v, ok := strings.CutPrefix(sig, "v1,"); ok && hmac.Equal([]byte(v), []byte(expected)) { return nil } } return errors.New("invalid signature") } ``` ### Node.js ```js import crypto from "node:crypto"; // rawBody must be the exact bytes received (e.g. express.raw({ type: "*/*" })). export function verify(secret, headers, rawBody) { const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const id = headers["webhook-id"]; const ts = headers["webhook-timestamp"]; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("timestamp outside tolerance"); const expected = crypto .createHmac("sha256", key) .update(`${id}.${ts}.`) .update(rawBody) .digest("base64"); const ok = (headers["webhook-signature"] || "").split(" ").some((part) => { const [version, sig] = part.split(","); return ( version === "v1" && sig?.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ); }); if (!ok) throw new Error("invalid signature"); } ``` ### Python ```python import base64 import hashlib import hmac import time def verify(secret: str, headers: dict, body: bytes) -> None: key = base64.b64decode(secret.removeprefix("whsec_")) msg_id = headers["webhook-id"] ts = headers["webhook-timestamp"] if abs(time.time() - int(ts)) > 300: raise ValueError("timestamp outside tolerance") signed = f"{msg_id}.{ts}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for part in headers.get("webhook-signature", "").split(" "): version, _, sig = part.partition(",") if version == "v1" and hmac.compare_digest(sig, expected): return raise ValueError("invalid signature") ``` ## Network rules On cronpad Cloud (and any server with `mode = "hosted"`), cronpad resolves the hostname and refuses to connect to loopback, private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), link-local (including `169.254.169.254`), carrier-grade NAT, IPv6 unique-local and link-local, multicast and unspecified addresses. The check happens after DNS resolution, so a public name that resolves to a private address is also blocked. Only ports 80 and 443 are allowed. Self-hosted servers allow private addresses by default (`sinks.webhook.allow_private_networks = true`), so you can call services on your own network. ## Test before saving **Send test** in the dashboard, or `POST /api/v1/jobs/{id}/test`, makes one real request and returns the outcome, status code and response body without recording a run. It uses the same signing, headers and network rules as a real run. --- # Commands A command job runs a program on the cronpad server when it fires. It's available only on self-hosted servers (`mode = "selfhosted"`, the default) and can be turned off or restricted to an allowlist. cronpad captures the exit code and output, kills the whole process group on timeout, and retries based on exit codes you choose. :::caution Commands run with the permissions of the cronpad process, inside its container if you use Docker. Anyone who can create jobs in a workspace can run commands. Restrict them with `sinks.command.allowed_commands`, or disable them with `sinks.command.enabled = false`. ::: ## Configuration ```json { "kind": "command", "config": { "command": "/usr/local/bin/backup.sh", "args": ["--full", "--target", "s3://backups/{{run.scheduled_at}}"], "env": {"AWS_SECRET_ACCESS_KEY": "{{secret.AWS_SECRET}}"}, "workdir": "/srv", "shell": false, "success_exit_codes": [0], "retry_exit_codes": [75] } } ``` | Field | Default | Description | |---|---|---| | `command` | required | Path of the executable, or a command line when `shell` is `true`. | | `args` | `[]` | Arguments, passed directly (no shell quoting issues). | | `env` | `{}` | Extra environment variables. Values may use [templates](index.md#templates). | | `workdir` | process working dir | Working directory. | | `shell` | `false` | Run `command` through `sinks.command.shell` (default `/bin/sh -c`), allowing pipes and redirects. | | `success_exit_codes` | `[0]` | Exit codes that mean success. | | `retry_exit_codes` | `[]` | Exit codes that are retryable. Any other non-success code is fatal. Timeouts are always retryable. | The job's `timeout` (default `60s`) applies to each attempt. On timeout, cronpad kills the command's entire process group, so child processes don't linger. ## Environment The command does **not** inherit cronpad's environment (which contains its own secrets). It receives: - `PATH`, `HOME`, `LANG` and any variables listed in `sinks.command.env_passthrough`; - `CRONPAD_RUN_ID`, `CRONPAD_JOB_ID`, `CRONPAD_SCHEDULED_AT`, `CRONPAD_ATTEMPT`; - the job's `env`. ## Output Standard output and standard error are captured together, up to 64 KiB (`sinks.command.max_output_bytes`), and stored as the attempt's response body. ## Restricting commands ```toml [sinks.command] enabled = true allowed_commands = ["/usr/local/bin/backup.sh", "/usr/local/bin/report"] ``` When `allowed_commands` is non-empty, jobs can only use those executables and `shell = true` is rejected. ## Running commands in Docker cronpad publishes one image, built on Alpine (`/bin/sh`, `curl`, `ca-certificates`). Commands run inside the cronpad container. Build your own image `FROM` the cronpad image that adds your scripts and the tools they need, or run cronpad as a binary on the host. --- # Retries and failures cronpad handles failure at three levels. A failed **attempt** is retried with backoff when the failure looks temporary. A **run** fails when it runs out of attempts or hits a failure that won't fix itself. A **job** whose runs fail too many times in a row is automatically disabled so it stops hammering a broken endpoint, and you are told about it. | Level | Controlled by | Page | |---|---|---| | Attempt → retry | `retry` policy on the job | [Retries](retries.md) | | Run → failed | `max_attempts`, sink outcome | [Retries](retries.md) | | Job → disabled | `auto_disable_threshold` | [Auto-disable](auto-disable.md) | | Telling you | Alert rules | [Alerts](../alerts/index.md) | --- # Retries When an attempt fails with a retryable outcome (a 5xx response, a timeout, a network error), cronpad waits and tries again, up to `max_attempts` tries in total. Waits grow exponentially by default and include random jitter so that many failing jobs don't retry in lockstep. A fatal outcome (such as a 404) ends the run immediately. ## Retry policy ```json "retry": { "max_attempts": 3, "backoff": "exponential", "initial_interval": "10s", "max_interval": "10m", "multiplier": 2.0, "jitter": 0.2 } ``` | Field | Default | Description | |---|---|---| | `max_attempts` | `3` | Total tries **including the first**. `1` disables retries. Capped at 10 on cronpad Cloud. | | `backoff` | `exponential` | `exponential` or `fixed`. | | `initial_interval` | `10s` | Wait before the first retry (and every retry with `fixed`). | | `max_interval` | `10m` | Upper bound for any single wait, including `Retry-After` values. | | `multiplier` | `2.0` | Growth factor for `exponential`. | | `jitter` | `0.2` | Each wait is randomized by ±20%. `0` disables jitter. | Server-wide defaults live in [`[retry.defaults]`](../self-hosting/configuration.md#retrydefaults). ## Wait times Retry *n* waits `min(initial_interval × multiplier^(n−1), max_interval)`, then ±jitter, with the final wait capped at `max_interval`. With `max_attempts: 6` and the other defaults: | Attempt | Waits before it (before jitter) | Elapsed since first attempt | |---|---|---| | 0 (first) | — | 0 | | 1 | 10 s | ~10 s | | 2 | 20 s | ~30 s | | 3 | 40 s | ~1 m 10 s | | 4 | 80 s | ~2 m 30 s | | 5 | 160 s | ~5 m 10 s | If the endpoint sends `Retry-After` with a 429 or 503, cronpad waits that long instead (capped at `max_interval`). ## What gets retried Whether an outcome is retryable depends on the sink: - Webhooks: `retry_on`, by default 5xx, 408, 429, network errors and timeouts. See [Webhooks](../sinks/webhooks.md#responses). - Commands: `retry_exit_codes` and timeouts. See [Commands](../sinks/commands.md). ## While a run is retrying - The run's status is `deferred`, with `next_attempt_at` set. - Retries never delay the job's next scheduled run, unless the job uses `overlap_policy: skip_if_running` ([Misfires and overlap](../schedules/misfires-and-overlap.md)). - Retries are durable: they're queued in the queue file and tracked in the database, so a restart doesn't lose them. - Every attempt is listed on the run's page with its outcome and response. ## Retrying by hand A run that ended `failed` can be retried with **Retry** on the run page or `POST /api/v1/runs/{id}/retry`. This creates a new run with `trigger = retry_manual` for the same scheduled time and a new run ID (and therefore a new idempotency key). Repeated manual-retry requests for that occurrence coalesce into the same retry run; an exhausted manual retry returns `409 conflict`. --- # Auto-disable If a job's runs fail many times in a row (20 by default), cronpad sets the job to `disabled`, stops firing it, and emails the workspace owners. This stops a broken job from sending thousands of requests to an endpoint that's gone, and it keeps the hosted service from being used to flood third parties. Re-enabling the job resets the count. ## How it counts - The counter `consecutive_failures` goes up by one for each **run** that ends `failed` (after all its retries), not for each attempt. - Any successful run resets it to zero. - Skipped and cancelled runs don't change it. So with `max_attempts: 3` and a threshold of 20, the job is disabled after 20 failed runs (up to 60 attempts). ## Threshold | Where | Setting | Default | |---|---|---| | Per job | `auto_disable_threshold` | Server default | | Server | `auto_disable.consecutive_failures` | `20` | | Server | `auto_disable.enabled` | `true` | On cronpad Cloud, a job's threshold can be lower than the server default but not higher. On self-hosted servers you can raise it per job, or switch the feature off entirely with `auto_disable.enabled = false`. ## When a job is disabled - `state` becomes `disabled` and `disabled_reason` is set, e.g. `consecutive_failures:20`. - Queued occurrences are removed; nothing more fires. - A `job.auto_disabled` event is emitted. It is always emailed to workspace owners (when email is configured), and any [alert rule](../alerts/rules.md) that includes it fires too. - The audit log records the change with actor `system`. - The dashboard shows a banner on the job with the last error. ## Re-enabling Fix the cause, then select **Re-enable** in the dashboard or call: ```sh curl -X POST "$CRONPAD_URL/api/v1/jobs/$JOB_ID/enable" -H "Authorization: Bearer $CRONPAD_API_KEY" ``` The failure counter resets to zero and the job resumes from its next future occurrence. Occurrences missed while disabled are not replayed. --- # Alerts Alerts tell you when something needs attention: a run failed, a run is retrying, a run started late, a job was disabled, or a failing job recovered. You create **channels** (where alerts go: email, Slack, Discord, or any webhook) and **rules** (which events, for which jobs, go to which channels). Alerts are written to a durable outbox in the same transaction as the run's result, so they aren't lost if cronpad restarts, and cooldowns stop a flapping job from flooding you. - [Channels](channels.md): email, Slack, Discord, generic webhook. - [Rules](rules.md): events, job selectors, conditions, cooldowns. ## Events | Event | Fires when | |---|---| | `run.failed` | A run ends `failed` (no attempts left, or a fatal error). | | `run.deferred` | An attempt failed and a retry is scheduled. | | `run.timeout` | An attempt timed out. | | `run.sla_breached` | A run started later than its SLA threshold. | | `run.succeeded` | A run succeeded. Opt-in; mostly useful for testing. | | `job.recovered` | A job's first successful run after one or more failed runs. | | `job.auto_disabled` | A job was [auto-disabled](../retries-and-failures/auto-disable.md). | | `job.completed` | A job's schedule ran out of occurrences. | | `system.lease_expired` | A run in this workspace had to be redelivered because a worker stopped responding. | | `workspace.quota_warning` | Monthly usage reaches 80%; owners are emailed once per month when SMTP is configured. | | `workspace.quota_exceeded` | Monthly usage reaches 100%; owners are emailed once per month when SMTP is configured. | | `system.degraded` | A self-hosted server encounters a dispatcher or queue failure. Available to self-hosted workspace operators. | ## A good starting setup 1. A channel for your team's Slack channel. 2. A rule: all jobs, events `run.failed`, `job.auto_disabled`, `job.recovered`, cooldown 15 minutes. 3. For critical jobs (tag them `critical`): a second rule with `run.sla_breached` and `run.deferred`, condition `min_attempt: 2`. Quota notices reach owners independently of rules and bypass ordinary email quotas. Workspace daily alert email caps suppress excess ordinary alerts; the next successful email includes the suppressed count. Suspended workspaces produce no outbound alerts. --- # Alert channels A channel is a destination for alerts. cronpad supports email and webhooks; webhook channels can format their body for Slack or Discord incoming webhooks, or send a generic signed JSON payload for your own systems. Every channel has a **Send test** button (`POST /api/v1/alert-channels/{id}/test`). ## Email ```json {"kind": "email", "name": "On-call", "config": {"to": ["oncall@example.com"]}} ``` - Verification links expire after 24 hours. Use **Resend verification** (`POST /api/v1/alert-channels/{id}/verify`) if a link expires. Changing the recipient list requires every address to verify again. - Every address must be verified: cronpad emails a confirmation link, and the channel only sends after it's clicked. This prevents cronpad from being used to email people who didn't ask for it. - Remote SMTP servers must support STARTTLS; loopback SMTP relays may use plaintext. - Self-hosted servers need SMTP settings in [`[alerts.email]`](../self-hosting/configuration.md#alertsemail). ## Slack and Discord Create an incoming webhook in Slack or Discord, then: ```json {"kind": "webhook", "name": "#ops", "config": {"url": "https://hooks.slack.com/services/T000/B000/XXXX", "format": "slack"}} ``` Use `"format": "discord"` for Discord webhook URLs. Messages include the event, job name, run time, attempts, last error and a link to the run in the dashboard. ## Generic webhook ```json {"kind": "webhook", "name": "PagerDuty bridge", "config": {"url": "https://alerts.example.com/cronpad", "format": "generic"}} ``` cronpad sends a `POST` with this body: ```json { "event": "run.failed", "occurred_at": "2026-11-10T03:31:12Z", "workspace_id": "ws_01J9X…", "job": {"id": "job_01J9ZK3Q…", "name": "nightly-report", "tags": ["reports"]}, "run": { "id": "run_01J9ZM1A…", "scheduled_at": "2026-11-10T03:30:00Z", "status": "failed", "attempts": 3, "last_error": "HTTP 503: upstream unavailable" }, "suppressed_count": 0, "url": "https://app.cronpad.com/runs/run_01J9ZM1A…" } ``` Requests are signed exactly like job webhooks (Standard Webhooks headers), with a per-channel signing secret shown when you create the channel. See [Verify signatures](../sinks/webhooks.md#verify-signatures). ## Delivery - Deliveries use a stable `webhook-id` across retries. Receivers should deduplicate this ID: a restart after delivery but before recording success can repeat a request. - Alerts are sent from a durable outbox and retried with backoff up to 8 times if the channel is down. - `suppressed_count` tells you how many identical alerts were held back by the rule's cooldown since the last one was sent. --- # Alert rules A rule connects events to channels. It selects jobs (all, a list, or by tag), lists the events it cares about, optionally adds conditions, and has a cooldown so a job failing every minute produces one alert per cooldown period, not hundreds. ## Example ```json { "name": "Critical jobs", "job_selector": {"tags": ["critical"]}, "events": ["run.failed", "run.sla_breached", "job.auto_disabled", "job.recovered"], "conditions": {"min_consecutive": 2}, "channel_ids": ["ch_01J9…", "ch_01JA…"], "cooldown": "15m", "enabled": true } ``` ## Fields | Field | Description | |---|---| | `job_selector` | `{"all": true}`, `{"job_ids": ["job_…"]}` or `{"tags": ["critical"]}` (any of the tags). | | `events` | One or more [event types](index.md#events). | | `conditions.min_consecutive` | For `run.failed`: only alert once this many runs in a row have failed. | | `conditions.min_attempt` | For `run.deferred`: only alert from this attempt number onwards. | | `channel_ids` | Where to send. | | `cooldown` | Minimum time between alerts for the same rule, job and event. Default `15m`. `job.recovered` and `job.auto_disabled` always bypass cooldown. | | `enabled` | Turn a rule off without deleting it. | ## How cooldown works The first matching event is queued for delivery. A queued alert reserves its cooldown immediately, so a slow or unavailable channel cannot accumulate a burst of identical pending alerts. Identical events (same rule, job and event type) within the cooldown are recorded as suppressed. The next alert sent after the cooldown includes `suppressed_count`, so you know how many you missed. --- # Secrets Secrets are encrypted values, such as API tokens or passwords, that you store once per workspace and reference from job configs as `{{secret.NAME}}`. The value is filled in only at the moment a request is sent or a command runs. It is never returned by the API, never shown in the dashboard, and never written to run history or logs. ## Why use them Without secrets, a token would sit in plain text inside every job that uses it. It would be visible to anyone who can read jobs, copied into exports, and painful to rotate. With secrets you store it once, reference it everywhere, and rotate it with a single update. ## Create or update a secret ```sh curl -X PUT "$CRONPAD_URL/api/v1/secrets/REPORTS_TOKEN" \ -H "Authorization: Bearer $CRONPAD_API_KEY" \ -H "Content-Type: application/json" \ -d '{"value": "sk_live_…"}' ``` Names are `UPPER_SNAKE_CASE`, up to 64 characters. Values are up to 8 KiB. ## Use it ```json "headers": {"Authorization": "Bearer {{secret.REPORTS_TOKEN}}"} ``` Secrets work anywhere [templates](../sinks/index.md#templates) do: webhook URL, headers and body; command arguments and environment. Saving a job that references a secret that doesn't exist fails with a validation error. ## List and delete - `GET /api/v1/secrets` lists names and timestamps, never values. - `DELETE /api/v1/secrets/{name}` deletes a secret. Jobs that still reference it fail their next run with a fatal error, `secret REPORTS_TOKEN not found`. ## How they're protected - Encrypted with AES-256-GCM using the server's `secrets.encryption_key`. - Write-only through the API and dashboard. - Redacted from attempts, logs and traces. - Self-hosted: set the key from an environment variable (`CRONPAD_SECRETS__ENCRYPTION_KEY`), and keep a copy somewhere safe. Without it, stored secrets can't be decrypted, including from backups. Rotate with `cronpad secrets rekey --new-key ` while the server is stopped; restart with the new key. Rekeying also covers webhook signing secrets and idempotency responses. Existing plaintext signing-secret and idempotency rows are encrypted atomically on startup when the key is configured. --- # AI integration cronpad is built to be operated by AI assistants as well as people. The built-in **MCP server** gives any MCP-capable assistant tools to validate schedules, create and manage jobs, and debug failing runs. The **agent skill** teaches an agent the recommended workflow. The docs themselves are published as `llms.txt` and raw Markdown so agents can read them. | Capability | Page | |---|---| | Manage jobs from an AI assistant | [MCP server](mcp-server.md) | | Teach an agent how to use cronpad well | [Agent skill](agent-skill.md) | | Scriptable CLI with JSON output | [CLI](../cli/index.md) | | Docs for agents | [`/llms.txt`](https://cronpad.com/llms.txt), [`/llms-full.txt`](https://cronpad.com/llms-full.txt), any docs page + `.md` | :::note Official SDKs are coming soon, so apps and agents can create and manage jobs in a few lines. Until then, use the [MCP server](mcp-server.md) or the [REST API](../api/index.md). ::: ## Scheduling an AI agent To run an agent on a schedule ("summarize yesterday's tickets every morning"), give the agent an HTTP trigger and point a webhook job at it. You get cronpad's retries, idempotency key, alerts and lag measurement for free. --- # MCP server cronpad includes a Model Context Protocol (MCP) server at `/mcp`. Connect an MCP-capable assistant with an API key and it can validate and preview schedules, create, pause, trigger and delete jobs, and read run history to debug failures. Everything goes through the same permission checks as the REST API, scoped to the API key's workspace. ## Connect ### Over HTTP (recommended) Create an API key (**Settings → API keys**), then add the server to your assistant's MCP configuration. Most clients accept this shape: ```json { "mcpServers": { "cronpad": { "type": "http", "url": "https://app.cronpad.com/mcp", "headers": {"Authorization": "Bearer cp_…"} } } } ``` Replace the URL with your server's `public_url` + `mcp.path` (default `/mcp`) when self-hosting. ### Over stdio For clients that only launch local processes, the cronpad binary can act as a stdio bridge to a remote server: ```json { "mcpServers": { "cronpad": { "command": "cronpad", "args": ["mcp", "--url", "https://app.cronpad.com"], "env": {"CRONPAD_API_KEY": "cp_…"} } } } ``` For a custom endpoint path, add `--path /custom-mcp` to the stdio arguments. The bridge also accepts `--api-key`; environment variables are recommended to keep credentials out of process arguments. MCP never returns signing secrets. ## Tools | Tool | Purpose | Changes data | |---|---|---| | `validate_schedule` | Check an expression; returns the detected kind, canonical form, English description and errors with suggestions | No | | `preview_schedule` | Next N fire times for an expression in a timezone | No | | `list_jobs` | Jobs, filterable by state, tag and text | No | | `get_job` | One job with its next fire times and 24-hour stats | No | | `create_job` | Create a job. Its description tells the model to preview the schedule and confirm with the user first. | Yes | | `update_job` | Change any job field | Yes | | `pause_job`, `resume_job` | Stop or restart firing | Yes | | `trigger_job` | Run now, without changing the schedule | Yes | | `delete_job` | Delete a job (marked destructive) | Yes | | `list_runs` | Runs of a job, filterable by status | No | | `get_run` | One run with every attempt, status code, error and response | No | | `get_stats` | Success rate, p95 start lag and SLA breaches, per job or workspace | No | Destructive tools carry the MCP `destructiveHint` annotation, so assistants that support it ask before calling them. Read-only tools carry `readOnlyHint`. Tool arguments use JSON objects. Schedule tools accept `expr`, `timezone`, and optional `kind`; preview also accepts `count` (1–100) and RFC3339 `after`. `create_job` uses the API job body: `name`, `schedule: {expr, timezone}`, `sink: {kind, config}`, and optional `retry`, `tags`, timeout and policy fields. A top-level `timezone` is also accepted when `schedule` is supplied. `update_job` accepts `job_id` plus supplied job fields. State tools take `job_id`; `get_run` takes `run_id`. Lists accept `limit` (1–200) and `cursor`; job lists also accept `state`, `tag`, `query`, and run lists accept `status`. `get_stats` returns 24-hour counts and success rate (0–1), with the REST statistics' lag and SLA fields. Workspace stats require `*` scope, as in REST. ## Resources and prompts | Kind | Name | Content | |---|---|---| | Resource | `cronpad://jobs/{id}` | A job as JSON | | Resource | `cronpad://runs/{id}` | A run with its attempts | | Prompt | `debug_failing_job(job_id)` | Walks the model through reading recent runs and attempts, classifying the failure and proposing a fix | ## Example > **You:** Every second Tuesday at 9am India time, call https://api.example.com/payroll/reminders. > > **Assistant** calls `preview_schedule("second tuesday of every month at 9am", "Asia/Kolkata")`, shows you the next three dates, and after you confirm calls `create_job`. It then offers to call `trigger_job` once to test the endpoint. ## Turning it off Set `[mcp] enabled = false` to remove the `/mcp` endpoint. API keys used by assistants can be given narrower scopes (`jobs:read`, `runs:read`) for read-only access. See [API authentication](../api/index.md#authentication). --- # Agent skill A skill is a folder of instructions that an AI agent loads when a task matches it. The cronpad repository ships one at `skills/cronpad/SKILL.md`. It tells the agent how to authenticate, which schedule syntax to use, the safe order of operations, how to verify webhook signatures in common languages, and how to debug a failing job from its runs and attempts. ## Install Copy the folder into wherever your agent reads skills from (for example a project's skills directory), or point the agent at the file directly: ```sh curl -fsSL https://raw.githubusercontent.com/gowthamgts/cronpad/main/skills/cronpad/SKILL.md -o skills/cronpad/SKILL.md ``` The skill expects `CRONPAD_URL` and `CRONPAD_API_KEY` in the environment, and uses the MCP server when connected, or the [CLI](../cli/index.md) with `--json` otherwise. ## What the skill teaches 1. **The model.** Jobs, occurrences, runs and attempts, what start lag measures, and why delivery is at-least-once, so the agent can explain "it ran twice" or "it ran late" correctly. 2. **Which tool to use.** A map from each task to its MCP tool, CLI command and REST endpoint, including the scope each needs and the few things only REST can do (testing a destination before a job exists, re-enabling an auto-disabled job, retrying a run). 3. **Validate, preview, confirm, then create.** Never create a job from an unverified schedule; show the next fire times in the user's IANA timezone first, then optionally test the destination once. 4. **Every job option.** Retry policy, timeout, misfire and overlap policies, SLA threshold, auto-disable threshold and secret references, with the server defaults. 5. **Make receivers idempotent** with the request headers listed and ready-made signature verification snippets. 6. **Debug from evidence.** A symptom table for "never ran", "skipped", "ran twice", "ran late", receiver errors and auto-disabled jobs, read from runs and attempts rather than guessed. 7. **Don't delete; pause.** Prefer `pause_job` unless the user explicitly asks to delete. 8. **Self-hosted operations.** What `/readyz` reports, the rebuildable queue file, opt-in Litestream backups and single-format logs. ## Receiver verification The skill contains Go, Node and Python functions that verify Standard Webhooks HMAC-SHA256 signatures over exact raw body bytes, check a five-minute timestamp tolerance, and accept any valid signature during key rotation. Repository tests extract and execute all three snippets against a real signed cronpad delivery, including tampered bodies, stale timestamps and malformed signatures. Store the signing secret in the receiver; MCP never returns it. Deduplicate `Idempotency-Key` durably after verification. Repository tests also parse every schedule example in the skill with cronpad's own parser, and check that the skill names every MCP tool and no tool that doesn't exist. The skill includes cron, seconds-first cron, Quartz, natural language, RRULE and one-off examples, plus timezone guidance. It documents scopes, JSON CLI fallbacks, command jobs, custom MCP paths, and the run/attempt debugging flow. --- # API reference The cronpad REST API lives under `/api/v1` on the same host as the dashboard. It speaks JSON, authenticates with a workspace API key in the `Authorization` header, returns errors in one consistent shape, and uses cursor pagination. The dashboard uses exactly this API, so anything you can do in the UI you can do with it. A machine-readable OpenAPI 3.1 spec is served at `/api/v1/openapi.yaml`. | Section | Endpoints | |---|---| | [Jobs](jobs.md) | Create, list, get, update, delete, pause, resume, enable, trigger, test, rotate signing secret | | [Runs](runs.md) | List runs, get a run with attempts, retry a failed run | | [Schedules](schedules.md) | Validate and preview schedules (no auth required) | | [Workspace](workspace.md) | API keys, secrets, alert channels and rules, stats, audit log, health | ## Server features `GET /api/v1/features` needs no authentication and returns what this server offers; the dashboard uses it to decide which sign-in buttons and sink types to show. It never includes secrets or client IDs, and clients may cache it for a minute. ```json { "public_url": "https://cronpad.example.com", "auth": {"providers": ["github", "google"], "dev_login": false}, "sinks": {"webhook": true, "command": false} } ``` ## Base URL ``` https://app.cronpad.com/api/v1 # cronpad Cloud https:///api/v1 # self-hosted ``` ## Authentication Send an API key as a bearer token: ```sh curl https://app.cronpad.com/api/v1/jobs -H "Authorization: Bearer cp_ab12cd34_…" ``` - Create keys under **Settings → API keys** or with `POST /api/v1/api-keys`. The full key is shown once; cronpad stores only a hash. - A key belongs to one workspace and can only see that workspace. - Scopes: `*` (default, full access), `jobs:read`, `jobs:write`, `runs:read`. A request outside the key's scopes gets `403 forbidden`. The dashboard authenticates with a session cookie instead, and sends the active workspace in a `Cronpad-Workspace` header. Cookie-authenticated requests that change data also need the `X-CSRF-Token` header; API-key requests don't. ## Conventions - **Times** are RFC 3339 in UTC (`2026-11-10T03:30:00Z`). - **Durations** are Go-style strings: `"30s"`, `"10m"`, `"1h30m"`. - **IDs** are prefixed ULIDs: `job_…`, `run_…`, `ws_…`, `key_…`, `ch_…`, `rule_…`. They sort by creation time. - **Request bodies** are limited to 1 MiB. - Every response has an `X-Request-ID` header (echoed if you send one). Include it when reporting problems. ## Errors ```json { "error": { "code": "validation_failed", "message": "schedule: unknown word \"tuesdy\"", "fields": {"schedule.expr": "unknown word \"tuesdy\" at position 6; did you mean \"tuesday\"?"} } } ``` | HTTP | `code` | Meaning | |---|---|---| | 400 | `validation_failed` | The request is malformed; `fields` says which inputs and why. | | 401 | `unauthorized` | Missing, invalid or revoked API key or session. | | 403 | `forbidden` | Authenticated, but the key's scopes or workspace membership don't allow it. | | 403 | `limit_exceeded` | A plan limit was reached; `limit` and `value` say which. | | 404 | `not_found` | Doesn't exist, or belongs to another workspace. | | 409 | `conflict` | State conflict, e.g. enabling a job that isn't disabled. | | 409 | `idempotency_conflict` | `Idempotency-Key` reused with a different body. | | 429 | `rate_limited` | Too many requests; wait for `Retry-After`. | | 500 | `internal` | A bug. Report it with the request ID. | | 503 | `unavailable` | The server is starting or a dependency is down; retry. | ## Pagination List endpoints return a page plus a cursor: ```json {"data": [ … ], "next_cursor": "eyJpZCI6InJ1bl8wMUo5…"} ``` Pass `?cursor=` for the next page and `?limit=` to set the page size (default 50, max 200). `next_cursor` is `null` on the last page. ## Idempotent creates `POST` endpoints that create things accept an `Idempotency-Key` header. Repeating the same request with the same key within 24 hours returns the original response instead of creating a duplicate. Reusing the key with a different body returns `409 idempotency_conflict`. ## Rate limits | Caller | Default limit | |---|---| | API key or session | 600 requests per minute (`limits.api_rate_limit_per_minute`) | | Unauthenticated schedule endpoints | 60 requests per minute per IP | Responses include `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers. --- # Jobs API Jobs are the core resource: a schedule, a sink, and policies for retries, misfires, overlap and auto-disable. All endpoints are workspace-scoped through your API key. ## The job object ```json { "id": "job_01J9ZK3Q…", "name": "nightly-report", "description": "Builds the daily revenue report", "tags": ["reports"], "schedule": { "expr": "every day at 9am", "kind": "natural", "canonical": "FREQ=DAILY;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "description": "At 09:00 every day", "timezone": "Asia/Kolkata", "starts_at": null, "ends_at": null, "count": null }, "sink": {"kind": "webhook", "config": {"url": "https://api.example.com/reports", "method": "POST"}}, "retry": {"max_attempts": 3, "backoff": "exponential", "initial_interval": "10s", "max_interval": "10m", "multiplier": 2, "jitter": 0.2}, "timeout": "30s", "sla_threshold": null, "misfire_policy": "fire_once", "overlap_policy": "allow", "auto_disable_threshold": null, "state": "active", "disabled_reason": null, "consecutive_failures": 0, "next_run_at": "2026-11-11T03:30:00Z", "next_runs": ["2026-11-11T03:30:00Z", "2026-11-12T03:30:00Z", "2026-11-13T03:30:00Z"], "last_run_at": "2026-11-10T03:30:00Z", "last_run_status": "succeeded", "version": 3, "created_at": "2026-10-16T08:00:00Z", "updated_at": "2026-10-20T11:12:00Z" } ``` `null` for `sla_threshold` and `auto_disable_threshold` means "use the server default". `next_runs` is included by `GET /jobs/{id}`. ## Create a job `POST /api/v1/jobs` ```json { "name": "nightly-report", "schedule": {"expr": "every day at 9am", "timezone": "Asia/Kolkata"}, "sink": {"kind": "webhook", "config": {"url": "https://api.example.com/reports"}}, "retry": {"max_attempts": 5}, "timeout": "30s", "tags": ["reports"] } ``` Only `name`, `schedule.expr` and `sink` are required. Omitted fields take server defaults. Partial `retry` objects are merged with the defaults. Response `201` is the job object plus, once only, `"signing_secret": "whsec_…"` for webhook jobs. Send an `Idempotency-Key` header to replay the same create response for 24 hours within a workspace. Reusing the key with a different payload returns `409 idempotency_conflict`. Errors: `400 validation_failed` (bad schedule, unknown secret, URL not allowed, sink disabled on this server), `403 limit_exceeded` (job count or minimum interval). ## List jobs `GET /api/v1/jobs?state=active&tag=reports&q=report&limit=50` | Query | Description | |---|---| | `state` | `active`, `paused`, `disabled`, `completed` | | `tag` | Jobs with this tag | | `q` | Case-insensitive match on name and description | ## Get a job `GET /api/v1/jobs/{id}`, including `next_runs` and 24-hour `stats_24h` (`runs`, `succeeded`, `failed`, `sla_breached`, `p95_start_lag_ms`). ## Update a job `PATCH /api/v1/jobs/{id}` with any subset of the create fields. The job's `version` increases, queued occurrences of the old version are dropped, and the next fire time is recalculated from now. ## Delete a job `DELETE /api/v1/jobs/{id}` → `204`. The job stops immediately; its run history remains until it ages out. ## Change state | Endpoint | From | To | |---|---|---| | `POST /api/v1/jobs/{id}/pause` | `active` | `paused` | | `POST /api/v1/jobs/{id}/resume` | `paused` | `active` (next future occurrence) | | `POST /api/v1/jobs/{id}/enable` | `disabled` | `active`, failure counter reset | Calling from any other state returns `409 conflict`. ## Run now `POST /api/v1/jobs/{id}/trigger` → `202` with `{"run_id": "run_…"}`. Creates a run with `trigger = manual` that starts immediately. The schedule is unaffected. Works on paused jobs too. ## Test the sink `POST /api/v1/jobs/{id}/test` → `200` ```json {"outcome": "success", "status_code": 200, "duration_ms": 87, "response_body": "{\"ok\":true}", "request_summary": "POST https://api.example.com/reports (231 bytes)"} ``` Executes the sink once, synchronously, without recording a run or affecting counters. You can also test an unsaved config with `POST /api/v1/jobs/test` and a full job body. ## Rotate the signing secret `POST /api/v1/jobs/{id}/rotate-signing-secret` → `200` with the new `signing_secret`. Both secrets sign requests for 24 hours. --- # Runs API Runs record each occurrence of a job and every attempt to deliver it. Use these endpoints to build dashboards, export history, or debug failures. ## The run object ```json { "id": "run_01J9ZM1A…", "job_id": "job_01J9ZK3Q…", "scheduled_at": "2026-11-10T03:30:00Z", "trigger": "schedule", "status": "succeeded", "attempts": 2, "claimed_at": "2026-11-10T03:30:00.004Z", "started_at": "2026-11-10T03:30:00.006Z", "finished_at": "2026-11-10T03:30:21.410Z", "next_attempt_at": null, "start_lag_ms": 6, "e2e_ms": 21410, "sla_breached": false, "last_error": null } ``` | Field | Values | |---|---| | `trigger` | `schedule`, `manual`, `retry_manual` | | `status` | `running`, `deferred`, `succeeded`, `failed`, `skipped`, `cancelled` | See [Jobs, runs and attempts](../concepts/jobs-runs-attempts.md) for what each state and timestamp means. ## List runs of a job `GET /api/v1/jobs/{id}/runs?status=failed&from=2026-11-01T00:00:00Z&to=2026-11-10T00:00:00Z&limit=50` Newest first, with run ID breaking ties at the same scheduled time. Pass `next_cursor` as `cursor` to continue a page (default 50, maximum 200). Filter by `status`, `trigger`, `sla_breached=true`, and a `from`/`to` range on `scheduled_at`. ## List runs across the workspace `GET /api/v1/runs` with the same filters plus `job_id` and `tag`. ## Get a run `GET /api/v1/runs/{id}` returns the run plus its attempts: ```json { "id": "run_01J9ZM1A…", "status": "succeeded", "attempts": [ { "n": 0, "started_at": "2026-11-10T03:30:00.006Z", "finished_at": "2026-11-10T03:30:01.010Z", "duration_ms": 1004, "outcome": "retryable", "status_code": 503, "error": "HTTP 503", "request_summary": "POST https://api.example.com/reports (231 bytes)", "response_body": "upstream unavailable" }, { "n": 1, "started_at": "2026-11-10T03:30:11.300Z", "finished_at": "2026-11-10T03:30:21.410Z", "duration_ms": 110, "outcome": "success", "status_code": 200, "error": null, "request_summary": "POST https://api.example.com/reports (231 bytes)", "response_body": null } ] } ``` `response_body` is `null` when it wasn't stored (successful attempts by default) or has aged out. See [Data retention](../platform/data-retention.md). ## Retry a failed run `POST /api/v1/runs/{id}/retry` → `202` with the new run. Only runs with status `failed` can be retried; the new run has `trigger = retry_manual`, the same `scheduled_at`, and a fresh set of attempts. Repeated requests for the same occurrence coalesce into one manual retry; an exhausted manual retry returns `409 conflict`. --- # Schedules API Two endpoints check schedules without creating anything. They need no API key (they're rate-limited per IP), so you can use them from build scripts, forms or other tools. ## Validate `POST /api/v1/schedules/validate` ```json {"expr": "0 0 9 ? * 3#2", "kind": "auto", "timezone": "Europe/London"} ``` ```json { "valid": true, "kind_detected": "quartz", "canonical": "0 0 9 ? * 3#2", "description": "At 09:00 on the second Tuesday of the month", "errors": [] } ``` Invalid input still returns `200`, with `valid: false` and structured errors: ```json { "valid": false, "kind_detected": "natural", "errors": [{"kind": "natural", "position": 6, "length": 6, "message": "unknown word \"tuesdy\"", "suggestion": "every tuesday at 9am"}] } ``` ## Preview `POST /api/v1/schedules/preview` | Field | Default | Description | |---|---|---| | `expr` | required | Any supported [schedule](../schedules/index.md) | | `kind` | `auto` | Force a format | | `timezone` | `UTC` | IANA timezone | | `count` | `10` | Number of fire times, 1–100 | | `after` | now | Compute fire times after this instant | ```json { "kind_detected": "natural", "canonical": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=18;BYMINUTE=30;BYSECOND=0", "description": "At 18:30 on weekdays", "timezone": "America/New_York", "next": ["2026-11-02T23:30:00Z", "2026-11-03T23:30:00Z", "2026-11-04T23:30:00Z"] } ``` `next` is in UTC. An invalid expression returns `400 validation_failed` with the same structured errors. ## Reference time, limits, and errors An explicit `after` is also the recurrence anchor for previews: `in 20 minutes` is evaluated relative to it, and interval phases begin there. Otherwise the server's injected clock supplies the reference time. Validation samples 100 successive gaps and rejects a sampled gap below `limits.min_interval`; this is a finite preview check rather than a proof about every future calendar transition. One-off schedules have no pair of occurrences and therefore no minimum-gap error. Both routes share a per-peer-IP limit configured by `limits.api_rate_limit_per_minute`; `0` disables it. They return `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers, and a limited request returns `429` with `error.code = "rate_limited"`. Untrusted proxy headers do not change the rate-limit key; clients behind one proxy share its bucket. Malformed JSON, unknown request fields, invalid `after`, and counts outside 1–100 return `400 validation_failed`. Request bodies are limited to 16 KiB and expressions to 4096 bytes. Preview parse errors use the common envelope: ```json { "error": { "code": "validation_failed", "message": "invalid schedule", "fields": {"schedule": "unknown word \"tusday\""}, "errors": [{"kind":"natural","position":6,"length":6,"message":"unknown word \"tusday\"","suggestion":"every tuesday at 9am"}] } } ``` Positions and lengths count UTF-8 bytes in the original expression. Syntax errors from `/validate` remain a `200` response with `valid: false`. Each validation or preview has a cooperative 100 ms compute deadline, separate from the HTTP read/write timeouts. Cancellation is checked during finite-count construction and between preview/minimum-gap steps; computation stays on the request goroutine. A computation that exhausts its budget returns a structured `schedule computation budget exceeded` validation error (`valid: false` from `/validate`, or `400 validation_failed` from `/preview`) rather than an apparently exhausted schedule or a detached background worker. --- # Workspace API Everything in a workspace that isn't a job or a run: API keys, secrets, alerting, statistics and the audit log, plus the server's health endpoints. ## Identity and workspace `GET /api/v1/me` returns the user and accessible workspaces. `GET /api/v1/workspaces/{ws}` returns `id`, `name`, `slug`, `plan` and `created_at`; `PATCH` accepts `name` and `slug`. The workspace must match the authenticated workspace; other IDs return `404`. ## API keys | Endpoint | Description | |---|---| | `GET /api/v1/api-keys` | List keys: `id`, `name`, `prefix` (e.g. `cp_ab12cd34`), `scopes`, `created_at`, `last_used_at`, `revoked_at` | | `POST /api/v1/api-keys` | Create: `{"name": "ci", "scopes": ["jobs:read","runs:read"]}`. Response includes the full `key` once. | | `DELETE /api/v1/api-keys/{id}` | Revoke. Takes effect immediately. | ## Secrets | Endpoint | Description | |---|---| | `GET /api/v1/secrets` | List names with `created_at`/`updated_at`. Values are never returned. | | `PUT /api/v1/secrets/{name}` | Create or replace: `{"value": "…"}` | | `DELETE /api/v1/secrets/{name}` | Delete | See [Secrets](../secrets/index.md). ## Alert channels | Endpoint | Description | |---|---| | `GET /api/v1/alert-channels` | List | | `POST /api/v1/alert-channels` | Create. Email channels start unverified. Webhook channels return a `signing_secret` once. | | `GET /api/v1/alert-channels/{id}` | Read one channel | | `GET /api/v1/alert-channels/verify?token=…` | Verify an address using its signed email link (no login required) | | `POST /api/v1/alert-channels/{id}/verify` | Resend confirmation links | | `PATCH /api/v1/alert-channels/{id}` | Update | | `DELETE /api/v1/alert-channels/{id}` | Delete (rules referencing it stop sending to it) | | `POST /api/v1/alert-channels/{id}/test` | Send a test alert | See [Alert channels](../alerts/channels.md). ## Alert rules | Endpoint | Description | |---|---| | `GET /api/v1/alert-rules` | List | | `POST /api/v1/alert-rules` | Create | | `GET /api/v1/alert-rules/{id}` | Read one rule | | `PATCH /api/v1/alert-rules/{id}` | Update | | `DELETE /api/v1/alert-rules/{id}` | Delete | See [Alert rules](../alerts/rules.md). ## Stats `GET /api/v1/stats/overview?window=24h` (`1h`, `24h`, `7d`, `30d`): ```json { "window": "24h", "jobs": {"active": 42, "paused": 3, "disabled": 1, "completed": 7}, "runs": {"total": 18240, "succeeded": 18190, "failed": 50, "sla_breached": 4}, "start_lag_ms": {"p50": 3, "p95": 9, "p99": 41}, "failing_jobs": [{"id": "job_01J9…", "name": "crm-sync", "consecutive_failures": 6}] } ``` ## Audit log `GET /api/v1/audit-log?action=job.pause&limit=50`. Each entry has `actor` (`user:…`, `key:…` or `system`), `action` (e.g. `job.create`, `job.update`, `job.pause`, `job.auto_disable`, `api_key.create`, `secret.update`), `target`, `details` and `created_at`. Entries are kept for 30 days by default. ## Health (no authentication, outside `/api/v1`) | Endpoint | Returns | |---|---| | `GET /healthz` | `200` while the process is running. Use for liveness probes. | | `GET /readyz` | `200` when both databases are writable, the queue file responds, the dispatcher is active and result writing is keeping up; `503` otherwise, with a JSON breakdown. Use for readiness probes and uptime checks. | | `GET /version` | `{"version": "1.0.0", "commit": "…", "build_date": "…", "edition": "oss"}` | ```json {"dispatcher": "active", "queue": true, "heartbeat": "2026-10-18T09:30:00Z", "writer_backpressure": false, "sqlite_writable": true} ``` `dispatcher` is one of `starting`, `active`, `standby` (another process holds the dispatcher lock) or `stopped`. `queue` is `false` when the queue file can't be read, `sqlite_writable` covers both databases, and `heartbeat` is the dispatcher's last tick. ## Usage and suspension `GET /api/v1/usage` returns the workspace's UTC calendar-month run usage and effective limit: ```json {"month":"2026-10","runs":1200,"limit":50000,"over_quota_policy":"block","reset_at":"2026-11-01T00:00:00Z"} ``` `limit` is `0` for unlimited. `over_quota_policy` is `block` or `allow`. `reset_at` is the start of the next UTC month. At a blocking cap, manual job triggers return `429` with `error.code: "limit_exceeded"` and `limit: "max_runs_per_month_per_workspace"`. Suspended workspaces retain read access. Mutations return `403` with `error.code: "workspace_suspended"`; operators restore access with the `cronpad workspace unsuspend ` command. Skipped runs carry a `skip_reason` (`quota_exceeded`, `workspace_suspended`, `previous_run_in_progress` or `misfire`) separate from delivery errors in `last_error`. --- # CLI The `cronpad` binary is both the server and a command-line client. Server commands (`serve`, `migrate`, `user`, `secrets`) work on the local database and config. Client commands (`jobs`) talk to any cronpad server over the API. `schedule` commands work entirely offline. Schedule and jobs commands accept `--json` for machine-readable output; `user create-key` also supports it. ## Install Download a release binary for Linux or macOS (amd64, arm64) from the GitHub releases page, or use the Docker image (`docker run --rm cronpad: `). ## Client configuration | Variable | Flag | Description | |---|---|---| | `CRONPAD_URL` | `--url` | Server base URL, e.g. `https://app.cronpad.com` | | `CRONPAD_API_KEY` | `--api-key` | API key (`cp_…`) | ## Commands ### `cronpad serve` Starts the server: HTTP, dispatcher, workers and background tasks. Reads `cronpad.toml` from the working directory, or `--config `. Environment variables override the file. See [Configuration](../self-hosting/configuration.md). ### `cronpad migrate` Applies database migrations and exits. `serve` does this automatically unless `database.run_migrations = false`. ### `cronpad schedule validate ` ```sh $ cronpad schedule validate "0 0 9 ? * 3#2" valid quartz At 09:00 on the second Tuesday of the month ``` ### `cronpad schedule next ` ```sh $ cronpad schedule next "every weekday at 6:30pm" --tz America/New_York -n 3 2026-11-02 18:30 EST (2026-11-02T23:30:00Z) 2026-11-03 18:30 EST (2026-11-03T23:30:00Z) 2026-11-04 18:30 EST (2026-11-04T23:30:00Z) ``` Flags: `--tz` (default `UTC`), `-n` / `--count` (default 10, range 1–100), `--after `, `--kind` (default `auto`), and `--json`. `validate` also accepts `--tz`, `--kind`, and `--json`. Invalid schedules exit nonzero; JSON validation output includes the structured parser errors. These commands do not load configuration or open databases. An explicit `--after` also anchors relative expressions and recurrence phases. Offline validation checks syntax; the server additionally applies its configured minimum interval. ### `cronpad jobs …` | Command | Description | |---|---| | `cronpad jobs list [--state active] [--tag t]` | List jobs | | `cronpad jobs get ` | Show a job and its next runs | | `cronpad jobs create --name n --schedule s [--tz z] (--url u \| --command c [--arg a]…)` | Create a simple job | | `cronpad jobs create -f job.json` | Create from a full JSON body (same as the API) | | `cronpad jobs pause ` / `resume ` / `enable ` | Change state | | `cronpad jobs trigger ` | Run now | | `cronpad jobs runs [--status failed]` | Recent runs | Jobs flags override environment variables. For `create`, `--url` is the webhook URL; put a server override before the subcommand: ```sh cronpad jobs --url https://cronpad.example.com create --name report \ --schedule 'every weekday at 9am' --tz Asia/Kolkata \ --url https://example.com/hook --retries 5 --tag reports --json cronpad jobs create --name backup --schedule 'every day at 2am' \ --command /usr/local/bin/backup.sh --arg daily --json cronpad jobs list --query report --limit 20 --json ``` `--retries` sets maximum attempts including the first attempt. `--tag` accepts repeated flags or comma-separated tags. `-f -` reads a JSON body from stdin; file mode cannot be combined with simple creation flags. List and runs accept `--limit`; list also accepts `--cursor`. Table output reports `next_cursor` when another page is available. API errors print their code and message to stderr and exit nonzero. ### `cronpad mcp` Bridges the server's MCP endpoint to stdio for AI clients that launch local processes. Uses `CRONPAD_URL` and `CRONPAD_API_KEY`, overridden by `--url` and `--api-key`. Use `--path` for a custom `mcp.path`. See [MCP server](../ai/mcp-server.md). ### `cronpad user create-key --email --name ` Creates an API key for that user's personal workspace directly in the local database and prints it once. Useful for bootstrapping automation on a fresh self-hosted server. The user must have signed in at least once. ### `cronpad secrets rekey --new-key ` Re-encrypts all stored secrets with a new encryption key. Stop the server first, run this, then start it with the new `secrets.encryption_key`. ### `cronpad healthcheck` Calls the local `/readyz` and exits `0` or `1`. Used as the Docker healthcheck, since the image has no `curl`. ### `cronpad version` Prints version, commit and edition (`oss` or `cloud`). --- # Self-hosting A self-hosted cronpad is one process (the `cronpad` binary or container) and a data directory; there is no other service to run. All durable data is in two SQLite files on a local volume, backed up continuously with Litestream, next to a third, rebuildable SQLite file that holds the delivery queue. Self-hosted servers have every feature, no usage limits, and can run shell commands and call services on private networks. | Step | Page | |---|---| | Install with Docker, Compose, Kubernetes or a binary | [Installation](installation.md) | | Every setting and its environment variable | [Configuration reference](configuration.md) | | Google and GitHub sign-in | [Authentication](authentication.md) | | Continuous backups and restores | [Backups with Litestream](backups.md) | | Before you rely on it | [Production checklist](production-checklist.md) | | Moving to a new version | [Upgrading](upgrading.md) | | Metrics, logs, traces | [Observability](../observability/index.md) | ## Requirements | | Minimum | Comfortable for ~1,000 runs/s | |---|---|---| | CPU | 1 vCPU | 4 vCPU | | Memory | 256 MB | 2 GB | | Disk | 1 GB local SSD | Local NVMe, sized for your [retention](../platform/data-retention.md); the queue adds ~100 bytes per active job | --- # Installation cronpad is distributed as a container image and as static binaries for Linux and macOS. Every method needs only a persistent local directory for cronpad's files: the two databases and the rebuildable queue file. There is no other service to run. Run exactly one cronpad process per data directory. ## Docker Compose (recommended) ```yaml # docker-compose.yml services: cronpad: image: ghcr.io/gowthamgts/cronpad:1 ports: ["8080:8080"] env_file: .env environment: CRONPAD_DATABASE__PATH: /data/cronpad.db CRONPAD_DATABASE__HISTORY_PATH: /data/history.db volumes: ["cronpad-data:/data"] # also holds the queue, /data/cronpad-queue.db restart: unless-stopped volumes: cronpad-data: ``` ```sh docker compose up -d ``` For a local-only demo including a SeaweedFS S3 server, bucket setup and development login, use: ```sh docker compose --env-file deploy/demo.env -f deploy/docker-compose.yml --profile demo up --build ``` The demo uses fixed local credentials; replace them and configure OAuth for production. Without `LITESTREAM_REPLICA_URL`, cronpad runs [SQLite only](backups.md#sqlite-only-the-default): fine for trying it out, but nothing is replicated. The repository's `deploy/docker-compose.yml` also has optional profiles: `--profile backup` adds a SeaweedFS S3 server and bucket setup (set the Litestream URL and credentials, or use `deploy/demo.env`), and `--profile observability` adds an OpenTelemetry Collector, Prometheus and Grafana with a ready-made dashboard. ## Docker ```sh docker run -d --name cronpad -p 8080:8080 \ -v cronpad-data:/data \ -e CRONPAD_SERVER__PUBLIC_URL=https://cron.example.com \ -e CRONPAD_AUTH__GITHUB__CLIENT_ID=… -e CRONPAD_AUTH__GITHUB__CLIENT_SECRET=… \ -e CRONPAD_SECRETS__ENCRYPTION_KEY=… \ ghcr.io/gowthamgts/cronpad:1 ``` The published image is Alpine-based and includes `/bin/sh` and `curl`; enable the [command sink](../sinks/commands.md) with `CRONPAD_SINKS__COMMAND__ENABLED=true`, and add any other tools your jobs need in your own image. ## Kubernetes The essentials (a complete example is in `deploy/kubernetes/`): ```yaml apiVersion: apps/v1 kind: Deployment metadata: {name: cronpad} spec: replicas: 1 # exactly one: SQLite has a single writer strategy: {type: Recreate} # never run old and new pods at the same time selector: {matchLabels: {app: cronpad}} template: metadata: {labels: {app: cronpad}} spec: containers: - name: cronpad image: ghcr.io/gowthamgts/cronpad:1 ports: [{containerPort: 8080}] envFrom: [{secretRef: {name: cronpad-env}}] volumeMounts: [{name: data, mountPath: /data}] livenessProbe: {httpGet: {path: /healthz, port: 8080}} readinessProbe: {httpGet: {path: /readyz, port: 8080}} terminationGracePeriodSeconds: 45 # > server.shutdown_grace volumes: - name: data persistentVolumeClaim: {claimName: cronpad-data} # ReadWriteOnce, block storage ``` ## Binary ```sh curl -fsSLO https://github.com/gowthamgts/cronpad/releases/latest/download/cronpad_linux_amd64.tar.gz tar xzf cronpad_linux_amd64.tar.gz ./cronpad serve --config ./cronpad.toml ``` A systemd unit: ```ini [Unit] Description=cronpad After=network-online.target [Service] ExecStart=/usr/local/bin/cronpad serve --config /etc/cronpad/cronpad.toml EnvironmentFile=/etc/cronpad/env User=cronpad WorkingDirectory=/var/lib/cronpad Restart=always TimeoutStopSec=45 [Install] WantedBy=multi-user.target ``` ## Upgrading from a Redis-based version Earlier releases used Redis for the queue. Current releases ignore it: on the first start after upgrading, cronpad creates the queue file next to `cronpad.db` (`cronpad-queue.db`) and rebuilds it from your jobs, retries and pending manual runs, so nothing is lost. A leftover `[redis]` section or `CRONPAD_REDIS__*` variable only logs a warning; remove it, then stop the Redis service. ## Behind a reverse proxy Set `server.public_url` to the external URL and list the proxy's addresses in `server.trusted_proxies` so client IPs are logged and rate-limited correctly. The proxy must pass through `Authorization` headers and allow request bodies up to 1 MiB. No WebSocket or special routing is needed: everything is plain HTTP on one port. ## API-only mode Set `[ui] dashboard = false` (`CRONPAD_UI__DASHBOARD=false`) to serve only the API, MCP server, sign-in and health endpoints from the same binary. --- # Configuration reference cronpad reads settings from built-in defaults, then an optional TOML file (`cronpad.toml` in the working directory, or `--config `), then environment variables, then command-line flags; later sources win. Any setting can be set by environment variable: prefix `CRONPAD_`, uppercase the key, and join nested sections with a double underscore. Invalid configuration stops the server at startup with a list of every problem. ```toml [database] path = "/data/cronpad.db" ``` is the same as ```sh CRONPAD_DATABASE__PATH=/data/cronpad.db ``` **Durations** use Go units (`ms`, `s`, `m`, `h`) plus `d` for days: `"500ms"`, `"30s"`, `"1h30m"`, `"30d"`. **Lists** in environment variables are comma-separated: `CRONPAD_AUTH__ALLOWED_EMAIL_DOMAINS=example.com,example.org`. ## Top level | Key | Default | Description | |---|---|---| | `mode` | `"selfhosted"` | `selfhosted` or `hosted`. `hosted` applies safe defaults for a public, multi-tenant server; see [Security](../platform/security.md#hosted-mode). | ## server | Key | Default | Description | |---|---|---| | `addr` | `":8080"` | Address the process listens on. | | `public_url` | `"http://localhost:8080"` | The URL users reach cronpad at, e.g. `https://cron.example.com`. Used for OAuth callbacks, links in alert emails, and to mark cookies `Secure` when it's `https`. Set it whenever cronpad is behind a proxy or on a domain. | | `read_timeout` | `"15s"` | Maximum time to read a request. | | `write_timeout` | `"30s"` | Maximum time to write a response. | | `shutdown_grace` | `"30s"` | How long `SIGTERM` waits for in-flight runs. Make your orchestrator's stop timeout longer than this. | | `trusted_proxies` | `[]` | CIDRs whose `X-Forwarded-For` is trusted. | ## ui | Key | Default | Description | |---|---|---| | `dashboard` | `true` | Serve the dashboard at `/`. `false` disables the dashboard; the API, `/auth`, `/mcp` and health endpoints keep working. | | `cors_origins` | `[]` | Exact origins (no wildcards, no paths; for example `https://www.example.com`) allowed to call `/api/` from a browser. Leave empty for normal self-hosting: the dashboard and API share an origin. Set it only when another site's browser JavaScript calls your API, such as a page embedding the schedule preview. Cookies are never sent cross-origin, so those calls reach only public endpoints or use an API key. Env: `CRONPAD_UI__CORS_ORIGINS` (comma-separated). | ## database | Key | Default | Description | |---|---|---| | `path` | `"./data/cronpad.db"` | Control database: users, workspaces, jobs, keys, secrets, alert config. | | `history_path` | `"./data/history.db"` | History database: runs, attempts, audit log, alert outbox, stats. | | `busy_timeout` | `"5s"` | How long SQLite waits on a lock before failing. | | `run_migrations` | `true` | Apply migrations on startup. | Both files must be on a local or block-storage filesystem, never NFS/SMB. ## queue | Key | Default | Description | |---|---|---| | `path` | `""` | The SQLite queue file. Empty means `-queue.db` beside `database.path` (`./data/cronpad-queue.db` by default), so two servers with differently named databases in one directory never share a queue. It is a rebuildable index: never back it up or restore it. Must differ from both database paths. | Releases that used Redis read a `[redis]` section; it is now ignored with a warning at startup. See [Upgrading from a Redis-based version](installation.md#upgrading-from-a-redis-based-version). ## scheduler See [Scheduling engine](../concepts/scheduling-engine.md) for what these control. | Key | Default | Description | |---|---|---| | `tick_interval` | `"100ms"` | Longest the claim loop sleeps when nothing is due sooner. | | `results_flush_interval` | `"0s"` | `0s` = group commit: the result writer commits as soon as it is free and batches whatever arrived meanwhile. A positive value (1ms through 1s) instead waits for a fixed ticker to grow batches. | | `results_max_batch` | `500` | Commit immediately at this command count; 1 through 10000. | | `delivery_mode` | `"durable"` | `durable` waits for admission commits; opt-in `fast` overlaps delivery and commits. Requires receiver idempotency; allowed in hosted mode. | | `claim_batch` | `500` | Maximum occurrences claimed at once. | | `lease_duration` | `"60s"` | In-flight lease. Renewed while a sink runs, so it only expires when a worker crashes or hangs. | | `reaper_interval` | `"5s"` | How often expired leases are returned to the queue. | | `lock_ttl` | `"15s"` | Dispatcher lock lifetime; renewed every third of it. | | `reconcile_interval` | `"10s"` | How often overdue jobs are checked for lost queue entries. | | `overdue_grace` | `"30s"` | How far past its fire time a job must be before it counts as overdue. | | `full_reconcile_interval` | `"6h"` | Full database ↔ queue sweep (also at startup and when queue data loss is detected). | | `sla_threshold` | `"5s"` | Default start lag above which a run breaches its SLA. Jobs can override it. | | `default_misfire_policy` | `"fire_once"` | `fire_once`, `fire_all` or `skip`. See [Misfires](../schedules/misfires-and-overlap.md). | | `misfire_fire_all_cap` | `10` | Maximum missed occurrences replayed by `fire_all`. | ## workers | Key | Default | Description | |---|---|---| | `count` | `64` | Concurrent sink executions. | | `queue_size` | `1024` | Hand-off buffer between the claim loop and workers. | ## sinks.webhook | Key | Default | Description | |---|---|---| | `per_host_rate_limit` | `20` | Hosted-only requests/second per destination host across workspaces; token bucket delays excess. `0` disables; self-hosted mode leaves it off. | | `enabled` | `true` | | | `default_timeout` | `"30s"` | Per-attempt timeout when a job doesn't set one. | | `max_timeout` | `"120s"` | Largest timeout a job may set. | | `max_response_bytes` | `65536` | Response bytes read and stored per attempt. | | `allow_private_networks` | `true` (`false` in hosted mode) | Allow webhooks to loopback, private and link-local addresses. | | `user_agent` | `"cronpad/{version}"` | | ## sinks.command | Key | Default | Description | |---|---|---| | `enabled` | `true` (`false` in hosted mode) | Allow command jobs. | | `shell` | `"/bin/sh"` | Shell used when a job sets `shell: true`. | | `allowed_commands` | `[]` | If non-empty, only these executables may run, and `shell: true` is rejected. | | `default_timeout` | `"60s"` | | | `max_output_bytes` | `65536` | Captured stdout+stderr per attempt. | | `env_passthrough` | `[]` | Server environment variables passed to commands, besides `PATH`, `HOME` and `LANG`. | ## retry.defaults | Key | Default | Description | |---|---|---| | `max_attempts` | `3` | Including the first attempt. | | `backoff` | `"exponential"` | `exponential` or `fixed`. | | `initial_interval` | `"10s"` | | | `max_interval` | `"10m"` | | | `multiplier` | `2.0` | | | `jitter` | `0.2` | ±fraction applied to each wait. | ## auto_disable | Key | Default | Description | |---|---|---| | `enabled` | `true` | Disable jobs after too many consecutive failed runs. | | `consecutive_failures` | `20` | Default threshold. | | `notify` | `true` | Email workspace owners when a job is disabled. | ## limits `0` means unlimited. Self-hosted defaults are unlimited; cronpad Cloud sets [its own values](../platform/limits.md). | Key | Default | Description | |---|---|---| | `max_jobs_per_workspace` | `0` | | | `min_interval` | `"1s"` | Shortest allowed time between a job's occurrences. | | `max_runs_per_month_per_workspace` | `0` | UTC calendar-month cap; automatic retries do not count. | | `over_quota` | `"block"` | `block` records excess occurrences as skipped; `allow` keeps delivering and counting. | | `max_concurrent_runs_per_workspace` | `0` | Maximum concurrent admissions per workspace; excess claims wait 250ms. | | `max_alert_emails_per_day` | `0` | Ordinary alert emails per workspace per UTC day; excess is suppressed. | | `max_runs_per_day_per_workspace` | deprecated | Converted ×30 to monthly with a startup warning; explicit monthly wins. | | `max_api_keys_per_workspace` | `0` | Unlimited on self-hosted; hosted default 20. | | `max_retry_attempts` | `0` | Upper bound for a job's `max_attempts`. | | `api_rate_limit_per_minute` | `600` | Per API key or session. | ## retention See [Data retention](../platform/data-retention.md). `"0"` keeps data forever. | Key | Default | Description | |---|---|---| | `runs` | `"30d"` | Runs and their attempts. | | `attempt_bodies` | `"7d"` | Response bodies and command output only. | | `store_success_bodies` | `false` | Also store bodies of successful attempts. | | `audit_log` | `"30d"` | | | `alert_outbox` | `"30d"` | Sent, failed and suppressed alerts. Pending alerts are never deleted. | | `job_stats_daily` | `"400d"` | Daily rollups behind dashboard charts. | | `janitor_interval` | `"5m"` | | | `janitor_batch` | `1000` | Rows deleted per transaction. | ## auth See [Authentication](authentication.md). | Key | Default | Description | |---|---|---| | `session_lifetime` | `"720h"` | | | `allowed_email_domains` | `[]` | If set, only these email domains may sign in. | | `allow_signup` | `false` | `true` lets anyone who can sign in create an account. When `false`, only existing users sign in, except the first user of an empty server ([First sign-in](authentication.md#first-sign-in)). | | `dev_login` | `false` | Passwordless local login for development. Never enable on a reachable server. | | `google.client_id`, `google.client_secret` | `""` | | | `github.client_id`, `github.client_secret` | `""` | | ## secrets | Key | Default | Description | |---|---|---| | `encryption_key` | `""` | 32 random bytes, base64. Required to use [secrets](../secrets/index.md). Generate with `openssl rand -base64 32`. Keep a copy: backups are useless without it. | ## alerts.email | Key | Default | Description | |---|---|---| | `enabled` | `false` | | | `smtp_host`, `smtp_port` | `""`, `587` | STARTTLS on 587, implicit TLS on 465. | | `username`, `password` | `""` | | | `from` | `"cronpad "` | | ## telemetry See [Observability](../observability/index.md). | Key | Default | Description | |---|---|---| | `service_name` | `"cronpad"` | | | `otlp_endpoint` | `""` | OTLP collector endpoint. Empty disables OTLP export. | | `otlp_protocol` | `"grpc"` | `grpc` or `http`. | | `otlp_logs` | `false` | Also export logs over OTLP. | | `prometheus_addr` | `""` | e.g. `":9090"` to serve `/metrics` on a separate port. | | `traces_sample_ratio` | `0.1` | Fraction of runs traced. | ## logging See [Logging](../observability/logging.md). | Key | Default | Description | |---|---|---| | `level` | `"info"` | `debug`, `info`, `warn`, `error`. | | `format` | `"json"` | `json` or `logfmt`. | | `add_source` | `false` | Include `source=file.go:123`. | ## mcp | Key | Default | Description | |---|---|---| | `enabled` | `true` | Serve the [MCP server](../ai/mcp-server.md). | | `path` | `"/mcp"` | | ## Example ```toml [server] public_url = "https://cron.example.com" trusted_proxies = ["10.0.0.0/8"] [auth.github] client_id = "Iv1.0123456789" # client_secret from CRONPAD_AUTH__GITHUB__CLIENT_SECRET [alerts.email] enabled = true smtp_host = "smtp.postmarkapp.com" from = "cronpad " [logging] format = "logfmt" [retention] runs = "90d" ``` ## auth.oidc One optional instance-wide OIDC login provider; see [Authentication](authentication.md#generic-oidc) for Authentik and Keycloak setup. Environment variables use the prefix `CRONPAD_AUTH__OIDC__`. | Key | Default | Description | |---|---|---| | `issuer` | `""` | Issuer URL, used for discovery; empty disables the provider. | | `client_id` | `""` | OIDC client ID, required when issuer is configured. | | `client_secret` | `""` | Confidential client secret; prefer an environment variable. | | `display_name` | `"SSO"` | Label displayed on the dashboard sign-in button. | | `scopes` | `["openid", "email", "profile"]` | Requested scopes; must include `openid`. | | `require_email_verified` | `true` | Reject identities without verified email; disabling trusts the provider's email for linking. | Register `/auth/oidc:default/callback` as the provider's exact redirect URI. Discovery and token verification errors reject login. This provider counts toward the startup OAuth-provider requirement. --- # Authentication People sign in to cronpad with Google, GitHub or a generic OIDC provider; programs use API keys. A self-hosted server needs at least one sign-in provider configured (or development login for local testing) and refuses to start otherwise, so it's never accidentally open. Production servers also require `secrets.encryption_key` (32 random bytes encoded as base64) to protect webhook signing secrets and cached create responses. Set `CRONPAD_SECRETS__ENCRYPTION_KEY` before creating jobs, including during development. You can restrict sign-in to your company's email domains. ## GitHub 1. GitHub → **Settings → Developer settings → OAuth Apps → New OAuth App**. 2. **Homepage URL:** your `server.public_url`. 3. **Authorization callback URL:** `/auth/github/callback`. 4. Generate a client secret, then set: ```sh CRONPAD_AUTH__GITHUB__CLIENT_ID=Iv1.… CRONPAD_AUTH__GITHUB__CLIENT_SECRET=… ``` cronpad uses the account's primary **verified** email address. ## Google 1. Google Cloud Console → **APIs & Services → Credentials → Create credentials → OAuth client ID** → *Web application*. 2. **Authorized redirect URI:** `/auth/google/callback`. 3. Set: ```sh CRONPAD_AUTH__GOOGLE__CLIENT_ID=….apps.googleusercontent.com CRONPAD_AUTH__GOOGLE__CLIENT_SECRET=… ``` Only Google accounts with a verified email can sign in. ## Restricting access ```toml [auth] allowed_email_domains = ["example.com"] # only @example.com addresses allow_signup = false # default; true lets anyone who can sign in create an account ``` If someone signs in with GitHub and later with Google using the same verified email, both identities are linked to one user. An identity is the provider's account ID, not its email. If someone's email changes at the provider, they still sign in to the same user, and cronpad updates the user's address to the new one (the new address must still match `allowed_email_domains`). If another user already has the new address, cronpad keeps the old one and logs a warning. ## First sign-in Sign-up is off by default (`allow_signup = false`), with one exception: while the server has no users, the first person to sign in gets an account. Until then the dashboard opens on a setup page (`/setup`) instead of the sign-in page, and the server logs `no users yet` at startup. After that first account exists, people without an account see "This server doesn't take new accounts" when they try to sign in. Because the first sign-in on an empty server always succeeds, sign in yourself as soon as the server is reachable, or set `allowed_email_domains` before you expose it. To let more people create accounts, set `allow_signup = true` (`CRONPAD_AUTH__ALLOW_SIGNUP=true`), ideally together with `allowed_email_domains`. Development login is local-only and always creates accounts. ## Users and workspaces Each person who signs in for the first time gets a user and a personal workspace of their own. Jobs, runs, secrets, alert channels and API keys belong to that workspace, and only its owner sees them. A self-hosted server has no shared workspaces, members or invitations: two people who sign in with the same provider still see separate dashboards. To let a script, a CI pipeline or an assistant manage someone's jobs, give it an API key for that person's workspace: `POST /api/v1/api-keys` with only the scopes it needs (the dashboard's **API keys** tab shows the command), or `cronpad user create-key` on the server. ## Development login ```sh CRONPAD_AUTH__DEV_LOGIN=true ``` Shows a simple name field on the sign-in page with no password. cronpad logs a warning on every start while it's on. Use it only on your own machine. ## API keys without a browser To automate a fresh server before anyone has opened the dashboard, sign in once through the dashboard, then mint a key from the server itself: ```sh cronpad user create-key --email you@example.com --name provisioning ``` ## Sessions Session cookies (`cp_session`) are `HttpOnly`, `SameSite=Lax`, `Secure` when `public_url` is `https`, and last 30 days (`auth.session_lifetime`). Signing out (`POST /auth/logout`) deletes the session on the server. Login also sets `cp_csrf`; send its value in `X-CSRF-Token` on cookie-authenticated mutations. OAuth state, PKCE verifier and nonce live in a signed, ten-minute `HttpOnly` cookie. Hosted sign-in endpoints allow ten requests per minute per peer IP. ## Generic OIDC Configure one instance-wide OpenID Connect provider. cronpad discovers the provider's endpoints and signing keys, uses authorization code flow with PKCE and nonce validation, and stores identities as `oidc:default`. This provider satisfies the startup requirement for a configured OAuth provider. ```toml [auth.oidc] issuer = "https://auth.example.com/application/o/cronpad/" client_id = "cronpad" client_secret = "replace-with-your-client-secret" display_name = "Company SSO" scopes = ["openid", "email", "profile"] require_email_verified = true ``` Register the exact redirect URI `/auth/oidc:default/callback` with your identity provider. Set `server.public_url` to the public HTTPS origin. Keep the client secret on the server; for example, set `CRONPAD_AUTH__OIDC__CLIENT_SECRET` instead of saving it in a configuration file. The dashboard uses `display_name` for the login button; the default is **SSO**. The unauthenticated `/api/v1/features` endpoint lists the provider and its label without credentials. ### Authentik Create an OAuth2/OpenID Connect provider and an application with slug `cronpad`. Use a confidential client, authorization code flow, and the redirect URI above. Select scope mappings for `openid`, `email` and `profile`, and use a signing key. Use per-provider issuer mode so the configured issuer and discovery document match: ```toml [auth.oidc] issuer = "https://auth.example.com/application/o/cronpad/" client_id = "your-authentik-client-id" client_secret = "your-authentik-client-secret" display_name = "Authentik" ``` The application slug forms the issuer path. See [Authentik's OAuth2 provider reference](https://docs.goauthentik.io/add-secure-apps/providers/oauth2/). ### Keycloak Create an OpenID Connect client named `cronpad` in your realm, enable client authentication and Standard Flow, and set the exact redirect URI above. Ensure users have verified email addresses and that the email/profile scopes include the expected claims. Copy the client secret into cronpad: ```toml [auth.oidc] issuer = "https://identity.example.com/realms/company" client_id = "cronpad" client_secret = "your-keycloak-client-secret" display_name = "Keycloak" ``` The issuer is the realm URL, without `/.well-known/openid-configuration`. See [Keycloak's OIDC endpoint reference](https://www.keycloak.org/securing-apps/oidc-layers). ### Email trust and workspace policies The default requires an `email_verified` claim. `allowed_email_domains` and `allow_signup` apply to OIDC too, and matching trusted email addresses link identities. Only disable `require_email_verified` for a provider you trust to assert authoritative email addresses: disabling it also permits email-based account linking without that claim. Per-workspace providers, SAML and SSO enforcement are Enterprise features; instance-wide OIDC needs no license. --- # Backups with Litestream By default cronpad runs on plain SQLite with no replication. For continuous backups, its container image includes Litestream, which you turn on with one environment variable. It then streams every change to both SQLite files to object storage (S3, GCS, Azure Blob, any S3-compatible service, SFTP) within about a second, and on startup, if the data volume is empty, the container restores the latest copy automatically. You can also restore to any point in time within your retention window. The queue file (`cronpad-queue.db`) needs no backup: cronpad rebuilds it from the restored database. ## Enable replication Set the replica location and credentials on the container: ```sh LITESTREAM_REPLICA_URL=s3://my-bucket/cronpad AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=… # For S3-compatible services such as SeaweedFS, Garage or Cloudflare R2: # LITESTREAM_ENDPOINT=https://.r2.cloudflarestorage.com ``` When `LITESTREAM_REPLICA_URL` is set, the entrypoint runs cronpad under Litestream with this configuration (`deploy/litestream.yml`): ```yaml dbs: - path: /data/cronpad.db replicas: - url: ${LITESTREAM_REPLICA_URL}/control - path: /data/history.db replicas: - url: ${LITESTREAM_REPLICA_URL}/history ``` Without it, cronpad runs as plain SQLite with no replication; see [SQLite only](#sqlite-only-the-default). :::note Litestream's configuration format has changed between major versions. The file in `deploy/` matches the Litestream version bundled in the image; check the Litestream documentation if you run your own. ::: ## SQLite only (the default) With `LITESTREAM_REPLICA_URL` unset, the entrypoint runs plain `cronpad serve`: no restore on start, no replication. Startup logs one INFO line, `running SQLite only; set LITESTREAM_REPLICA_URL to replicate`. The bare binary always runs this way, since Litestream is only bundled in the container image. Earlier releases warned when no replica was set and offered `LITESTREAM_DISABLED=true` to silence it. That variable is no longer used: if it's still set, startup mentions it at INFO level and otherwise ignores it. What it means: your data lives only in the two SQLite files, `cronpad.db` (jobs, keys, settings) and `history.db` (run history), on the data volume. Nothing is streamed anywhere, so a lost disk loses everything written since your last copy. The queue file still needs no backup. It's a reasonable choice for local development, evaluation, and small installs where losing a day of history is acceptable or the host already snapshots its disks. Keep a separate copy of `secrets.encryption_key` regardless. Simple alternatives to continuous replication: - **Scheduled `sqlite3 .backup`.** Back up **both** databases, for example from cron or a systemd timer: ```sh sqlite3 /data/cronpad.db ".backup '/backups/cronpad-$(date +%F).db'" sqlite3 /data/history.db ".backup '/backups/history-$(date +%F).db'" ``` `.backup` takes a consistent snapshot while cronpad is running. To restore, stop cronpad, put the files back as `cronpad.db` and `history.db`, and delete any leftover `-wal` and `-shm` files next to them. - **Volume or disk snapshots** from your cloud provider or filesystem (ZFS, LVM, EBS), taken as a single atomic snapshot of the volume holding both files. :::warning Don't copy a live database with `cp`, `rsync` or a file-level backup tool. cronpad uses WAL mode, so recent writes sit in a `-wal` file; copying only the main file, or copying the files at different moments, produces a corrupt or inconsistent backup. Use `.backup`, a volume snapshot, or stop cronpad first. ::: ## Automatic restore on start On start, the container: 1. Restores `cronpad.db` from the replica if the file doesn't exist and a replica does. 2. Restores `history.db` the same way, into a temporary file. If that fails, takes more than 5 seconds, or the replica is missing, cronpad starts with an empty history and logs an error. Set `LITESTREAM_HISTORY_RESTORE_TIMEOUT` to change this bound. A partial restore is never opened. 3. Starts replication and cronpad. So recovering from a lost server is: start a new container with the same `LITESTREAM_REPLICA_URL` and an empty volume. The shell-free container runs `cronpad entrypoint`, which performs these steps and replaces itself with `litestream replicate -exec "cronpad serve"`. The bundled CLI is pinned to Litestream 0.3.13; the optional embedded library is omitted because its API is not stable. Control restore errors stop startup to avoid scheduling from an incomplete control database. ## Manual and point-in-time restore Stop cronpad first, then: ```sh # Latest state litestream restore -o /data/cronpad.db "$LITESTREAM_REPLICA_URL/control" litestream restore -o /data/history.db "$LITESTREAM_REPLICA_URL/history" # As of a moment in the past litestream restore -timestamp 2026-11-10T03:00:00Z -o /data/cronpad.db "$LITESTREAM_REPLICA_URL/control" ``` After a point-in-time restore, cronpad's [misfire policy](../schedules/misfires-and-overlap.md) decides what happens to runs that came due between the restore point and now. ## What else to keep safe - **`secrets.encryption_key`.** Store it in your secret manager. Restored [secrets](../secrets/index.md) can't be decrypted without it. - **OAuth client secrets and SMTP credentials**, which live in your config or environment, not the database. ## Test your restores Run `tools/litestream-restore-test.sh` from the repository for a local `file://` recovery exercise. It downloads the pinned Litestream binary into scratch only when missing, creates a job and runs, destroys the whole data directory (including the queue file), then verifies restore and resumed scheduling. With the Litestream binary cached it runs without network access. Restore into a scratch directory regularly and start a throwaway cronpad on it. Its queue file is created inside the scratch directory, so it can't touch your production server's queue. It will schedule the restored jobs, though, so start it with `CRONPAD_SINKS__WEBHOOK__ENABLED=false` and `CRONPAD_SINKS__COMMAND__ENABLED=false`: runs then fail with "sink disabled or unavailable" instead of reaching your real endpoints. A backup you haven't restored is a hope, not a backup. --- # Production checklist Go through this list before you rely on a self-hosted cronpad. Each item prevents a specific failure: lost data, duplicate scheduling, silent outages or leaked secrets. ## Data - [ ] **One cronpad process per data directory.** Kubernetes: `replicas: 1`, `strategy: Recreate`. Two processes on one SQLite file can corrupt it. (A second process started by mistake waits in standby, because the dispatcher lock lives in the queue file, but don't rely on it.) - [ ] **Local or block storage**, never NFS/SMB/EFS for the database and queue files. Keep the queue file (`cronpad-queue.db`) on the same volume, and leave it out of any backup or restore. - [ ] **Backups in place.** cronpad runs on [plain SQLite](backups.md#sqlite-only-the-default) unless you turn replication on, so pick one: Litestream (`LITESTREAM_REPLICA_URL` set) with a [tested restore](backups.md#test-your-restores), or scheduled `sqlite3 .backup` of both databases, or volume snapshots. - [ ] **`secrets.encryption_key` set** from your secret manager, with a copy kept outside the server. - [ ] **Retention** set for your needs and disk size ([Data retention](../platform/data-retention.md)). ## Access - [ ] `server.public_url` is the real `https://` URL, so cookies are `Secure` and OAuth callbacks work. - [ ] At least one OAuth provider configured; `auth.dev_login` is `false`. - [ ] You signed in first. On an empty server the first sign-in creates an account even with sign-up off, so sign in before exposing the server, or set `auth.allowed_email_domains` ([First sign-in](authentication.md#first-sign-in)). - [ ] `auth.allow_signup` left `false` (the default), or, if you turned it on, `auth.allowed_email_domains` set to your organization's domains. - [ ] Commands restricted with `sinks.command.allowed_commands`, or disabled if you don't use them. - [ ] TLS terminated at your proxy or load balancer, with `server.trusted_proxies` set. ## Operations - [ ] Liveness probe on `/healthz`, readiness probe and external uptime check on `/readyz`. - [ ] Stop timeout longer than `server.shutdown_grace` (default 30 s), so in-flight runs finish on deploy. - [ ] Metrics scraped or exported ([Observability](../observability/index.md)), with alerts on at least: - `/readyz` failing; - `cronpad.queue.oldest_due_age` above 30 s (work is waiting); - `cronpad.leases.expired` increasing (workers crashing or hanging); - SLA breach ratio above 1%. - [ ] Logs collected, in `json` for a log pipeline or `logfmt` for humans. - [ ] Alert email (SMTP) configured, so auto-disable notifications reach someone. ## Expectations - A deploy or restart pauses scheduling for a few seconds. Runs due during it start late and are flagged as SLA breaches; none are lost. - Receivers should be idempotent ([Delivery guarantees](../concepts/delivery-guarantees.md)). --- # Upgrading Upgrading cronpad means replacing the binary or image and restarting. Database migrations run automatically on start. Because only one process may own the database, an upgrade is a stop-then-start, which pauses scheduling for a few seconds; runs due in that window fire as soon as the new version is up. ## Steps 1. Read the release notes for the versions you're skipping. 2. Make sure Litestream replication is healthy (or take a copy of the data directory). 3. Verify the release archive against `checksums.txt` and review its SPDX SBOM if installing a binary. Replace the image tag or binary and restart (`docker compose pull && docker compose up -d`, or roll out the new image with `strategy: Recreate`). 4. Watch the logs for `server started` and check `/readyz`. ## Versioning - cronpad follows semantic versioning. Minor and patch releases are drop-in. - Major releases may change configuration or the API. `/api/v1` stays compatible for as long as it exists; breaking API changes get a new version path. - Migrations only move forward. To roll back to an older version after a migration, restore the database from before the upgrade. ## Running migrations separately If you set `database.run_migrations = false`, run them yourself before starting the new version: ```sh cronpad migrate --config /etc/cronpad/cronpad.toml ``` --- # Observability cronpad is instrumented with OpenTelemetry. It exports metrics and traces over OTLP to any collector, can expose a Prometheus `/metrics` endpoint, and writes structured logs in JSON or logfmt in which every line about a run carries the job ID, run ID and trace ID. A Grafana dashboard and Prometheus alert rules ship in the repository. | Signal | How to get it | Page | |---|---|---| | Metrics | OTLP (`telemetry.otlp_endpoint`) or Prometheus (`telemetry.prometheus_addr`) | [Metrics](metrics.md) | | Logs | stdout, `json` or `logfmt`; optionally OTLP | [Logging](logging.md) | | Traces | OTLP | below | ## Quick start with Prometheus ```toml [telemetry] prometheus_addr = ":9090" ``` The Compose observability profile scrapes `http://cronpad:9090/metrics` directly. Import `deploy/grafana/cronpad-dashboard.json` into Grafana and load `deploy/prometheus/alerts.yml` into Prometheus. ## Quick start with an OpenTelemetry Collector ```toml [telemetry] otlp_endpoint = "otel-collector:4317" otlp_protocol = "grpc" otlp_logs = true ``` ## Traces Each run attempt is a span named `cronpad.run` with attributes `cronpad.job_id`, `cronpad.run_id`, `cronpad.attempt`, `cronpad.sink` and `cronpad.outcome`. Webhook requests are child spans with standard HTTP attributes, and cronpad sends a `traceparent` header, so if your endpoint is instrumented its trace joins cronpad's. API requests are traced too. `/readyz` reports dependency and dispatcher health separately from the process liveness probe `/healthz`. `telemetry.traces_sample_ratio` (default `0.1`) controls the sampled fraction. ## The four numbers to watch | Question | Metric | |---|---| | Are runs starting on time? | `cronpad.run.start_lag` p99 and `cronpad.runs.sla_breached` | | Is work piling up? | `cronpad.queue.oldest_due_age` | | Are workers healthy? | `cronpad.leases.expired` (should stay at 0) | | Are jobs succeeding? | `cronpad.runs` by `status` | --- # Metrics cronpad's metrics use OpenTelemetry names (dot-separated). The Prometheus exporter converts them to Prometheus conventions: dots become underscores and the unit becomes a suffix, so `cronpad.run.start_lag` (milliseconds) is exposed as `cronpad_run_start_lag_milliseconds_bucket`, `_sum` and `_count`. Labels are deliberately low-cardinality: there is never a per-job or per-workspace label. Per-job numbers are in the dashboard and the [stats API](../api/workspace.md#stats). ## Runs | Metric | Type | Labels | Description | |---|---|---|---| | `cronpad.run.start_lag` | histogram (ms) | `sink`, `trigger` | Scheduled time → first attempt started. The SLA number. | | `cronpad.run.queue_wait` | histogram (ms) | | Claimed → started by a worker. | | `cronpad.run.sink_duration` | histogram (ms) | `sink`, `outcome` | Duration of each attempt. | | `cronpad.run.e2e` | histogram (ms) | `sink`, `status` | Scheduled time → final attempt finished, including retries. | | `cronpad.runs` | counter | `sink`, `status` | Runs reaching a final or deferred state: `succeeded`, `failed`, `deferred`, `skipped`, `cancelled`. | | `cronpad.runs.sla_breached` | counter | `sink` | Runs whose start lag exceeded their SLA threshold. | | `cronpad.attempts` | counter | `sink`, `outcome` | Attempts by `success`, `retryable`, `fatal`, `timeout`. | | `cronpad.duplicates_suppressed` | counter | | Redeliveries of already-finished runs that were skipped. | Histogram buckets (ms): 1, 5, 10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000. `cronpad.results.batch_size` instead uses command-count buckets 1, 10, 50, 100, 250, 500. Observable queue gauges are sampled every five seconds. ## Scheduler | Metric | Type | Labels | Description | |---|---|---|---| | `cronpad.dispatcher.concurrency_deferred` | counter | | Claims deferred 250ms by workspace concurrency caps; no workspace label. | | `cronpad.dispatcher.claimed` | counter | | Occurrences claimed. | | `cronpad.dispatcher.tick_duration` | histogram (ms) | | Time per claim-loop iteration. | | `cronpad.dispatcher.backpressure` | counter | | Iterations where all workers were busy. Rising = add workers. | | `cronpad.dispatcher.lock_held` | gauge (0/1) | | Whether this process is the active dispatcher. | | `cronpad.queue.due_size` | gauge | | Entries in the `due` queue (future and due). | | `cronpad.queue.inflight_size` | gauge | | Entries currently leased. | | `cronpad.queue.oldest_due_age` | gauge (ms) | | How long the oldest due-but-unclaimed entry has waited. The best single "falling behind" signal. | | `cronpad.leases.expired` | counter | | Leases that expired and were redelivered. Should be 0. | | `cronpad.leases.lost` | counter | | Workers that found their lease taken while still running. | | `cronpad.reconciler.repaired` | counter | `kind` | Queue entries restored from the database (`lost_member`, `lost_retry`, `lost_inflight`, `full_sweep`). | | `cronpad.reconciler.backlogged` | counter | `kind` | Overdue jobs that were simply waiting in the queue (load, not loss). | ## Storage and background work | Metric | Type | Labels | Description | |---|---|---|---| | `cronpad.results.batch_size` | histogram | `db` | Writes per database transaction. | | `cronpad.results.flush_duration` | histogram (ms) | `db` | Time per batched transaction. | | `cronpad.janitor.deleted` | counter | `table` | Rows removed by retention. | | `cronpad.janitor.duration` | histogram (ms) | | Time per pruning pass. | | `cronpad.queue.errors` | counter | `op` | Queue file failures (claim, lease renewal, lock). Called `cronpad.redis.errors` before Redis was removed. | | `cronpad.db.errors` | counter | `op` | Database errors. | ## Jobs and alerts | Metric | Type | Labels | Description | |---|---|---|---| | `cronpad.jobs` | gauge | `state` | Jobs by state. | | `cronpad.jobs.auto_disabled` | counter | | Jobs disabled for consecutive failures. | | `cronpad.alerts.sent` | counter | `channel`, `status` | Alert deliveries by channel kind and result. | ## Suggested alerts ```yaml groups: - name: cronpad rules: - alert: CronpadFallingBehind expr: cronpad_queue_oldest_due_age_milliseconds > 30000 for: 2m - alert: CronpadWorkersDying expr: increase(cronpad_leases_expired_total[10m]) > 0 - alert: CronpadSLABreaches expr: sum(rate(cronpad_runs_sla_breached_total[15m])) / sum(rate(cronpad_runs_total[15m])) > 0.01 for: 15m - alert: CronpadNotDispatching expr: max(cronpad_dispatcher_lock_held) == 0 for: 1m ``` The shipped alert rules also include `CronpadNotReady`, using a blackbox-exporter probe of `/readyz` with job label `cronpad-readyz`. --- # Logging cronpad writes structured logs to standard output, in JSON (default) or logfmt. Every line about a run automatically carries the job ID, run ID, occurrence ID, attempt, workspace and trace ID, however deep in the code it was written, so you can filter one job's or one run's entire story with a single query. Secrets, tokens and authorization headers are redacted. ## Format ```toml [logging] format = "logfmt" # or "json" level = "info" # debug | info | warn | error add_source = false # add source=file.go:123 ``` The same fields are emitted in both formats, so switching never breaks your queries. **logfmt:** ``` time=2026-11-10T03:30:00.118Z level=INFO msg="run finished" service=cronpad version=1.0.0 job_id=job_01J9ZK3Q run_id=run_01J9ZM1A occ="job_01J9ZK3Q|1794281400000|0|3|s" attempt=0 trigger=s workspace_id=ws_01J9X sink=webhook outcome=success status_code=200 duration_ms=104 start_lag_ms=6 sla_breached=false trace_id=4bf92f3577b34da6a3ce929d0e0e4736 ``` **json:** ```json {"time":"2026-11-10T03:30:00.118Z","level":"INFO","msg":"run finished","service":"cronpad","version":"1.0.0","job_id":"job_01J9ZK3Q","run_id":"run_01J9ZM1A","occ":"job_01J9ZK3Q|1794281400000|0|3|s","attempt":0,"trigger":"s","workspace_id":"ws_01J9X","sink":"webhook","outcome":"success","status_code":200,"duration_ms":104,"start_lag_ms":6,"sla_breached":false,"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"} ``` ## Standard fields | Context | Fields | |---|---| | Every line | `time`, `level`, `msg`, `service`, `version`; `trace_id` and `span_id` when inside a traced operation | | Anything during a run | `job_id`, `run_id`, `occ`, `attempt`, `trigger`, `scheduled_at`, `workspace_id`, `sink` | | Run finished | `outcome`, `status_code`, `duration_ms`, `start_lag_ms`, `sla_breached`, `error` | | API requests | `request_id`, `method`, `route` (the route pattern, e.g. `/api/v1/jobs/{id}`), `status`, `duration_ms`, `workspace_id`, `actor` | `occ` is the [occurrence ID](../concepts/scheduling-engine.md#occurrence-ids): it identifies one fire of one job, including the attempt, which is exactly what you want when tracing a duplicate delivery. ## Useful queries ```sh # Everything that happened to one run (logfmt + grep) grep 'run_id=run_01J9ZM1A' cronpad.log # Loki: failed attempts for one job {app="cronpad"} | logfmt | job_id="job_01J9ZK3Q" and outcome!="success" # jq: slowest starts jq -c 'select(.msg=="run finished" and .start_lag_ms > 1000)' cronpad.json.log ``` ## What's logged at each level | Level | Contents | |---|---| | `error` | Things an operator must act on: database or queue failures, panics recovered in workers, failed alert deliveries after all retries | | `warn` | Degraded but handled: lease lost, queue entry repaired, queue data loss detected, dev login enabled, missing Litestream | | `info` | One line per finished run, one per API request, startup/shutdown, janitor passes, job state changes | | `debug` | Claim-loop iterations, retries scheduled, cache misses | At high volume, `info` is about three lines per run. Set `level = "warn"` to log only problems; run history is still recorded in the database. ## Redaction Values of fields named like `authorization`, `cookie`, `token`, `secret`, `password`, `api_key` or `signature` are replaced with `[REDACTED]`. Webhook and command configs are never logged whole; only a request summary (`POST https://api.example.com/reports (231 bytes)`). Secret values substituted from `{{secret.NAME}}` never appear in logs. ## Shipping logs elsewhere Logs go to stdout for your container runtime or journald to collect. To also send them over OTLP to an OpenTelemetry Collector, set `telemetry.otlp_logs = true`; the trace and run fields become OTLP log attributes. --- # Platform Reference pages describing the boundaries cronpad operates within. - [Limits](limits.md): free-tier limits on cronpad Cloud and technical limits everywhere. - [Data retention](data-retention.md): how long runs, attempts, logs and alerts are kept. - [Security](security.md): authentication, isolation, secrets, SSRF protection and hosted mode. - [Editions](editions.md): the open-source, Enterprise and Cloud offerings, and licensing. --- # Limits Self-hosted servers have no limits by default. Operators set them in the `[limits]` section of the configuration; a value of `0` means unlimited. cronpad Cloud applies limits by plan; see [editions](editions.md). ## Monthly runs A run counts once when it first starts, whether scheduled, manually triggered, or manually retried. Automatic retry attempts, skipped and cancelled occurrences, and `/jobs/test` dry runs do not count. Months are UTC calendar months and reset at midnight UTC on the first day. Deleting or pruning run history does not give quota back. With `over_quota = "block"`, new occurrences at the cap are recorded as `skipped` with `skip_reason = "quota_exceeded"`. The schedule keeps advancing, so jobs resume next month or after a plan upgrade. With `"allow"`, jobs keep firing and usage continues to count beyond the limit. Manual trigger requests at a blocking cap return `429`: ```json {"error":{"code":"limit_exceeded","limit":"max_runs_per_month_per_workspace","value":50000}} ``` At 80% and 100% of the monthly limit, workspace owners are emailed (when SMTP is configured) and the `workspace.quota_warning` and `workspace.quota_exceeded` [alert events](../alerts/index.md#events) fire. Each notice is sent once per workspace per month and is not affected by the daily alert-email cap. `GET /api/v1/usage` returns the current month's usage; see the [Workspace API](../api/workspace.md#usage-and-suspension). If cronpad crashes, the last few milliseconds of usage counts and speculative deliveries (at most 50 ms of work) can be lost, so receivers should honor idempotency keys. ## Concurrency and suspension `max_concurrent_runs_per_workspace` caps how many runs a workspace executes at once. Excess occurrences wait briefly (250 ms) and are retried without being skipped. Their start lag is visible in run history, but lateness caused by the cap is not counted as an SLA breach. Operators can suspend a workspace, for example for abuse: ```sh cronpad workspace suspend --reason "abuse" cronpad workspace unsuspend ``` These commands run against the local database. A suspended workspace stays readable, but API changes return `403 workspace_suspended`, scheduled occurrences are recorded as `skipped` with `skip_reason = "workspace_suspended"`, and alerts and webhook deliveries stop. Both actions are recorded in the audit log. ## Outbound controls In hosted mode, `sinks.webhook.per_host_rate_limit` applies a shared limit per destination host, default 20 requests/second. Excess requests wait; they are not dropped. Self-hosted mode leaves this off. `max_alert_emails_per_day` suppresses ordinary alert emails after the workspace's daily cap; the next email that is sent reports how many were suppressed. Zero disables either control. ## Other limits | Setting | Meaning | |---|---| | `max_jobs_per_workspace` | Maximum jobs | | `min_interval` | Minimum interval between occurrences | | `max_api_keys_per_workspace` | Maximum API keys | | `max_retry_attempts` | Maximum attempts per run | | `api_rate_limit_per_minute` | Requests per API key or session | The deprecated `max_runs_per_day_per_workspace` is still accepted for now. It is converted to a monthly cap by multiplying by 30, with a startup warning; an explicitly configured monthly value takes precedence. See [Configuration](../self-hosting/configuration.md#limits) and [Data retention](data-retention.md). --- # Data retention Run history, attempts, the audit log and sent alerts grow with every run, so cronpad deletes them after a retention window: 30 days by default, 7 days for response bodies. Jobs, users, keys and settings are never pruned. A background task deletes old rows in small batches and returns the space to the operating system, so pruning never delays scheduling. ## Defaults | Data | Kept for | Setting | |---|---|---| | Runs and their attempt records | 30 days | `retention.runs` | | Response bodies and command output | 7 days, non-successful attempts only | `retention.attempt_bodies`, `retention.store_success_bodies` | | Audit log | 30 days | `retention.audit_log` | | Sent, failed and suppressed alerts | 30 days | `retention.alert_outbox` | | Pending alerts | Until delivered | — | | Daily statistics (dashboard charts) | 400 days | `retention.job_stats_daily` | | Sessions | Until they expire | `auth.session_lifetime` | | Jobs, workspaces, users, API keys, secrets, alert settings | Until you delete them | — | Set any window to `"0"` on a self-hosted server to keep that data forever. ## How pruning works - Every 5 minutes, the janitor deletes expired rows in batches of 1,000, each in its own short transaction, and pauses whenever the database writer is busy. - Charts don't lose history: daily statistics are rolled up before the raw runs they summarize are deleted. Runs that are still running or deferred are kept until they finish. - Freed space is returned to the filesystem incrementally, so the database files shrink rather than only grow. - Monthly usage counts are independent of run history: pruning a run does not return a quota slot. Monthly usage records are kept for 400 days. - Each pass is logged and counted in `cronpad.janitor.deleted`. ## Deleting a job Deleting a job stops it immediately. Its runs remain visible in history until they age out under the same windows. ## Estimating disk usage A run with its attempt metadata takes roughly 300–500 bytes. Response bodies add up to 64 KiB each when stored. | Runs per day | 30 days of runs (no bodies) | |---|---| | 10,000 | ~150 MB | | 1,000,000 | ~10–15 GB | Turning on `store_success_bodies` can multiply this many times over, depending on how large your endpoints' responses are. ## Workspace plans On cronpad Cloud, run, response-body and audit-log retention follow the workspace's plan; see [editions](editions.md). Self-hosted servers use the global settings above. --- # Security cronpad isolates workspaces from each other, encrypts stored secrets, signs every outgoing webhook, and refuses to send requests to internal networks on the hosted service. Self-hosted servers trust their operators and enable powerful features (shell commands, private-network webhooks) by default; setting `mode = "hosted"` switches every one of them to the safe default for a public, multi-tenant server. ## Accounts and access - Sign-in only through Google or GitHub (verified email required); no passwords are stored. - API keys are random, shown once and stored as SHA-256 hashes. Each belongs to one workspace and can be scoped read-only. - Session cookies are `HttpOnly`, `SameSite=Lax` and `Secure` over HTTPS; requests that change data with a cookie also need a CSRF token. - Every database query is scoped to the caller's workspace. Requests for another workspace's objects return `404`, so IDs can't be probed. - Every change (create, update, pause, delete, key creation, secret change, auto-disable) is written to the audit log with the actor. Development authentication is opt-in with `auth.dev_login = true`. It trusts `X-Dev-User` (default `developer`) and creates a personal workspace for that identity; use it only on a trusted development server, never on a public deployment. ## Secrets Stored [secrets](../secrets/index.md) are encrypted with AES-256-GCM, are write-only through the API, and are redacted from run history, logs and traces. Webhook signing secrets and cached idempotency responses are also encrypted with the same key. Protect the database, encryption key and backups as credentials. ## Outgoing requests - **Signed.** Every webhook carries a Standard Webhooks HMAC signature and timestamp so receivers can reject forgeries and replays. See [Verify signatures](../sinks/webhooks.md#verify-signatures). - **SSRF protection.** In hosted mode, cronpad resolves each hostname itself and refuses loopback, private, link-local (including cloud metadata at `169.254.169.254`), carrier-grade NAT, IPv6 unique-local and deprecated site-local, multicast, unspecified, documentation and benchmarking addresses, the IETF `192.0.0.0/24` range, and NAT64, 6to4, Teredo and deprecated IPv4-compatible translation/tunnel prefixes, on every connection and every redirect hop. Only ports 80 and 443 are allowed. - **Bounded.** Timeouts, response size caps, and [auto-disable](../retries-and-failures/auto-disable.md) stop a job from hammering an endpoint indefinitely. ## Hosted mode `mode = "hosted"` is how cronpad Cloud runs, and what you should use if strangers can sign up to your server. | Setting | Self-hosted default | Hosted mode | |---|---|---| | Command sink | On | Off | | Webhooks to private networks | Allowed | Blocked | | Email alert channels | Usable immediately | Address must be verified | | Sign-up | Off after the first user (`auth.allow_signup`) | Rate-limited per IP; turn on with `auth.allow_signup = true` | | Retry attempts, timeouts, auto-disable threshold | Unlimited / per job | Capped by server limits | | Usage limits | Unlimited | From `[limits]` | Each can still be overridden explicitly in configuration. ## Reporting a vulnerability Please report security issues privately through the repository's security advisory page (**Security → Report a vulnerability**) rather than in a public issue. --- # Editions cronpad has three lineups: **OSS** for self-hosting the complete open-source scheduler, **Enterprise** for self-hosted organization features under a commercial license, and **Cloud** for managed hosting with Free, Pro and Enterprise plans. Enterprise features are **coming soon — [contact us](mailto:contact@cronpad.com)** for availability. Plan prices and limits may change. ## Three lineups | Lineup | License | Entitlements | |---|---|---| | cronpad OSS | AGPL-3.0 | All OSS features always on | | cronpad Enterprise (self-hosted) | Commercial license covering the bundled OSS core | Instance-wide license key | | cronpad Cloud | Hosted service | Per-workspace Free / Pro / Enterprise plan | The whole scheduler stays open: schedule syntaxes, sinks, retries, misfire handling, alerts, metrics, MCP, CLI and dashboard. Google, GitHub and one instance-wide generic OIDC provider belong to OSS. Command sinks remain subject to the operator's hosted-mode safety policy. ## Feature and plan matrix Enterprise-only entries are **coming soon — [contact us](mailto:contact@cronpad.com)**, including license keys, SAML, workspace IdPs, SSO enforcement, SCIM, custom roles and audit streaming. Cloud Enterprise offers the same Enterprise features as self-hosted Enterprise, with a workspace plan instead of an instance license. | | Cloud Free | Cloud Pro | Cloud Enterprise | Self-host OSS | Self-host Enterprise | |---|---|---|---|---|---| | Price | $0 | $19/month | [Contact us](mailto:contact@cronpad.com) | Free forever | [Contact us](mailto:contact@cronpad.com) | | Jobs per workspace | 50 | 1,000 | Custom | Unlimited by default | Unlimited by default | | Runs per UTC calendar month | 50,000 hard cap | 2,000,000 included; then $1/100k, optional spend cap | Custom | Unlimited by default | Unlimited by default | | Over quota | New occurrences recorded as `skipped`, `quota_exceeded` | Overage; at spend cap, behaves like Free | Contractual | Operator-set limits | Operator-set limits | | Retries count toward quota | Never | Never | Never | Never | Never | | Minimum interval | 1 minute | 10 seconds | 1 second | Operator-configured | Operator-configured | | Concurrent runs per workspace | 5 | 50 | Custom | Operator-configured | Operator-configured | | Run history retention | 7 days | 90 days | 1 year+ | Operator-configured | Operator-configured | | Response body retention | 1 day | 7 days | 30 days | Operator-configured | Operator-configured | | Members | Unlimited | Unlimited | Unlimited | Operator-configured | Operator-configured | | API keys | 5 | 50 | Custom | Operator-configured | Operator-configured | | Authentication | Google, GitHub | Google, GitHub | Google/GitHub + SAML, workspace OIDC, multiple IdPs, SSO enforcement | Google/GitHub + instance-wide OIDC | OSS + SAML, workspace OIDC, multiple IdPs, SSO enforcement | | Roles | Owner / member | Owner / member | Custom roles | Owner / member | Custom roles | | SCIM | — | — | Coming soon | — | Coming soon | | Audit log | UI, 7 days | UI, 90 days | UI, 1 year+; SIEM/S3 streaming | Operator-configured retention | OSS + streaming | | On-time service commitment | Best effort | Published 99.9% monthly SLO, no credits | Contractual SLA with credits; dedicated single-tenant | Operator-managed | Support terms by agreement | | Infrastructure | Shared | Shared, priority claims | Dedicated single-tenant, region choice, static egress IPs | Your infrastructure | Your infrastructure | | Support | Community | Email | Named contact, onboarding, DPA, security review | Community | SLA support, advance security advisories | | Multi-node HA | — | — | [Contact us](mailto:contact@cronpad.com) | — | [Contact us](mailto:contact@cronpad.com) | | License | Service | Service | Service | AGPL-3.0 | Commercial, covering bundled OSS core | Self-hosted limits are disabled by default (`0 = unlimited`); the operator can set them. Retention and safety settings still apply. One every-minute job uses about 43,200 runs in a 30-day month, fitting the Free quota; two do not. “On time” means the first attempt starts within 5 seconds. The Pro SLO excludes announced maintenance and delays caused by the workspace's own concurrency cap. See [delivery guarantees](../concepts/delivery-guarantees.md) for the SLO/SLA definitions and planned maintenance windows. ## License and reliability A license, downgrade or billing problem never stops scheduling. The exceptions to continued admission are an exceeded Free-tier quota (or a Pro spend cap that adopts that behavior) and operator suspension for abuse; both produce visible skipped runs with a reason. Expired Enterprise licenses leave existing SSO logins, roles and audit streams working and make Enterprise configuration read-only after a grace period. --- # Licensing cronpad OSS is licensed under **AGPL-3.0**. Self-hosted cronpad Enterprise is offered under a separate commercial license; cronpad Cloud is a hosted service. Enterprise features are **coming soon — [contact us](mailto:contact@cronpad.com)**. ## Open-source license Read the complete [LICENSE](https://github.com/gowthamgts/cronpad/blob/main/LICENSE) for the permissions and conditions that apply to OSS, including its source-sharing requirements for modified network services. See [editions](editions.md) for what each offering includes. ## Commercial license The Enterprise commercial license replaces the AGPL obligations for the covered software, including its bundled OSS core. [Contact us](mailto:contact@cronpad.com) to discuss terms and availability. ## Contributor agreement Contributors keep their copyright. The [CLA](https://github.com/gowthamgts/cronpad/blob/main/CLA.md) lets the project owner also license contributions commercially and use them in proprietary and hosted offerings, without notice or payment. Only version 1.1 signatures count for new contributions. A contributor who signed 1.0 re-signs on their next PR; already-merged code remains covered by the 1.0 grant. See [how to contribute](https://github.com/gowthamgts/cronpad/blob/main/CONTRIBUTING.md). --- # Troubleshooting Start with the job's run history (dashboard, `GET /api/v1/jobs/{id}/runs`, or the `get_run` MCP tool): most questions are answered by a run's status, start lag and attempts. For server-level problems, check `/readyz` and the logs, which carry `job_id` and `run_id` on every relevant line. ## A job didn't fire | Check | How | Fix | |---|---|---| | Is it active? | Job `state` | `paused` → resume. `disabled` → read `disabled_reason`, fix, re-enable. `completed` → the schedule ran out (`count`, `until`, one-off). | | When is it due? | `next_runs` on the job, or `cronpad schedule next` | Check the timezone. `0 9 * * *` in `UTC` is 14:30 in India. | | Was it skipped? | Runs with status `skipped` | Misfire policy `skip`, or `overlap_policy: skip_if_running` with a run still in progress. | | Is the server scheduling? | `/readyz` → `dispatcher`, `queue` | `standby`: another process holds the dispatcher lock (two servers on one data directory). `queue: false`: the queue file can't be read or written; check disk space and permissions on the data volume. | ## Runs start late - Look at `start_lag_ms` on the run, and `cronpad.queue.oldest_due_age` and `cronpad.dispatcher.backpressure` on the server. - **Lots of jobs due at the same second** (e.g. midnight) with slow endpoints use up the worker pool. Increase `workers.count`, or spread schedules (`5 0 * * *` instead of `0 0 * * *`). - **After a restart or deploy,** runs due during the downtime start late by design; see [Misfires](../schedules/misfires-and-overlap.md). - `cronpad.reconciler.repaired` increasing means queue entries were lost and restored. That shouldn't happen in normal operation; check the logs for queue errors (`cronpad.queue.errors`) and whether something is deleting or replacing the queue file. ## A webhook fails Open the run and read each attempt. | Attempt shows | Likely cause | |---|---| | `401` / `403` | Wrong or expired token. Update the [secret](../secrets/index.md). | | `404` | Wrong URL or method. | | `4xx`, then the run fails without retries | 4xx (other than 408/429) is fatal by default. Add the code to `retry_on` if it's actually transient. | | `timeout` | Endpoint slower than the job's `timeout`. Raise it, or make the endpoint respond quickly and do the work asynchronously. | | `network: … connection refused` / DNS errors | Endpoint down or unreachable from cronpad. | | `blocked: private address` | On cronpad Cloud (or hosted mode), the URL resolves to a private IP. Use a public endpoint. | | `secret NAME not found` | The job references a deleted secret. | **Send test** on the job reproduces one request with full details, without recording a run. ## My endpoint received the same run twice That's at-least-once delivery doing its job after a crash, a timeout, or a lease loss. Deduplicate on `Idempotency-Key` / `webhook-id` (the run ID, identical across attempts). See [Delivery guarantees](../concepts/delivery-guarantees.md). `Cronpad-Attempt` tells you whether it was a retry or a redelivery of the same attempt. ## Signature verification fails - Verify against the **raw** body bytes, before any JSON parsing or re-serialization. - Use the full secret including decoding the part after `whsec_` from base64. - Signed content is `{webhook-id}.{webhook-timestamp}.{body}`. - Check your server clock: timestamps older than 5 minutes are rejected by the reference code. - After a rotation, both secrets are valid for 24 hours; make sure you deployed the new one. ## A job was disabled It had too many consecutive failed runs (20 by default). `disabled_reason` says how many; the last runs say why. Fix the cause, then **Re-enable**. See [Auto-disable](../retries-and-failures/auto-disable.md). ## The server won't start | Log message | Fix | |---|---| | `config: …` (a list of problems) | Each line names a setting and what's wrong with it. | | `no sign-in method configured` | Configure Google or GitHub, or `auth.dev_login = true` for local testing only. | | `database is locked` at startup | Another cronpad process is using the same files. Run exactly one. | | `the [redis] section is ignored` (warning) | Left over from a Redis-based release. Remove the `[redis]` section or `CRONPAD_REDIS__*` variables; the queue is now a SQLite file. | | `queue: …` | The queue file couldn't be opened or created. Check that the data directory is writable. A corrupt file is moved aside as `*-queue.db.corrupt-