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
{"expr": "0 0 9 ? * 3#2", "kind": "auto", "timezone": "Europe/London"}
{
"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:
{
"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 |
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 |
{
"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:
{
"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.