---
title: Driving a board over the API
description: Reading a board, searching it, and creating, moving, commenting on and labelling tickets.
section: Agents and API
order: 2
---

# Driving a board over the API

Everything the board screen does is an HTTP call, and most of them are one call.
This page is the working reference for those calls: what to send, what comes
back, and which cheaper endpoint to reach for instead of downloading a whole
board.

All examples use `https://api.laver.app` and a `laver_...` API key. A session
token works everywhere a key does. Writes need the **member**, **admin** or
**owner** role; viewers get `403`.

Two conventions apply throughout:

- Responses are JSON objects with a single named key — `{"task": …}`,
  `{"tasks": […]}`, `{"board": …}`. There is no bare array anywhere.
- Numeric columns (`position`) come back as decimal strings such as
  `"3000.000000"`, because Postgres exact numerics are not safe to round-trip
  through a float. Send them back as numbers.

## Read a whole board

One call returns everything the board screen needs.

```bash
curl https://api.laver.app/boards/22222222-3333-4444-8555-666666666666 \
  -H "Authorization: Bearer laver_..."
```

```json
{
    "board": {
        "uuid": "22222222-3333-4444-8555-666666666666",
        "workspace_uuid": "11111111-2222-4333-8444-555555555555",
        "name": "Delivery",
        "position": "1000.000000",
        "sprint_series_uuid": null,
        "sprint_number": null,
        "sprint_start_at": null,
        "sprint_end_at": null,
        "created_at": "2026-06-02T10:00:04.118Z",
        "updated_at": "2026-07-31T08:02:55.900Z"
    },
    "server_time": "2026-08-01T09:20:00.512Z",
    "series": null,
    "statuses": [
        {
            "uuid": "44444444-5555-4666-8777-888888888888",
            "board_uuid": "22222222-3333-4444-8555-666666666666",
            "name": "To Do",
            "color": "#94a3b8",
            "position": "1000.000000",
            "is_complete": false
        }
    ],
    "groups": [],
    "tasks": [
        {
            "uuid": "33333333-4444-4555-8666-777777777777",
            "board_uuid": "22222222-3333-4444-8555-666666666666",
            "group_uuid": null,
            "status_uuid": "44444444-5555-4666-8777-888888888888",
            "title": "Export the weekly report as CSV",
            "description": "The Monday report should download as CSV as well as PDF.",
            "description_json": { "type": "doc", "content": [] },
            "labels": [],
            "source_type": null,
            "source_id": null,
            "source_url": null,
            "source_assignees": null,
            "priority": "medium",
            "due_date": "2026-08-14",
            "position": "3000.000000",
            "version": 7,
            "created_by_user_uuid": "99999999-aaaa-4bbb-8ccc-dddddddddddd",
            "created_at": "2026-07-29T11:02:31.400Z",
            "updated_at": "2026-07-31T14:20:06.771Z",
            "cover_attachment_uuid": null,
            "assignee_uuids": [],
            "subtask_total": 0,
            "subtask_done": 0
        }
    ],
    "members": [],
    "mentionable_members": [],
    "labels": [],
    "card_preferences": {
        "show_priority": true,
        "show_assignees": true,
        "show_due_date": true,
        "show_cover_image": false,
        "hover_status_uuids": []
    }
}
```

Worth knowing:

- `statuses` are the columns, in board order. `is_complete` marks the columns
  that mean "finished".
- `groups` are the optional horizontal swimlanes. A ticket's `group_uuid` may be
  `null`.
- `tasks` excludes archived tickets. `labels` on a ticket is the **full label
  objects**, embedded, not a list of uuids.
- `members` is everyone active in the workspace; `mentionable_members` is the
  subset you may `@`-mention on this board.
- `card_preferences` is per person, not per board — it is the calling user's
  display settings and has no effect on anything you write.
- `server_time` is the polling cursor. See
  [Concurrency and polling](/docs/agents-and-api/concurrency-and-polling).
- Boards that belong to a sprint series carry `sprint_*` fields and a populated
  `series` object.

A large board is a large response. Prefer the narrower calls below in anything
that runs on a loop.

## Read one ticket without the board

```bash
curl https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777 \
  -H "Authorization: Bearer laver_..."
```

```json
{
    "task": {
        "uuid": "33333333-4444-4555-8666-777777777777",
        "workspace_uuid": "11111111-2222-4333-8444-555555555555",
        "board_uuid": "22222222-3333-4444-8555-666666666666",
        "group_uuid": null,
        "status_uuid": "44444444-5555-4666-8777-888888888888",
        "status_name": "To Do",
        "board_name": "Delivery",
        "title": "Export the weekly report as CSV",
        "description": "The Monday report should download as CSV as well as PDF.",
        "description_json": { "type": "doc", "content": [] },
        "labels": [],
        "priority": "medium",
        "due_date": "2026-08-14",
        "position": "3000.000000",
        "version": 7,
        "created_by_user_uuid": "99999999-aaaa-4bbb-8ccc-dddddddddddd",
        "created_at": "2026-07-29T11:02:31.400Z",
        "updated_at": "2026-07-31T14:20:06.771Z",
        "assignee_uuids": [],
        "subtask_total": 0,
        "subtask_done": 0
    }
}
```

This shape — including `version`, `status_name` and `board_name` — is exactly
what board search returns, so anything that can parse one can parse the other. A
ticket you cannot reach, or one that has been archived, answers
`404 {"error": "Task not found."}`.

Related one-ticket reads:

| Call                            | Returns                                                                   |
| ------------------------------- | ------------------------------------------------------------------------- |
| `GET /tasks/<uuid>/comments`    | `{"comments": […], "events": […]}` — comments and history in one timeline |
| `GET /tasks/<uuid>/subtasks`    | `{"subtasks": […]}` — the success-criteria checklist                      |
| `GET /tasks/<uuid>/attachments` | `{"attachments": […]}` — metadata only, newest first                      |

## Search a board by title or description

```bash
curl -G https://api.laver.app/boards/22222222-3333-4444-8555-666666666666/tasks \
  --data-urlencode "q=csv export" \
  -H "Authorization: Bearer laver_..."
```

`q` is a case-insensitive **substring** match against the ticket title or its
plain-text description — not a word index, so `csv` matches `CSVs` and
`export csv` matches nothing unless those two words appear in that order. One to
200 characters.

The description searched is the plain-text rendering of the rich-text body, so
you do not have to walk `description_json` yourself.

## Filter by column

Give a column name in `status`, or its uuid in `status_uuid`. Names are matched
case-insensitively with surrounding space trimmed, and are unique within a
board.

```bash
curl -G https://api.laver.app/boards/22222222-3333-4444-8555-666666666666/tasks \
  --data-urlencode "status=In Review" \
  -H "Authorization: Bearer laver_..."
```

Sending both is refused:

```json
{
    "error": "Give either status or status_uuid, not both."
}
```

A name that is not a column on this board is also a `400`:

```json
{
    "error": "No column named \"In Reveiw\" on this board."
}
```

`q`, `status`/`status_uuid` and `updated_since` combine — they are ANDed.

## Page through a large board

`limit` defaults to **50** and caps at **200**. When a page comes back full, the
response carries `next_cursor`; pass it back verbatim as `cursor`.

```bash
curl -G https://api.laver.app/boards/22222222-3333-4444-8555-666666666666/tasks \
  --data-urlencode "limit=100" \
  --data-urlencode "cursor=WyIzMDAwLjAwMDAwMCIsIjMzMzMzMzMz..." \
  -H "Authorization: Bearer laver_..."
```

The cursor is a keyset, not an offset, so tickets being added, moved or archived
mid-walk cannot make the walk skip or repeat a row. A page shorter than `limit`
is the end of the walk and carries no `next_cursor`. A cursor you did not get
from this endpoint answers `400 {"error": "Invalid cursor."}`.

Results are ordered by board position, then uuid.

## Create a ticket

```bash
curl -X POST https://api.laver.app/boards/22222222-3333-4444-8555-666666666666/tasks \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{
        "title": "Export the weekly report as CSV",
        "status": "To Do",
        "description": "The Monday report should download as CSV as well as PDF.",
        "priority": "medium",
        "due_date": "2026-08-14"
      }'
```

Responds `201`:

```json
{
    "task": {
        "uuid": "33333333-4444-4555-8666-777777777777",
        "board_uuid": "22222222-3333-4444-8555-666666666666",
        "group_uuid": null,
        "status_uuid": "44444444-5555-4666-8777-888888888888",
        "title": "Export the weekly report as CSV",
        "description": "The Monday report should download as CSV as well as PDF.",
        "description_json": { "type": "doc", "content": [] },
        "priority": "medium",
        "due_date": "2026-08-14",
        "position": "0.000000",
        "version": 1,
        "created_by_user_uuid": "99999999-aaaa-4bbb-8ccc-dddddddddddd",
        "created_at": "2026-08-01T09:14:22.001Z",
        "updated_at": "2026-08-01T09:14:22.001Z",
        "assignee_uuids": []
    }
}
```

| Field              | Required | Notes                                                                                           |
| ------------------ | -------- | ----------------------------------------------------------------------------------------------- |
| `title`            | yes      | 1–500 characters                                                                                |
| `status`           | no       | Column name. Mutually exclusive with `status_uuid`                                              |
| `status_uuid`      | no       | Must belong to this board, or `400`                                                             |
| `description`      | no       | Plain text; the rich-text body is generated from it                                             |
| `description_json` | no       | Rich text; if given, `description` is derived from it and any `description` you send is ignored |
| `group_uuid`       | no       | Must belong to this board, or `400`                                                             |
| `priority`         | no       | `none`, `low`, `medium`, `high`, `urgent`. Defaults to `none`                                   |
| `due_date`         | no       | `YYYY-MM-DD`, or `null`                                                                         |
| `position`         | no       | Number. Defaults to `0`, which places the ticket at the top of its column                       |

There is **no** `assignee_uuids` or `label_uuids` on create — send them in a
follow-up `PATCH` using the `version` the create returned (`1`).

New tickets start at `version: 1`.

If the board belongs to a closed sprint, creation is refused with
`409 {"error": "New tasks can only be added to the current sprint."}`.

## Update a ticket

```bash
curl -X PATCH https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777 \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{
        "version": 7,
        "title": "Export the weekly report as CSV",
        "priority": "high",
        "assignee_uuids": ["99999999-aaaa-4bbb-8ccc-dddddddddddd"],
        "label_uuids": ["aaaaaaaa-1111-4222-8333-444444444444"]
      }'
```

`version` is mandatory, and the body must contain at least one other field. The
response is the updated ticket with `version` incremented.

Accepted fields: `title`, `description`, `description_json`, `label_uuids`
(up to 20), `assignee_uuids` (up to 50), `status`, `status_uuid`, `group_uuid`,
`priority`, `due_date`, `position`.

- `assignee_uuids` **replaces** the assignee list; send the full list you want.
  Every uuid must be an active workspace member or the whole call is a `400`.
- `label_uuids` likewise replaces the label set, and every uuid must be a label
  in this workspace. The response's `labels` field holds the resolved label
  objects.
- `description` and `description_json` are two views of one field. Send whichever
  you have; the other is derived.
- Mentioning someone in `description_json` sends them a notification.
- There is no `board_uuid` here — see [Move a ticket to another board](#move-a-ticket-to-another-board).

## Move a ticket between columns

`POST /tasks/<uuid>/move` is the narrow version of `PATCH`: version plus any of
`status`, `status_uuid`, `group_uuid`, `position`.

```bash
curl -X POST https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777/move \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"version": 7, "status": "In Progress", "position": 500}'
```

It shares the update path, so it shares the version check and the `409`
behaviour. Moving a ticket into an agreed "in progress" column is the usual way
several agents avoid picking up the same work — see the
[agent quickstart](/docs/agents-and-api/quickstart).

`position` is a sort key within the column, not an index: send a number smaller
than the ticket you want to sit above.

## Move a ticket to another board

```bash
curl -X POST https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777/move-board \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{
        "version": 7,
        "board_uuid": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"
      }'
```

The destination must be a board in the **same workspace** that you can reach;
anything else answers `404 {"error": "Destination board not found."}`. Passing
the board the ticket is already on is a `400`.

The ticket lands in the destination column whose name matches its old one
(case-insensitively), or in the destination's first column if there is no match,
and is appended to the bottom of that column. Its swimlane is matched by name
the same way, or cleared. Comments, assignees, labels and attachments travel
with it.

The move is recorded on the source board, so the source board's next delta
reports the ticket in `removed_task_uuids`.

## Comment on a ticket

```bash
curl -X POST https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777/comments \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"body": "Shipped. Totals reconciled against last Monday'\''s PDF."}'
```

Responds `201 {"comment": …}`. Send `body` (plain text) or `body_json` (rich
text); at least one is required, and the other is derived. The resolved text
must be 1–10,000 characters, so a rich-text body that renders to nothing is a
`400`.

Comments carry **no version** and do not change the ticket's version.

Editing and deleting are limited to your own comments — `PATCH` and `DELETE` on
`/tasks/<uuid>/comments/<comment-uuid>`. Someone else's comment answers `404`,
not `403`, so you cannot use the API to probe for comments you are not allowed
to touch. `can_edit` on a comment tells you which are yours. Deletion is a soft
archive: the comment disappears from the thread and cannot be restored over the
API.

`GET /tasks/<uuid>/comments` returns comments **and** the ticket's history
(`events`) in the same response, because the app renders them as one timeline.
Event `type` is one of `created`, `moved`, `assigned` or `unassigned`.

## Labels

Labels belong to the **workspace**, not to a board, and are attached to tickets
by uuid.

```bash
curl https://api.laver.app/workspaces/11111111-2222-4333-8444-555555555555/labels \
  -H "Authorization: Bearer laver_..."
```

```json
{
    "labels": [
        {
            "uuid": "aaaaaaaa-1111-4222-8333-444444444444",
            "name": "regression",
            "color": "#dc2626",
            "created_at": "2026-06-04T12:00:00.000Z",
            "updated_at": "2026-06-04T12:00:00.000Z"
        }
    ]
}
```

Create one with `POST /workspaces/<workspace-uuid>/labels`:

```bash
curl -X POST https://api.laver.app/workspaces/11111111-2222-4333-8444-555555555555/labels \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"name": "regression", "color": "#dc2626"}'
```

`name` is 1–50 characters and must be unique in the workspace — a duplicate is
`409`. `color` must be a six-digit hex string with a leading `#`; three-digit
shorthand and named colours are rejected by schema validation.

`PATCH` and `DELETE` on `/workspaces/<workspace-uuid>/labels/<label-uuid>` rename
and remove. Both rewrite the label wherever it is embedded on a ticket, so
renaming a label updates every ticket carrying it, and deleting one removes it
from every ticket. Deleting a label does **not** bump those tickets' versions.

To put labels on a ticket, send the complete set you want:

```bash
curl -X PATCH https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777 \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"version": 7, "label_uuids": ["aaaaaaaa-1111-4222-8333-444444444444"]}'
```

Up to 20 per ticket. Send `"label_uuids": []` to clear them.

## Success criteria (the ticket checklist)

A ticket carries a flat checklist. These are not tickets: they have no column,
no board and no assignee, and they are removed with their parent.

| Call                                           | Body                                  | Result                                  |
| ---------------------------------------------- | ------------------------------------- | --------------------------------------- |
| `GET /tasks/<uuid>/subtasks`                   | —                                     | `{"subtasks": […]}`, in checklist order |
| `POST /tasks/<uuid>/subtasks`                  | `{"title": "Totals match the PDF"}`   | `201 {"subtask": …}`                    |
| `PATCH /tasks/<uuid>/subtasks/<subtask-uuid>`  | any of `title`, `is_done`, `position` | `{"subtask": …}`                        |
| `DELETE /tasks/<uuid>/subtasks/<subtask-uuid>` | —                                     | `{"deleted": true}`                     |

None of these take a `version` or change the ticket's version, but the counts
`subtask_total` and `subtask_done` on the ticket reflect them.

## Attachments

```bash
curl -X POST https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777/attachments \
  -H "Authorization: Bearer laver_..." \
  -F "file=@report.csv"
```

Multipart, one file per request, **25 MB** maximum. Allowed types are PDF, the
Office XML formats (`.docx`, `.xlsx`, `.pptx`), PNG, JPEG, GIF, WebP, CSV and
plain text. The file's actual bytes are checked against its declared type, so
renaming an executable to `.png` is refused with `415`.

`GET /tasks/<uuid>/attachments/<attachment-uuid>/content` streams the bytes back.
`DELETE` on the same path without `/content` removes it.

Uploads count against the workspace storage allowance and are refused with `402`
when it is full.

## Archiving and duplicating

```bash
curl -X POST https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777/archive \
  -H "Authorization: Bearer laver_..."
```

Archive takes **no body and no version** — it is unconditional, and there is no
un-archive endpoint. An archived ticket vanishes from board reads and searches,
answers `404` on a direct read, and appears in `removed_task_uuids` on the next
delta.

`POST /tasks/<uuid>/duplicate` does take `{"version": …}` and answers `201` with
the copy: same description, labels, priority, due date, assignees and checklist,
title suffixed `(copy)`, appended to the bottom of the same column. Comments,
history and attachments are not copied.

## Board columns

| Call                                          | Body                      | Notes                            |
| --------------------------------------------- | ------------------------- | -------------------------------- |
| `POST /boards/<uuid>/statuses`                | `{"name": "In Review"}`   | `201`, appended to the right     |
| `PATCH /boards/<uuid>/statuses/<status-uuid>` | `{"name": "Review"}`      | Rename only                      |
| `PATCH /boards/<uuid>/statuses/order`         | `{"status_uuids": [ … ]}` | Full list, in the order you want |

Reordering rejects a partial list with `409` — send **every** column uuid on the
board, or two people reordering at once would silently drop one. The same rule
applies to `PATCH /boards/<uuid>/groups/order` and `PATCH /boards/order`.

There is no endpoint to delete a column.

## Where to go next

- [Concurrency and polling](/docs/agents-and-api/concurrency-and-polling) —
  versions, `409` retries, deltas and the live stream.
- [API reference](/docs/agents-and-api/api-reference) — the OpenAPI document,
  authentication and rate limits.
- [Tickets](/docs/boards-and-tickets/tickets) — the same concepts from the app's
  point of view.
