# API conventions

> Cross-cutting rules for every SchedStack endpoint — auth, path versioning, cursor pagination, the typed error envelope, request IDs, idempotency, and request and payload limits.

These rules apply to **every** endpoint under `/v1`. Read this once and the per-endpoint
reference becomes mechanical. For the full interactive request and response schemas, see
the [API reference](/docs/api/).

## Base URL

All requests go to:

```
https://api.schedstack.com/v1
```

There is no separate test host. Test and live are selected by your API key, not by the
URL — see [Modes](/docs/concepts/modes/).

## Authentication

Send your API key as a bearer token on every request:

```bash
curl https://api.schedstack.com/v1/schedules \
  -H "Authorization: Bearer sk_test_…"
```

Keys are prefixed by mode — `sk_test_…` or `sk_live_…` — and each key is **scoped to a
single project and mode**. Every read and write is automatically confined to that
`(project, mode)` pair; you cannot reach another project's data, and a test key never sees
live data or vice versa.

The raw key is shown **once**, at creation. SchedStack stores only its SHA-256 hash, so a
lost key cannot be recovered — issue a new one and revoke the old.

A missing, malformed, or revoked key returns `401` with `type: authentication_error`.
A missing key uses `code: missing_api_key` (message `Provide an API key via
Authorization: Bearer <key>.`); an invalid or revoked key uses `code: invalid_api_key`:

```json
{
  "error": {
    "type": "authentication_error",
    "code": "invalid_api_key",
    "message": "The API key is invalid or has been revoked.",
    "request_id": "req_…"
  }
}
```

## Versioning

The major version lives **in the path**: `/v1`. That prefix is the contract. We ship
**backward-compatible additions continuously** without a version bump — so write your
client to **tolerate new fields**:

- New response fields, new enum values, and new endpoints can appear at any time.
- Existing fields keep their name, type, and meaning.
- A breaking change, if one ever happens, ships under a new path prefix (`/v2`); `/v1`
  keeps working.

Pin to `/v1` and ignore unknown JSON keys. That is the entire versioning story you need.

**Sched-Version is not active**

You may see a date-pinned `Sched-Version` header in the OpenAPI document. It is **planned,
not yet implemented** — the engine does not read or honor it. **Path versioning (`/v1`) is
the real and only mechanism today.** Do not build clients that depend on `Sched-Version`.

## Request IDs

Every response — success or error — carries a `Sched-Request-Id` header:

```
Sched-Request-Id: req_…
```

On an error, the **same** id is also echoed inside the body as `error.request_id`. Log it.
It is the fastest way for you or for us to trace a single call end to end.

## Pagination

List endpoints (`GET /v1/schedules`, `/v1/deliveries`, `/v1/endpoints`, and the nested
`.../deliveries` and `.../attempts` collections) return a **cursor-paginated** envelope:

```json
{
  "object": "list",
  "data": [ { "...": "..." } ],
  "has_more": true,
  "next_cursor": "eyJ…"
}
```

| Field | Meaning |
|---|---|
| `object` | Always `"list"` for a collection response. |
| `data` | The page of objects, newest first. |
| `has_more` | `true` if more pages remain after this one. |
| `next_cursor` | Opaque cursor for the next page. `null` on the last page. |

Control the page with two query parameters:

| Parameter | Default | Notes |
|---|---|---|
| `limit` | `20` | Number of objects per page. |
| `cursor` | — | The `next_cursor` from the previous response. |

Treat `next_cursor` as **opaque** — do not parse or construct it. Walk pages until
`has_more` is `false`:

```bash
cursor=""
while : ; do
  resp=$(curl -s "https://api.schedstack.com/v1/deliveries?limit=50&cursor=$cursor" \
    -H "Authorization: Bearer sk_test_…")
  echo "$resp" | jq '.data[]'
  [ "$(echo "$resp" | jq -r '.has_more')" = "true" ] || break
  cursor=$(echo "$resp" | jq -r '.next_cursor')
done
```

```python
cursor = None
while True:
    params = {"limit": 50}
    if cursor:
        params["cursor"] = cursor
    page = httpx.get(
        "https://api.schedstack.com/v1/deliveries",
        params=params,
        headers={"Authorization": "Bearer sk_test_…"},
    ).json()
    for obj in page["data"]:
        process(obj)
    if not page["has_more"]:
        break
    cursor = page["next_cursor"]
```

```js
let cursor = null;
do {
  const url = new URL("https://api.schedstack.com/v1/deliveries");
  url.searchParams.set("limit", "50");
  if (cursor) url.searchParams.set("cursor", cursor);

  const page = await fetch(url, {
    headers: { Authorization: "Bearer sk_test_…" },
  }).then((r) => r.json());

  for (const obj of page.data) process(obj);
  cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
```

## Errors

Every error — at any status code — uses the same typed envelope:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "url_blocked",
    "message": "The destination resolves to a blocked address.",
    "param": "endpoint",
    "request_id": "req_…"
  }
}
```

| Field | Always present | Meaning |
|---|---|---|
| `type` | yes | Broad category — branch on this. See the enum below. |
| `code` | yes | Stable machine-readable specifier (e.g. `url_blocked`, `invalid_cron`). |
| `message` | yes | Human-readable explanation. Display it; don't match on it. |
| `param` | when applicable | The request field that caused the error. |
| `request_id` | yes | Same value as the `Sched-Request-Id` header. |

`type` is one of:

| `type` | Typical status | Meaning |
|---|---|---|
| `invalid_request_error` | `400` / `422` | Malformed or invalid input — the request as sent will never succeed unchanged. |
| `authentication_error` | `401` | Missing, malformed, or revoked API key. |
| `idempotency_error` | `409` | An `Idempotency-Key` conflict (see below). |
| `not_found_error` | `404` | No such object in this `(project, mode)`. |
| `api_error` | `5xx` | Something failed on our side — safe to retry. |

**No API rate limiting today**

SchedStack does not rate-limit API requests today, so you will not receive a `429` /
`rate_limit_error`. (A `429` returned by *your endpoint* to a delivery is a different thing
entirely — that's a retryable response; see the [delivery contract](/docs/delivery-contract/)
and [Retries and dead-letter](/docs/concepts/retries-and-dead-letter/).)

**Branch on `type` and `code`, log `request_id`**

Use `type` for control flow (retry vs. fix-the-request), `code` for specific handling, and
keep `message` for humans. Always log `request_id` so a failure is traceable.

## Idempotency

Send an `Idempotency-Key` header on any **mutating** call (such as `POST /v1/schedules`) to
make a network retry safe — a replayed request returns the original result instead of
creating a second schedule. Keys are stored for **24 hours**, scoped to `(project, mode)`,
and matched against the method, path, and body.

This is one of two distinct mechanisms named `Idempotency-Key` in SchedStack (the other is
the dedup key on **deliveries to your endpoint**). The full rules — fingerprinting,
conflict codes, and how to dedup on the receiving side — are in
[Idempotency](/docs/concepts/idempotency/).

## Limits

| Limit | Value | Applies to |
|---|---|---|
| Request body | **≤ 1 MB** | The JSON you `POST`/`PATCH` to the API. |
| Delivery body | **≤ 256 KB** (262144 bytes) | The `body` SchedStack sends to your endpoint. |
| Minimum delay | **~1 second** | The soonest a schedule can fire; a smaller `delay` is rejected. |

The `headers` you attach to a delivery have no separate cap — they count toward the 1 MB
request body above.

Exceed a limit and you get an `invalid_request_error` naming the offending `param`. The
minimum delay reflects the dispatch model: SchedStack publishes a **100–300 ms p99
dispatch-initiation SLO**, not sub-second precision — see
[Modes](/docs/concepts/modes/) for what "dispatch-initiation" measures.

## See also

- [API reference](/docs/api/) — interactive, per-endpoint request and response schemas.
- [Idempotency](/docs/concepts/idempotency/) — the two `Idempotency-Key` mechanisms in full.
- [Retries and dead-letter](/docs/concepts/retries-and-dead-letter/) — how delivery failures
  are retried and surfaced, never silently lost.
