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