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 deleted tickets, and tickets somebody has hidden from the board — see Hidden 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 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
exporfindsexportwhile you are still typing it. Mid-word does not match —xportfinds 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-draftexcludes. - Words are stemmed, so
exportalso findsexportsandexporting.
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.
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.
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:
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.
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#
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 |
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#
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_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.
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#
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.
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.
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.
Link one ticket to another#
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.
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#
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.
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:
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:
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:
{ "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:
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#
- Concurrency and polling —
versions,
409retries, deltas and the live stream. - Outbound webhooks — be told when a ticket changes instead of polling for it.
- API reference — the OpenAPI document, authentication and rate limits.
- Tickets — the same concepts from the app's point of view.