Skip to content
cronpad
API reference / Schedules API

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

FieldDefaultDescription
exprrequiredAny supported schedule
kindautoForce a format
timezoneUTCIANA timezone
count10Number of fire times, 1–100
afternowCompute 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.