---
title: API reference
description: The OpenAPI document, how to generate a client from it, and the authentication, rate limit and paging rules it does not tell you.
section: Agents and API
order: 4
---

# API reference

There is no hand-maintained endpoint list here, because a hand-maintained list
goes stale. **The canonical, machine-readable contract is the OpenAPI document
served by the API itself.** This page tells you where it is, how to turn it into
a client, and the handful of cross-cutting rules — authentication, rate limits,
paging, validation — that a schema cannot express on its own.

## The OpenAPI document

```text
https://api.laver.app/openapi.json
```

It is public, needs no credential, and describes every route, parameter, request
body and response the API serves. Fetch it from the host you actually call: it
is generated from the running application, so it always matches the deployment
that serves it.

```bash
curl -sS https://api.laver.app/openapi.json -o laver-openapi.json
```

If a field or an endpoint disagrees with anything written in these docs, the
OpenAPI document is right and the prose is out of date. Please tell us.

## Generate a client from it

Any OpenAPI 3 toolchain works. Point it at the URL or at the file you just
downloaded:

```bash
# TypeScript types
npx openapi-typescript https://api.laver.app/openapi.json -o laver-api.d.ts

# A full client, any supported language
npx @openapitools/openapi-generator-cli generate \
  -i https://api.laver.app/openapi.json \
  -g python \
  -o ./laver-client
```

Pin the document, not the URL, in anything you build and ship: download it,
commit it, and regenerate deliberately. Generating at build time against a live
host makes your build depend on our uptime and lets a deployment change your
types without a code review.

For agents that call the API directly rather than through a generated client,
the document is also a usable tool schema — the parameter and body definitions
translate straight into tool definitions.

## Base URL and response conventions

Every request goes to the API host, `https://api.laver.app` throughout these
docs, over HTTPS. There is no version prefix in the path; the OpenAPI document
is the contract.

- Request and response bodies are JSON. Send `Content-Type: application/json` on
  anything with a body — except attachment uploads, which are `multipart/form-data`.
- Successful responses are objects with a single named key: `{"task": …}`,
  `{"tasks": […]}`, `{"boards": […]}`. Nothing returns a bare array, so a
  response can gain a field without breaking your parser.
- Timestamps are RFC 3339 in UTC: `"2026-08-01T09:20:00.512Z"`. Date-only fields
  such as `due_date` are `"YYYY-MM-DD"` strings.
- Identifiers are uuids. The API never exposes database row ids.
- Exact-numeric columns — `position` on tickets, boards, columns and groups —
  are returned as **decimal strings** (`"3000.000000"`) so no precision is lost
  in transit. Send them back as JSON numbers.
- Errors are objects. Errors raised by a handler carry a single human-readable
  `error` string; errors raised by schema validation, authentication or the rate
  limiter carry the framework's `statusCode`, `error`, `code` and `message`
  fields. See [Errors](/docs/troubleshooting/errors).
- Browser-based cross-origin calls are restricted to the Laver web app's own
  origin. Integrations run server-side.

## Authentication

Every authenticated request carries a bearer token:

```text
Authorization: Bearer <token>
```

There are two kinds, and they are not interchangeable.

**Session tokens** are what the web app holds after a person signs in. They are
JWTs, they expire, and they are renewed automatically while in use. Every route
group accepts one.

**API keys** begin `laver_` and do not expire — they last until revoked, until
their owner is deactivated, or until their owner leaves the workspace the key is
booked against. A key **acts as the person who created it and inherits exactly
their access**: their role, their board memberships, and any change to either
takes effect on the next request. There is no separate permission model for
keys. See [API keys](/docs/workspaces-and-access/api-keys).

The workspace named when a key is created is the only workspace the key can
reach. Within that workspace it acts with its owner's exact access — their
role, their board memberships — but a request against any other workspace,
including ones its owner is an active member of with their own session, gets a
`403`. The key stops working entirely the moment its owner stops being an
active member of the workspace it is booked against.

Which route groups accept an API key:

| Route group             | Session token        | `laver_...` API key |
| ----------------------- | -------------------- | ------------------- |
| `/workspaces`           | Yes                  | **Yes**             |
| `/boards`               | Yes                  | **Yes**             |
| `/tasks`                | Yes                  | **Yes**             |
| `/wikis`, `/wiki-pages` | Yes                  | No                  |
| `/notifications`        | Yes                  | No                  |
| `/profile`, `/platform` | Yes                  | No                  |
| `/admin`                | Yes                  | No                  |
| `/billing`              | Yes                  | No                  |
| `/auth`                 | Yes (where relevant) | No                  |
| `/public`, `/health`    | Not required         | Not required        |

Two consequences:

- **A key cannot mint another key.** Key management lives on `/profile` and
  `/admin`, which take session tokens only, so a leaked key cannot bootstrap
  more credentials. Creating a key is therefore always a human, interactive act.
- **A key cannot look itself up.** There is no endpoint accepting a key that
  reports which user it acts as. If you need your own user uuid, read
  `GET /workspaces/<workspace-uuid>/members` and match on the email address of
  the account that owns the key.

Sending a `laver_` token to a route that does not accept keys fails
authentication with `401`, because the route only knows how to verify a session
JWT. That is not a permissions failure and no amount of role granting will fix
it.

## Endpoint catalogue

Grouped by resource. Each group is a plugin with its own authentication and its
own rules; the OpenAPI document has the individual routes.

### Workspaces — `/workspaces`

The tenant boundary. Lists the workspaces you belong to and your role in each;
lists a workspace's boards, members and labels; creates workspaces, boards and
labels; accepts or declines invitations addressed to you. **Accepts API keys.**
Renaming and deleting a workspace are owner-only.

### Boards — `/boards`

A board and its furniture: the full board read, the narrow ticket search, the
columns and swimlanes, board ordering, per-user card display preferences, ticket
creation, and the live event stream at `/boards/<uuid>/events`. **Accepts API
keys.** Sprint management is also addressed under `/boards/<uuid>/sprints`, but
belongs to the sprint routes and takes session tokens only.

### Tickets — `/tasks`

One ticket at a time: read, update, move between columns, move between boards,
duplicate, archive. Also its comments, its success-criteria checklist and its
attachments. This is where optimistic concurrency lives — see
[Concurrency and polling](/docs/agents-and-api/concurrency-and-polling).
**Accepts API keys.**

### Wiki — `/wikis`, `/wiki-pages`

The workspace's nested documentation: the page tree, full-text search, page
creation and editing, duplication, moving, archiving, version history and
restore, and page attachments. Session tokens only.

### Notifications — `/notifications`

Mentions and assignment notices addressed to the calling user, with an unread
count, and the calls to mark one or all as read. Session tokens only, and always
scoped to the caller — there is no way to read anyone else's.

### Admin — `/admin`

Workspace administration, restricted to owners and admins: the people list,
role changes and deactivation, invitations, the workspace's API keys (including
revoking someone else's), and the audit log. Session tokens only. A non-admin
gets `403 {"error": "Admin access required."}`.

### Billing — `/billing`

The workspace's plan, subscription state, seat count and renewal date, plus the
calls that open a Stripe Checkout session or the Stripe billing portal. Reading
the summary is open to any member; changing anything is owner-only. There is
also a Stripe webhook endpoint, authenticated by Stripe's signature rather than
by a bearer token — it is not for you to call.

### Profile and account — `/profile`, `/platform`

Your own account: name and profile image, password and email changes, a full
data export, account deletion, workspace transfer, **and your own API keys**
(`GET`, `POST`, `DELETE /profile/api-keys`). `/platform` carries terms-acceptance
state. Session tokens only.

### Auth — `/auth`

Sign-up, email verification, login, two-factor, password reset, token refresh,
logout and log-out-everywhere. The credential-issuing routes are the only
unauthenticated ones; the rest need a session.

### Public — `/public`, `/health`, `/`

No credential required. Signed links to the privacy and cookie policies, and a
health probe used by the load balancer. `/health` is exempt from rate limiting.

### Support — `/staff`

Reserved for Laver support accounts and refused to everyone else. Listed for
completeness; there is nothing here for an integrator.

## Rate limits

|                |                                                                              |
| -------------- | ---------------------------------------------------------------------------- |
| Allowance      | **900 requests per minute**                                                  |
| Window         | Rolling, one minute                                                          |
| Counted per    | Authenticated user for sessions; individual API key; **client IP otherwise** |
| Exempt         | `GET /health`, and any path ending in `/events`                              |
| Over the limit | `429`, with `retry-after`                                                    |

Responses carry `x-ratelimit-limit`, `x-ratelimit-remaining` and
`x-ratelimit-reset`; a `429` adds `retry-after`, in seconds. Read
`x-ratelimit-remaining` and slow down before you hit the wall rather than after.

**The detail that matters for agents:** every API key has its own budget. Agents
sharing one key share its 900 requests per minute, but a different key starts a
separate budget even when it runs on the same host. Invalid or unauthenticated
traffic is counted by IP address. Plan a fleet's polling accordingly, and do not
run a tight retry loop.

The allowance is an abuse ceiling rather than a fair-use quota. If you are close
to it in normal operation, the fix is almost always to poll deltas instead of
re-reading boards — see
[Concurrency and polling](/docs/agents-and-api/concurrency-and-polling).

## Pagination and limits

Most collections in Laver are naturally small and are returned complete: a
workspace's boards, a board's columns and members, a ticket's comments, its
checklist, its attachments, a workspace's labels. There is no paging on those
and no `limit` parameter to pass.

Two endpoints do page:

**Board ticket search** — `GET /boards/<uuid>/tasks`

| Parameter     | Rule                                            |
| ------------- | ----------------------------------------------- |
| `limit`       | 1–200, default **50**                           |
| `cursor`      | The previous response's `next_cursor`, verbatim |
| `next_cursor` | Present only when the page came back full       |

The cursor is a keyset over `(position, uuid)`, so a ticket being added, moved
or archived mid-walk cannot make the walk skip or repeat a row. A malformed or
foreign cursor is `400 {"error": "Invalid cursor."}`.

**Notifications** — `GET /notifications`

Returns up to 30 notifications per page alongside `unread_count`, `has_more` and
`next_cursor`. Pass `next_cursor` back as `cursor` for the following page.
`unread_only=1` narrows it to unread.

### Size limits worth knowing before you hit them

| Thing                    | Limit                                 |
| ------------------------ | ------------------------------------- |
| Ticket title             | 500 characters                        |
| Comment body             | 10,000 characters (and at least 1)    |
| Search query `q`         | 200 characters                        |
| Column name              | 100 characters                        |
| Board and workspace name | 200 characters                        |
| Label name               | 50 characters                         |
| Labels per ticket        | 20                                    |
| Assignees per ticket     | 50                                    |
| Attachment               | 25 MB per file, allowed types only    |
| API keys                 | 10 live keys per person per workspace |

Plan limits — seats, boards and total storage — are a separate matter and are
enforced with `402`. See [Errors](/docs/troubleshooting/errors).

## Request validation

Every declared request body, path parameter and query string is validated
strictly, and **undeclared fields are rejected rather than ignored**. A typo in
a field name, or a field copied from a response into a request, is a `400` and
the request is not partially applied:

```json
{
    "statusCode": 400,
    "code": "FST_ERR_VALIDATION",
    "error": "Bad Request",
    "message": "body must NOT have additional properties"
}
```

This is deliberate. Silently dropping a field you meant to set is a far worse
failure than refusing the call, particularly for a client that never reads the
response body.

Practical consequences:

- Send only the fields the endpoint declares. Round-tripping a whole ticket
  object from a `GET` into a `PATCH` will fail — a response carries fields no
  request accepts.
- Unknown query parameters fail too, including cache-busting parameters some
  HTTP clients append by default.
- Rich-text fields (`description_json`, `body_json`, `content_json`) are
  validated against the editor's document schema wherever they appear. An
  unrecognised node type is a `400` with a message describing the problem,
  rather than a document that stores fine and then fails to render.
