# Limits & constraints

> Every limit SchedStack enforces and the typed error you get if you cross it — so you learn them here, not from a surprise 4xx.

Every floor, cap, and validation rule SchedStack enforces — and the typed error you get
when you cross one. Each error follows the [typed error envelope](/docs/conventions/#errors)
(`{ "error": { "type", "code", "message", "param", "request_id" } }`); the **`code`** is
shown below.

**Where this page and the API reference disagree, trust this page**

The [interactive API reference](/docs/api/) is generated from the OpenAPI spec, which is
being corrected. Until then, a few things differ — **this page reflects the actual
behavior**:

- Oversize bodies return **`400`** (request) or **`422`** (delivery body), **not `413`**.
- An in-flight duplicate request returns **`idempotency_in_progress`**, not `in_flight`.
- There is **no request rate limiting** today — you will never get a **`429`** /
  `rate_limit_error` from the API.
- The total-headers size limit and the `Idempotency-Key` length are **advisory**, not
  enforced hard caps.
- `Sched-Request-Id` is on **API responses only**, not on outbound deliveries.

## Timing

| Rule | Limit | Error |
|---|---|---|
| Minimum `delay` | `>= 1s` | `422 sub_floor_delay` |
| `delay` format | valid duration (`30s`, `5m`, `24h`, `1h30m`) | `400 invalid_duration` |
| `fire_at` must be in the future | `>= now + 1s` | `422 fire_at_in_past` |
| `fire_at` horizon | `<= now + 10 years` | `422 fire_at_too_far` |
| `fire_at` format | strict RFC 3339 instant | `400` (message) |
| `local_fire_at` format | wall-clock `2006-01-02T15:04:05` (with `timezone`) | `400 invalid_duration` |
| `local_fire_at` bounds | same future / 10-year rules | `422 fire_at_in_past` / `fire_at_too_far` |
| Exactly one timing source | one of `delay` · `fire_at` · `local_fire_at` · `cron` | `422 missing_timing` / `400 multiple_timing` |
| `cron` | 5-field standard cron (no seconds field) | `422 invalid_cron` |
| `timezone` | a valid IANA name (e.g. `America/New_York`); default `UTC` | `422 invalid_cron` |

## Payloads & headers

| Rule | Limit | Behavior |
|---|---|---|
| Request body | `<= 1 MB` | larger → `400 invalid_json` |
| Delivery `body` | `<= 256 KB` | larger → `422 payload_too_large` |
| Custom delivery headers | keep them small | no enforced total-size cap today — don't rely on one |

Two header rules make a **delivery terminal** (dead-lettered, never retried):

- **No control characters.** A header name or value containing CR, LF, or other control
  characters is rejected.
- **No hop-by-hop / reserved names.** `host`, `content-length`, `connection`, `keep-alive`,
  `proxy-*`, `te`, `trailer`, `transfer-encoding`, and `upgrade` are not allowed.

SchedStack sets its own `Sched-*` and `Idempotency-Key` headers **after** yours, so you
cannot override them. See the [delivery contract](/docs/delivery-contract/).

## Retry policy

All bounds are enforced; any violation returns `422 invalid_retry_policy`.

| Field | Range | Default |
|---|---|---|
| `max_attempts` | 1–50 | 8 |
| `base` | 0–24h | `5s` |
| `max` | 0–168h (7 days) | `1h` |
| `factor` | 1–100 | 2 |
| `strategy` | `exponential` only | `exponential` |
| `jitter` | boolean | `true` |

The next attempt is scheduled at `base × factor^attempt`, capped at `max`. See
[Retries & dead-letter](/docs/concepts/retries-and-dead-letter/).

`jitter` is accepted and echoed back, but is **not currently applied** to retry timing —
backoff is deterministic today.

## TTL & expiry

`ttl` is any valid duration with **no minimum or maximum**. The delivery deadline is
`first fire + ttl`, and it **caps retries**: a delivery that can't land before the deadline
won't be retried past it — it finalizes as `expired` (a terminal, replayable state).

## Idempotency (API requests)

The optional request `Idempotency-Key` header makes a mutation safe to retry.

| Rule | Behavior |
|---|---|
| Window | the first response is stored for **24 hours** |
| Scope | `(project, mode, key)` — test and live never collide |
| "Same request" | a fingerprint of method + path + body |
| Same key, **different** body | `409 idempotency_key_reuse` |
| Same key, still in flight | `409 idempotency_in_progress` |
| What's cached | only `2xx`/`3xx`; a `4xx`/`5xx` releases the key so you can retry |
| Replay | returns the stored response with `Idempotent-Replayed: true` |
| Key length | a free-form string (a UUID is ideal) — keep it short; no enforced maximum |

Applies to: `POST /schedules`, `PATCH /schedules/{id}`, `/reschedule`, `POST /endpoints`,
`PATCH /endpoints/{id}`, and `/replay`. See [Idempotency](/docs/concepts/idempotency/).

## Pagination

| Rule | Behavior |
|---|---|
| Page size (`limit`) | default **20**, max **100**, min **1** |
| Out-of-range `limit` | silently clamped to 20 — not an error |
| Cursor | opaque; pass `next_cursor` back via `?cursor=` |
| Invalid cursor | `400 invalid_cursor` |

## Destinations

| Rule | Requirement | Error / outcome |
|---|---|---|
| Scheme | **HTTPS required** | `422 url_blocked` |
| Reachability | must be **publicly routable** — private, loopback, link-local, carrier-grade-NAT, and cloud-metadata addresses (e.g. `169.254.169.254`) are rejected | delivery terminal |
| Redirects | **not followed** — a `3xx` is a terminal misconfiguration, fix the URL | delivery terminal |
| DNS rebinding | the resolved address is re-checked on every connection | delivery terminal |
| HTTP method | one of `POST`, `PUT`, `PATCH`, `GET`, `DELETE` (default `POST`) | `400 invalid_method` |

## Endpoint profiles

| Rule | Requirement | Error |
|---|---|---|
| `url` | required, HTTPS | `422 missing_url` / `url_blocked` |
| `timeout` | `> 0` and `<= 1h` | `422 invalid_timeout` |
| `method` with `endpoint_id` | omit it — the method comes from the profile | `400 method_on_profile` |
| `endpoint` + `endpoint_id` | mutually exclusive | `400 multiple_endpoint` |
| Unknown / archived `endpoint_id` | must exist and be active | `422 invalid_endpoint` / `endpoint_archived` |
| Archiving a profile in use | blocked while a schedule references it | `409 profile_in_use` |
| `retry_budget` | `rate >= 0`, `burst >= 0` (default 10 / 100) | `422 invalid_retry_budget` |
| `rate_limit` | `per_second >= 0` — accepted but **not yet enforced** | `422 invalid_rate_limit` |
| `breaker_policy` | `threshold` 1–1000; `base_open` / `max_open` / `probe_timeout` all required, each `> 0` and `<= 24h` | `422 invalid_breaker_policy` |

See [Endpoint profiles](/docs/guides/endpoint-profiles/).

## Authentication

| Rule | Behavior | Error |
|---|---|---|
| Header | `Authorization: Bearer sk_<mode>_…` | — |
| Missing key | — | `401 missing_api_key` |
| Invalid or revoked key | — | `401 invalid_api_key` |
| Cross-scope access | a key only sees its own `(project, mode)` | `404 not_found` |

## Defaults worth knowing

- **Per-attempt delivery timeout:** 30 seconds.
- **Retry budget:** 10 retries/second, burst 100, per `(project, endpoint)`.
- **Circuit breaker:** opens after 5 consecutive failures; open window 30s → 1h; probes every 30s.
- **Retry policy:** `max_attempts` defaults to 8 when you don't supply a policy.

## Error code index

Every typed `code` you can receive, with its HTTP status. Branch on `type` for control flow
and `code` for specifics (see [Conventions](/docs/conventions/#errors)).

| `code` | HTTP | When |
|---|---|---|
| `invalid_json` | 400 | Malformed JSON, or request body over 1 MB |
| `invalid_duration` | 400 | `delay` / `local_fire_at` isn't a valid duration/format |
| `multiple_timing` | 400 | More than one timing source given |
| `multiple_endpoint` | 400 | Both `endpoint` and `endpoint_id` given |
| `method_on_profile` | 400 | `method` sent alongside `endpoint_id` |
| `invalid_method` | 400 | HTTP method not one of POST/PUT/PATCH/GET/DELETE |
| `invalid_cursor` | 400 | Pagination `cursor` is not valid |
| `missing_api_key` | 401 | No `Authorization` bearer key |
| `invalid_api_key` | 401 | Key is unknown or revoked |
| `not_found` | 404 | No such object in this `(project, mode)` |
| `idempotency_key_reuse` | 409 | Same `Idempotency-Key`, different request body |
| `idempotency_in_progress` | 409 | A request with the same `Idempotency-Key` is still in flight |
| `not_replayable` | 409 | The delivery isn't in a terminal state, so it can't be replayed |
| `profile_in_use` | 409 | Can't archive an endpoint profile a schedule still references |
| `sub_floor_delay` | 422 | `delay` below the 1 s floor |
| `fire_at_in_past` | 422 | `fire_at` / `local_fire_at` is not in the future |
| `fire_at_too_far` | 422 | `fire_at` / `local_fire_at` is more than 10 years out |
| `missing_timing` | 422 | No timing source given |
| `invalid_cron` | 422 | `cron` expression or `timezone` is invalid |
| `payload_too_large` | 422 | Delivery `body` over 256 KB |
| `url_blocked` | 422 | Destination isn't HTTPS or isn't publicly routable |
| `invalid_retry_policy` | 422 | A `retry_policy` field is out of range |
| `missing_url` | 422 | Endpoint profile has no `url` |
| `invalid_endpoint` | 422 | `endpoint_id` doesn't exist |
| `endpoint_archived` | 422 | `endpoint_id` refers to an archived profile |
| `invalid_timeout` | 422 | Profile `timeout` is `<= 0` or `> 1h` |
| `invalid_retry_budget` | 422 | Profile `retry_budget` values are invalid |
| `invalid_rate_limit` | 422 | Profile `rate_limit` value is invalid |
| `invalid_breaker_policy` | 422 | Profile `breaker_policy` is incomplete or out of range |
