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

# API conventions

> The envelope, pagination, errors and parameter handling shared by every /v1 endpoint.

Every `/v1` endpoint shares one envelope, one pagination scheme and one error
shape. Read this once and the rest of the reference is per-endpoint specifics.

<Note>
  Two endpoints predate this contract and keep their original flat shapes for
  compatibility with live callers:
  [`POST /v1/users`](/api/v1/users/create-or-update-a-user) and
  [`POST /v1/answer`](/api/v1/ask/ask-the-agent-a-question). They are marked
  as such in the reference.
</Note>

## The envelope

**A single object:**

```json theme={null}
{
  "object": "lead",
  "data": { "id": "...", "object": "lead", "status": "qualified" },
  "request_id": "req_xxx"
}
```

**A list:**

```json theme={null}
{
  "object": "list",
  "data": [ { "id": "...", "object": "lead" } ],
  "has_more": true,
  "next_cursor": "eyJ2IjoxLCJ...",
  "request_id": "req_xxx"
}
```

`has_more` and `next_cursor` are **always present**, even when `false` and
`null`, so a paging loop is written once and never branches.

Every response also carries `X-Request-Id` (matching `request_id`) and
`Cache-Control: no-store` — this is workspace data behind a credential, and a
shared cache in front of it is how one tenant gets served another's response.

<Note>
  Quote a `request_id` to support for any outcome, success or failure. It is the
  only thing that ties your report to the server-side log line — error messages
  deliberately never echo the offending row.
</Note>

## Pagination

Keyset cursors only. There is no offset paging.

```bash theme={null}
# First page
curl "https://api.peeve.ai/v1/sessions?limit=100" \
  -H "Authorization: Bearer pv_ut_xxx"

# Next page — pass next_cursor back verbatim
curl "https://api.peeve.ai/v1/sessions?limit=100&cursor=eyJ2IjoxLCJ..." \
  -H "Authorization: Bearer pv_ut_xxx"
```

```js theme={null}
async function* allSessions(token) {
  let cursor = null;
  do {
    const url = new URL("https://api.peeve.ai/v1/sessions");
    url.searchParams.set("limit", "200");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    const page = await res.json();

    yield* page.data;
    cursor = page.next_cursor;
  } while (cursor);
}
```

<Warning>
  **Do not parse or construct a cursor.** It is opaque and we stay free to change
  it. It is also not a security boundary — it carries only a sort value and a row
  id, both of which you already have. The tenant boundary is your credential.
</Warning>

Why keyset and not offset: the customer this API is for is exporting six months
of sessions from a table that is still being written to. With `OFFSET`, every row
inserted ahead of the window shifts everything down — rows are silently skipped,
others arrive twice, and the export looks complete. A keyset cursor pins the read
to "strictly after this row", so the walk is stable no matter what lands
mid-export.

`has_more` is derived by fetching one row beyond the page, not by a `count`. A
count taken alongside the page is stale the moment it is taken, so it would be a
number presented as authoritative that we could not stand behind.

### Reusing a cursor under a different sort is rejected

```bash theme={null}
# First page sorted by created_at, then switching to score — refused.
?sort=created_at&cursor=...   →  ok
?sort=score&cursor=...        →  400 invalid_cursor
```

The cursor simply does not describe a position in the new ordering, so returning
data would be quietly wrong rather than an error.

### Common list parameters

Every list endpoint accepts these; per-endpoint filters are on each operation.

| Parameter                          | Notes                                                                         |
| ---------------------------------- | ----------------------------------------------------------------------------- |
| `limit`                            | Default **50**, maximum **200**. Over the max is a `400`, not a silent clamp. |
| `cursor`                           | A `next_cursor` from a previous page.                                         |
| `sort`                             | Validated against that endpoint's allowed fields.                             |
| `order`                            | `asc` or `desc`. Default `desc`.                                              |
| `created_after` / `created_before` | ISO 8601. A backwards window is a `400`.                                      |

## Strict parameters

<Warning>
  **An unrecognised query parameter is a `400`, not a shrug.**

  The failure this prevents is the expensive one: you write `?since=2026-01-01`,
  get a `200` and a full first page, and conclude your filter worked — then
  reconcile against a number that was never filtered. Silently ignoring a
  parameter is indistinguishable from honouring it.
</Warning>

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "unknown_parameter",
    "message": "Unknown query parameter 'since'. This endpoint accepts: created_after, created_before, cursor, limit, order, sort.",
    "param": "since"
  },
  "request_id": "req_xxx"
}
```

The same applies to duplicates — `?status=new&status=won` has no obvious meaning,
so picking one silently discards the other.

On **writes**, unknown body keys are **rejected, not dropped**; enums are enums,
not free strings; and money is in **minor units** (cents), never decimals.

## Errors

One shape, everywhere:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "unknown_parameter",
    "message": "Unknown query parameter 'since'. …",
    "param": "since"
  },
  "request_id": "req_xxx"
}
```

* **`type`** — the coarse family. Branch on this; it is stable.
* **`code`** — the specific machine label.
* **`message`** — what was expected, in words.
* **`param`** — the offending query parameter or dotted body path, when there is one.

| `type`                  | Status   | Meaning                                                        |
| ----------------------- | -------- | -------------------------------------------------------------- |
| `authentication_error`  | 401      | Missing, invalid, revoked, or the wrong **kind** of credential |
| `invalid_request_error` | 400, 403 | A bad parameter, or an insufficient role                       |
| `not_found_error`       | 404      | No such record in this workspace                               |
| `rate_limit_error`      | 429      | Over the ceiling; carries `Retry-After`                        |
| `api_error`             | 500, 503 | Our side                                                       |

### 404 is deliberately ambiguous

A record belonging to another workspace and a record that does not exist return
the **identical** `404`. A distinguishable `403` would turn the endpoint into an
existence oracle for other tenants' ids.

The same collapse applies to [MCP](/mcp/workspace-endpoint): a token for a
different workspace than the URL gets the same `404` as an unknown workspace.

### 401 vs 403

* **401** — the *credential* is wrong: missing, invalid, revoked, or the wrong
  kind. The message names what arrived and what was wanted.
* **403** — the credential is fine; the *role* does not reach. See
  [Roles](/authentication/roles).

## Rate limits

Every `/v1` endpoint shares **one bucket per credential**, not a bucket per
endpoint. Per-endpoint buckets would let a caller multiply their real ceiling by
the number of endpoints we happen to have shipped, so the published limit would
drift upwards every time we added a route.

Ceilings follow your plan — see [Rate limits](/reference/rate-limits). A
rate-limited response carries `Retry-After` in seconds.

A call refused for role reasons is rejected **before** the limiter charges your
workspace: a call that was never allowed should not consume your budget.
