# 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-<time>` and recreated automatically. |
| `secrets: cannot decrypt` | `secrets.encryption_key` differs from the key the data was encrypted with. |

## Still stuck

Open an issue with the cronpad version (`cronpad version`), the `X-Request-ID` or `run_id`, and the relevant log lines (they're already redacted).