Laver docs
Open Laver
Agents and API

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.
  • 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 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.

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#