> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.scripe.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Conventions

# Conventions

This page documents the rules that apply uniformly to every endpoint in
the Scripe API. If your client respects these conventions, every new
endpoint we ship will Just Work — there are no per-resource surprises.

***

## Versioning

Pin a date-stamped version on every request:

```http theme={null}
Scripe-Api-Version: 2026-08-01
```

* The current version is **`2026-08-01`** (Phase 2 release).
* Omitted header → we default to the current version. This is fine for
  exploration but **dangerous in production** — pin explicitly so the
  next version cut doesn't change your responses under you.
* Unknown version → `400 version_unsupported`. The error body lists the
  versions we still accept.
* We accept at least the **two most recent** versions at any time, with a
  minimum 90-day overlap when we sunset an old one. Sunset is announced
  via the `Scripe-Deprecation` response header before any code change:

```http theme={null}
Scripe-Deprecation: 2026-08-01; sunset=2026-12-01; replacement=2026-11-01
```

If you see a `Scripe-Deprecation` header, plan a migration before the
sunset date. Bumping the pinned version is usually a one-line change.

We **never** make breaking changes within a pinned version. Additive
changes (new optional fields on responses, new endpoints) can ship at
any time and are documented in the changelog at
[errors/](./errors/) (versioned alongside the error codes).

***

## Pagination

All list endpoints (`GET /v1/notes`, `GET /v1/posts`, `GET /v1/projects`)
use opaque cursor pagination. There are no page numbers; you loop until
the response says there are no more rows.

### Request

```
GET /v1/notes?projectId=proj_abc&limit=50
GET /v1/notes?projectId=proj_abc&limit=50&cursor=<prev next_cursor>
```

| Parameter | Default | Max | Notes                                                  |
| --------- | ------- | --- | ------------------------------------------------------ |
| `limit`   | 50      | 200 | Clamped silently. Out-of-range → `400 bad_pagination`. |
| `cursor`  | —       | —   | Opaque base64 string. Echo what we returned last time. |

### Response envelope

```json theme={null}
{
  "data": [ /* resource objects */ ],
  "pagination": {
    "next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2L…",
    "has_more": true
  }
}
```

* `next_cursor` is `null` once there are no more rows.
* `has_more` is the canonical "loop again?" signal. Don't compare cursor
  values — they're opaque and may change shape across versions.
* A cursor is bound to the request's filter set. Changing `projectId`,
  `dateFrom`, `dateTo`, `status`, `folderId`, etc. mid-loop will likely
  emit `400 bad_cursor` because the keyset reference no longer applies.
* A cursor never expires server-side, but your filters might (e.g. you
  filter by a `dateTo` that's now in the past). Treat `400 bad_cursor`
  as "start the loop over from the beginning" rather than as a bug.

### Pagination idiom (pseudocode)

```ts theme={null}
let cursor: string | null = null;
while (true) {
  const u = new URL("https://api.scripe.io/v1/notes");
  u.searchParams.set("projectId", projectId);
  u.searchParams.set("limit", "100");
  if (cursor) u.searchParams.set("cursor", cursor);
  const res = await fetch(u, { headers });
  const { data, pagination } = await res.json();
  yield* data;
  if (!pagination.has_more) break;
  cursor = pagination.next_cursor;
}
```

***

## Errors

Every non-2xx response returns the same envelope:

```json theme={null}
{
  "error": {
    "code": "key_revoked",
    "message": "This API key has been revoked.",
    "request_id": "req_01J9Z…",
    "docs_url": "https://docs.scripe.io/api/v1/errors#key_revoked"
  }
}
```

* `code` is the canonical machine-readable identifier. **Stable across
  releases.** Switch on this — never on `message` or HTTP status alone.
* `message` is human-readable and may evolve.
* `request_id` is your handle to correlate with our Sentry span if you
  open a support ticket. It also matches the `X-Request-Id` response
  header.
* `docs_url` deep-links to a page describing the code, the most likely
  cause, and a suggested remediation.
* `details` (optional) carries a code-specific payload. For example,
  `bad_cursor` doesn't include details, but `invalid_request` may
  include the offending field.

The full code catalogue lives in [errors/](./errors/). One page per code.

### Status code summary

| HTTP | Common codes                                                             |
| ---- | ------------------------------------------------------------------------ |
| 400  | `invalid_request`, `bad_cursor`, `bad_pagination`, `version_unsupported` |
| 401  | `unauthenticated`, `invalid_token`, `key_revoked`, `key_expired`         |
| 403  | `scope_missing`, `plan_not_eligible`, `workspace_mismatch`               |
| 404  | `not_found`                                                              |
| 405  | `method_not_allowed`                                                     |
| 429  | `rate_limited`                                                           |
| 500  | `internal_error`                                                         |
| 503  | `service_unavailable`                                                    |

A 401 always means "fix your authentication". A 403 always means "ask
the dashboard for more scopes / upgrade your plan". A 429 means "back
off and retry"; never treat it as a permanent failure.

***

## Rate limits

Limits are **per-workspace, per-bucket, sliding 60-second window**. Two
buckets exist:

| Bucket  | Limit       | Used by                               |
| ------- | ----------- | ------------------------------------- |
| `read`  | 120 req/min | Every endpoint shipping in Phase 2.   |
| `write` | 30 req/min  | Reserved for Phase 3 write endpoints. |

Each successful response carries:

```http theme={null}
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 41
```

`X-RateLimit-Reset` is in seconds, not a timestamp. On a 429 we also
include `Retry-After`:

```http theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 17
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 17
```

### Survival tips

* **Pre-throttle.** Watch `Remaining` and slow yourself down rather than
  burning the whole budget.
* **Respect `Retry-After`.** It's the smallest safe sleep value; longer
  is fine.
* **Group by workspace, not by key.** Adding more keys for the same
  workspace doesn't increase your budget.
* **Test mode counts.** A test key shares the workspace bucket with live
  keys. CI loops shouldn't run against production data without a
  dedicated test workspace.

***

## Headers we set on every response

```http theme={null}
Scripe-Api-Version: 2026-08-01
X-Request-Id: req_01J9Z…
Access-Control-Allow-Origin: *
Cache-Control: no-store        (default; health probes override)
```

For 4xx and 5xx the same headers apply, plus `Retry-After` on 429 and
`Allow` on 405.

***

## Resource IDs

Every resource exposes a typed string id with a stable prefix. Treat the
whole string as opaque — you should never parse beyond the prefix.

| Resource  | Prefix  | Example                                      |
| --------- | ------- | -------------------------------------------- |
| Workspace | `org_`  | `org_2pYJfL3VpQK4G2J7nE9b6Vw` (Clerk org id) |
| Project   | `proj_` | `proj_01J9ZAB12CD34E56F7G8H9`                |
| Note      | `note_` | `note_01J9ZAB12CD34E56F7G8H9`                |
| Post      | `post_` | `post_01J9ZAB12CD34E56F7G8H9`                |
| Source    | `src_`  | `src_01J9ZAB12CD34E56F7G8H9`                 |
| API key   | `key_`  | `key_01J9ZAB12CD34E56F7G8H9`                 |

A 404 on `GET /v1/projects/proj_does_not_exist` is indistinguishable
from a 404 on `GET /v1/projects/proj_belongs_to_other_workspace`. We do
not leak the existence of cross-workspace resources via 401/403/404
distinctions.

***

## Date and time

Every timestamp on the wire is **ISO 8601 UTC** with a trailing `Z`:

```
2026-08-01T14:23:11.000Z
```

Date-only filter parameters (`dateFrom`, `dateTo`) accept `YYYY-MM-DD`
and are interpreted as **start- and end-of-day in UTC** respectively
(`dateFrom=2026-08-01` → `>= 2026-08-01T00:00:00Z`,
`dateTo=2026-08-01` → `<= 2026-08-01T23:59:59.999Z`).

***

## CORS

The API responds `Access-Control-Allow-Origin: *` for read endpoints, so
any browser can call it as long as the user supplies their own
`Authorization` header. We do **not** echo cookies, and we strip any
inbound `Cookie` headers — the API surface is stateless and never
participates in browser session auth.

CORS preflight (`OPTIONS`) returns 204 without authentication.

***

## Anything else?

If a behaviour isn't documented here or in the per-resource pages,
**file a bug** rather than relying on the observed behaviour. The OpenAPI
spec at
[`packages/api-public/openapi/v1.yaml`](../../../packages/api-public/openapi/v1.yaml)
is the canonical schema; anything you observe but cannot find in the
spec is undocumented and may change.
