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 deleted tickets, and tickets somebody has hidden from the board — see Hidden 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 full-text search over the ticket title and its plain-text description, one to 200 characters. It matches words, not substrings:

  • One word on its own is prefix-matched, so expor finds export while you are still typing it. Mid-word does not match — xport finds nothing.
  • Two or more words are read the way a search engine reads them: all terms must be present, in any order and anywhere in the ticket. "csv export" in double quotes is an exact phrase, and -draft excludes.
  • Words are stemmed, so export also finds exports and exporting.

Results come back ranked by relevance, with a match in the title outranking one in the description. Because relevance is not a stable walk order, a ranked reply carries no next_cursor — see Page through a large board.

Searching by ID is not a text search: pass a whole uuid, just its first block, or the short ID a Laver link carries — the one the ticket dialog shows and copies — and you get that ticket and nothing else.

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.

Hidden tickets#

A ticket can be hidden from its board: it comes off the board without being deleted, and it is still counted in the board's stats. Hiding is not deleting — a hidden ticket is not in the workspace trash and is never swept away.

GET /boards/<uuid>/tasks excludes them by default, which is what makes the call answer with the cards the board actually draws. Ask for them with hidden:

hidden Returns
absent, or exclude the board's cards — the default
only just the hidden tickets
include everything, which is what the stats count

The board's CSV export takes the same three words — GET /boards/<uuid>/export?hidden=include — and excludes hidden tickets by default, so exporting a board and importing the file back does not put work you had put away onto the board again. The file has no hidden column, so a hidden=include export re-imports as ordinary tickets.

A hidden ticket is still readable at GET /tasks/<uuid>, and carries hidden_at — the time it was hidden, or null. Put one back with:

bash
curl -X POST https://api.laver.app/tasks/44444444-5555-4666-8777-888888888888/unhide \
  -H "Authorization: Bearer laver_..."

POST /tasks/<uuid>/hide is the other half. Neither takes a version: hiding changes nothing two people could be editing at once, and asking for the state a ticket is already in is accepted and does nothing.

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.

Paging and q are mutually exclusive. A search is ordered by relevance, and "everything after position X" is not the next page of a relevance-ordered list, so a ranked reply never hands out a next_cursor and sending a cursor alongside q is a 400 rather than a page of the wrong tickets. Raise limit to see more of a search — it caps at 200 — or drop q to walk the board in order.

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
start_date no YYYY-MM-DD, or null. Independent of due_date; neither is validated against the other
due_date no YYYY-MM-DD, or null
position no Number. Omitted, the ticket is appended to the bottom of the column it is created in
parent_task_uuid no File this ticket under another one — see Break a ticket into sub-tickets

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, parent_task_uuid.

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

Omit it on a move and the ticket is appended to the bottom of the column it arrives in. It does not keep the number it had in the column it left — that number was computed against a different list, so keeping it landed the ticket at an arbitrary place in the new one. The same default applies however the move was made: PATCH, this route, an automation, sprint rollover or a restore from the trash.

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.

A comment you post with a key is authored by the person the key acts as, so author is them — but the comment also carries api_key, {"uuid", "name"}, naming the key that wrote it, and the app puts that comment under Laver, with the Laver mark instead of the person's face; whose key it used is in the tooltip. A comment somebody typed has api_key: null. Read it if you are summarising a thread: it is the only thing separating a note left by an agent from an instruction its author wrote by hand. It is withheld along with author whenever you are not entitled to be told who the author is.

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.

bash
curl -X POST https://api.laver.app/tasks/$TASK/relationships \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"other_task_uuid": "…", "kind": "duplicate_of"}'
kind Meaning
blocks This ticket blocks the other one
blocked_by This ticket is blocked by the other one
related_to Related, with no direction implied
duplicate_of This ticket duplicates the other one

blocks and blocked_by are the two directions of one dependency — link it once, from either end, rather than twice. Every ticket read returns blocked_by, blocks and a computed is_blocked.

Remove one with DELETE /tasks/<uuid>/relationships/<other-task-uuid>, naming the same kind.

Break a ticket into sub-tickets#

parent_task_uuid files one ticket under another. It is an ordinary column on the ticket, so it is set on create and changed with PATCH like any other field — there is no separate endpoint.

bash
curl -X POST https://api.laver.app/boards/$BOARD/tasks \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"title": "Write the CSV writer", "parent_task_uuid": "'$PARENT'"}'

A sub-ticket is a whole ticket — its own column, assignees, comments and version. It is not a checklist line; for those see Success criteria.

Filing one under a parent changes nothing about how it reads back: it is still returned by GET /boards/<uuid>/tasks, by the board read and by deltas, and status_uuid still says which column it is in. Only the board view is different, and only when the parent is on the same board — there the piece is drawn inside the parent's card rather than beside it, so a person looking at the board sees one card and a done/total count. Read parent_task_uuid if you want to reproduce that grouping; do not expect the API to do it for you.

Every ticket read carries the family:

Field Is
parent_task_uuid The uuid it is filed under, or null
parent {uuid, title, board_uuid, version, status_name, is_complete}, or null
sub_tickets The same shape per child, in position order
sub_ticket_total How many
sub_ticket_done How many are sitting in a completing column

parent is null both when there is none and when you cannot see the one there is — a sub-ticket on a board you were not given is left out of sub_tickets too, and out of both counts. parent_task_uuid always carries the raw column, so the two cases can be told apart.

Detach with PATCH /tasks/<uuid> and "parent_task_uuid": null, carrying the child's version. The ticket itself is untouched.

Refusals:

Reply When
404 No such parent, or one you cannot see
400 A parent in another workspace, or the ticket itself
409 The move would make a loop — a ticket inside its own piece

Deleting a parent does not delete its sub-tickets; they are left with parent_task_uuid: null.

Archive several tickets at once#

bash
curl -X POST https://api.laver.app/tasks/archive \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"task_uuids": ["…", "…"]}'

Up to 200 per call, and each uuid must be one you can already reach. This is what the app's multi-select delete uses, rather than a loop of single deletes. Archived tickets go to the workspace trash and stay recoverable for 30 days — see Trash and restore.

Deployment notifications#

When your pipeline has finished deploying the work on a ticket, tell Laver, and everyone assigned to that ticket is notified — in the app, by email and by push, each under their own preference.

bash
curl -X POST https://api.laver.app/tasks/<task_uuid>/deployments \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "succeeded", "environment": "test", "url": "https://github.com/acme/app/actions/runs/123"}'
Field Is
status succeeded or failed. Required
environment Where it went — test, production, a review app. Optional
url The run to open when somebody asks what happened. Optional

The reply is {"deployment": {"status": "…", "notified": 2}}, where notified counts the assignees who wanted this event on at least one channel. A ticket with no assignees is a 200 with "notified": 0 — reporting is never an error because nobody was listening.

The notification is attributed to GitHub Actions, not to the person whose API key posted it: they did not deploy anything, a pipeline did. The ticket's timeline records it as well ("This ticket was deployed to test"), with the (via …) marker naming the key that reported it, and the run URL kept in the event's metadata.

Nothing dedupes: two calls are two notifications. If your workflow can run twice for one merge, decide there which run reports.

Wiring it to GitHub Actions#

.github/workflows/notify-deployment.yml in this repository is a reusable workflow that does the whole of the above — it finds the pull request the deployed commit came from, reads the Ticket: <uuid> line Laver writes into every pull request body, and posts the result. Call it at the end of the job that deploys:

yaml
jobs:
    deploy: # …your existing deploy job…

    notify:
        needs: deploy
        if: always()
        uses: ./.github/workflows/notify-deployment.yml
        with:
            status: ${{ needs.deploy.result == 'success' && 'succeeded' || 'failed' }}
            environment: test
        secrets:
            laver_api_key: ${{ secrets.LAVER_API_KEY }}
            laver_api_url: ${{ secrets.LAVER_API_URL }}

if: always() is the point of it: without that, a failed deploy skips the notification and the failure is the case nobody hears about.

The key is an ordinary agent API key for the workspace the board is in. A service account key is the better choice — it belongs to the workspace rather than to a person, so it survives them leaving.

Merged pull requests#

A board can nominate one column as the one merged pull requests land in: open a column's menu, choose Set as merged pull requests column, and it wears a merge badge. Only one column of a board can hold it — tagging a second takes it off the first.

When the pull request for a ticket is merged, tell Laver, and the ticket moves there by itself:

bash
curl -X POST https://api.laver.app/tasks/<task_uuid>/pull-requests/merged \
  -H "Authorization: Bearer laver_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://github.com/acme/app/pull/42", "branch": "run/1234", "base": "test", "commit": "9f2c…"}'
Field Is
url The pull request, for whoever reads the ticket. Optional
branch The branch that was merged. Optional
base The branch it was merged into — test, main. Optional
commit The commit the merge produced, in full. Optional

Send commit if you can. It is what a later deployment of that commit is matched against, so the ticket's assignees hear how the deploy went even when the commit message says nothing about a ticket — which is every merge that was not squashed.

The reply says what happened to the ticket:

json
{ "pull_request": { "moved": true, "reason": "moved", "status": { "uuid": "…", "name": "In test" }, "task": { "…": "…" } } }

reason is moved, or one of three ways nothing moved: no_column (this board has not tagged one — the merge is still recorded), already_there, and refused, which carries an error naming the entry requirement the ticket does not meet. All of them are a 200: the merge happened whatever the board does about it, and a workflow doing exactly what it was told should not go red because a column is untagged.

Either way the merge lands on the ticket's timeline ("This ticket's pull request was merged into test"), with the (via …) marker naming the key that reported it. The move itself is an ordinary move — same events, same webhook, same board refresh for everyone watching — attributed to the key's owner.

Wiring it to GitHub Actions#

.github/workflows/notify-merged-pull-request.yml in this repository does the whole of the above. Copy it in and it runs itself: it fires when a pull request closes, checks that it really merged — closing one without merging is not a merge, and a ticket must not move because somebody gave up on a branch — reads the Ticket: <uuid> line Laver writes into every pull request body, and posts. Give the repository a LAVER_API_KEY secret and there is nothing else to wire. Without one it says so in the job summary and stops, so merges in a repository that has not been connected yet stay green.

A workflow you already have can call it instead, handing over a ticket it knows:

yaml
jobs:
    notify:
        uses: ./.github/workflows/notify-merged-pull-request.yml
        with:
            ticket_uuid: ${{ needs.something.outputs.ticket }}
        secrets:
            laver_api_key: ${{ secrets.LAVER_API_KEY }}
            laver_api_url: ${{ secrets.LAVER_API_URL }}

Where to go next#