# 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://<your public_url>/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=<next_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.