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.
curl https://api.laver.app/boards/22222222-3333-4444-8555-666666666666 \
-H "Authorization: Bearer laver_..."
{
"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:
statusesare the columns, in board order.is_completemarks the columns that mean "finished".groupsare the optional horizontal swimlanes. A ticket'sgroup_uuidmay benull.tasksexcludes archived tickets.labelson a ticket is the full label objects, embedded, not a list of uuids.membersis everyone active in the workspace;mentionable_membersis the subset you may@-mention on this board.card_preferencesis per person, not per board — it is the calling user's display settings and has no effect on anything you write.server_timeis the polling cursor. See Concurrency and polling.- Boards that belong to a sprint series carry
sprint_*fields and a populatedseriesobject.
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#
curl https://api.laver.app/tasks/33333333-4444-4555-8666-777777777777 \
-H "Authorization: Bearer laver_..."
{
"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#
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.
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:
{
"error": "Give either status or status_uuid, not both."
}
A name that is not a column on this board is also a 400:
{
"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.
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#
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:
{
"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#
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_uuidsreplaces the assignee list; send the full list you want. Every uuid must be an active workspace member or the whole call is a400.label_uuidslikewise replaces the label set, and every uuid must be a label in this workspace. The response'slabelsfield holds the resolved label objects.descriptionanddescription_jsonare two views of one field. Send whichever you have; the other is derived.- Mentioning someone in
description_jsonsends them a notification. - There is no
board_uuidhere — see 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.
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.
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#
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#
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.
curl https://api.laver.app/workspaces/11111111-2222-4333-8444-555555555555/labels \
-H "Authorization: Bearer laver_..."
{
"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:
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:
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#
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#
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 —
versions,
409retries, deltas and the live stream. - API reference — the OpenAPI document, authentication and rate limits.
- Tickets — the same concepts from the app's point of view.