Concurrency and polling#
A board is a shared mutable thing. People drag cards while your agent is halfway through a request, other agents pick up the same ticket, and a client that disconnects for a minute has to work out what it missed. Laver solves both problems with two small mechanisms: a version on every ticket, and a server clock on every board read.
Why tickets carry a version#
Every ticket has an integer version, starting at 1. Every accepted write
increments it by one.
Writes do not say "set the priority to high". They say "set the priority to high, and I believe this ticket is at version 7". If the ticket has since moved to version 8, the write is refused instead of quietly overwriting whatever the other writer did.
That is optimistic concurrency: no locks, no leases, nothing to release if your process dies. The cost is that you must be able to handle a refusal.
Which writes need a version#
| Endpoint | Needs version |
|---|---|
PATCH /tasks/<uuid> |
Yes |
POST /tasks/<uuid>/move |
Yes |
POST /tasks/<uuid>/move-board |
Yes |
POST /tasks/<uuid>/duplicate |
Yes |
POST /tasks/<uuid>/archive |
No — unconditional |
| Comments, success criteria, attachments | No |
| Labels, boards, columns, groups | No |
The version is a ticket concern. Commenting on a ticket does not change its version, and a comment written between your read and your write will not make your write fail.
Omitting version where it is required is a 400 from schema validation, not a
409. So is sending only version with no field to change — the body needs
version plus at least one other property.
What a 409 looks like#
{
"error": "Task was updated by another request.",
"version": 9
}
That is the whole body, with HTTP status 409. The key point is the second field: the current version travels with the rejection, so you do not have to re-read the ticket just to discover what version to retry with.
Not every 409 from the API is a version conflict. The board-level ones —
"The column list changed", "The board list changed", "The group list changed" —
mean you sent a partial list to a reordering endpoint, and the sprint ones mean
the target sprint is closed. Only the ticket conflict carries a version field,
so branch on that field's presence rather than on the status code alone.
The correct retry loop#
Whether you may retry straight from the 409 depends on what your write was.
A blind write — one whose new value does not depend on the old one, such as "move to Done" or "set priority to high" — can be retried immediately with the version from the response body:
def move(task_uuid, column, version, attempts=3):
for _ in range(attempts):
response = post(f"/tasks/{task_uuid}/move",
json={"version": version, "status": column})
if response.status_code != 409:
return response
conflict = response.json()
if "version" not in conflict:
return response # not a version conflict; do not retry
version = conflict["version"]
raise RuntimeError("gave up after repeated conflicts")
A read-modify-write — appending a line to a description, adding one name to
assignee_uuids, toggling something based on its current value — must not
retry from the 409 alone. The version you were handed tells you the ticket
changed; it does not tell you what it changed to, and assignee_uuids and
label_uuids replace the whole list. Re-read the ticket with
GET /tasks/<uuid>, recompute the new value from what you find, and write again
with the version that read returned.
Three rules that keep this well-behaved:
- Bound the retries. Three attempts is plenty. A ticket that conflicts three times in a row is being fought over, and the right answer is to stop and say so in a comment rather than to keep pushing.
- Do not retry a claim. If you are moving a ticket into an "in progress"
column to claim it and get a
409, another worker claimed it first. Take a different ticket. - Back off a little. A short pause between attempts costs nothing and stops two agents ping-ponging conflicts at full speed into the rate limit.
Polling: server_time as a cursor#
Every board read hands back the server's clock:
{
"board": { "uuid": "22222222-3333-4444-8555-666666666666" },
"server_time": "2026-08-01T09:20:00.512Z",
"tasks": []
}
GET /boards/<uuid> and GET /boards/<uuid>/tasks both return it. Keep the
most recent one and send it back as updated_since:
curl -G https://api.laver.app/boards/22222222-3333-4444-8555-666666666666 \
--data-urlencode "updated_since=2026-08-01T09:20:00.512Z" \
-H "Authorization: Bearer laver_..."
Always use the server's clock, never your own. The value is read before the rows it describes, so a write landing mid-query is re-delivered on your next poll rather than lost. A local clock that runs a few seconds fast skips changes permanently, and there is nothing in a later response that would tell you it happened.
updated_since must be an RFC 3339 timestamp — pass back exactly the string you
were given.
What a delta contains#
With updated_since, GET /boards/<uuid> returns the same top-level shape as a
full read, but tasks holds only the tickets whose updated_at moved after the
cursor, oldest change first:
{
"board": {
"uuid": "22222222-3333-4444-8555-666666666666",
"name": "Delivery"
},
"server_time": "2026-08-01T09:26:02.884Z",
"updated_since": "2026-08-01T09:20:00.512Z",
"tasks": [],
"removed_task_uuids": ["33333333-4444-4555-8666-777777777777"],
"statuses": [],
"groups": [],
"members": [],
"mentionable_members": [],
"labels": [],
"card_preferences": {
"show_priority": true,
"show_assignees": true,
"show_due_date": true,
"show_cover_image": false,
"hover_status_uuids": []
}
}
- Only
tasksis filtered.statuses,groups,members,mentionable_members,labelsandcard_preferencesarrive in full every time, so a renamed column or a new member needs no special handling — replace your copy wholesale. removed_task_uuidslists tickets that are no longer on this board: archived since the cursor, or moved to another board since the cursor. Drop them from your copy.- Tickets in a delta carry
status_name,board_nameandworkspace_uuid, but notcover_attachment_uuid. A full read is the other way round. If you depend on cover images, take them from the full read. - A delta omits the
seriesobject that a full read includes for sprint boards.
GET /boards/<uuid>/tasks accepts updated_since too, and combines it with
q, status and paging. It returns removed_task_uuids only when
updated_since was supplied.
What a delta does not contain#
A delta is a convenience for keeping a board view fresh. It is not a complete change feed, and building on the assumption that it is will eventually give you a wrong picture that never corrects itself.
It does not report:
- Comments, success criteria or attachments. None of those touch the
ticket's
updated_at, so a ticket that only gained a comment does not appear in the delta at all. PollGET /tasks/<uuid>/commentsfor threads you care about. - Access changes. If the key's owner loses access to a board, or their role changes, tickets simply stop being returned. There is no event saying so.
- Board deletion. An archived board answers
404on the next poll. Handle that rather than treating it as a transient failure. - Anything outside boards and tickets — wiki pages, notifications and workspace settings have their own endpoints.
The cross-board caveat#
When a ticket moves from board A to board B, board A's delta reports it in
removed_task_uuids and board B's delta returns it in tasks. Both sides
converge — provided the move was recorded. Recording the move is deliberately
best-effort: it must never fail the move it describes, so a failure there is
logged and swallowed. If that happens, board A's delta contains no trace of the
ticket at all — it is not in tasks, because it is no longer on board A, and
not in removed_task_uuids either. Your copy keeps a ticket that is no longer
there, indefinitely.
The same is true of any change your poll happened to miss for other reasons: a delta only ever adds and removes, so nothing later contradicts a stale entry.
Re-read the board in full periodically. Once an hour of continuous polling is plenty, plus on every process restart and after any run of failed requests. It is the only thing that guarantees your copy matches the server's.
Live updates: the event stream#
There is a server-sent events stream per board:
curl -N https://api.laver.app/boards/22222222-3333-4444-8555-666666666666/events \
-H "Authorization: Bearer laver_..."
retry: 3000
data: {"origin":null}
: ping
What it is, precisely:
- It carries no data about the change. The only field is
origin, which echoes thex-client-idrequest header of the write that triggered it. The frame means "something on this board changed" and nothing more — you still have to fetch the delta. That is deliberate: there is no per-field reconciliation to drift out of step with the real board. - Set
x-client-idon your writes to a value unique to your process, and ignore frames whoseoriginmatches it. Otherwise every write you make wakes you up to fetch your own change back. - It needs an
Authorizationheader, which means a browser's built-inEventSourcecannot open it —EventSourcesends no custom headers. Read it withfetchand a streaming body parser, or a client library that supports headers. - Cross-origin browser use is restricted. The stream is written directly to the socket rather than through the normal response pipeline, so its CORS headers are set by hand and allow only the Laver web app's own origin. Server side, where CORS does not apply, this makes no difference.
- A comment frame (
: ping) arrives every 25 seconds to keep proxies and load balancers from closing an idle connection. Ignore it.retry: 3000asks a reconnecting client to wait three seconds. - It is exempt from rate limiting, because every reconnect would otherwise
spend a request and a
429would turn a blip into a reconnect storm. - It is served from the process that handled the write. Delivery is therefore best-effort: a frame is not persisted, not replayed, and carries no sequence number, so anything that happens while you are disconnected is simply not sent.
Because of that last point, treat the stream as a latency optimisation on top of polling, never as a replacement for it. The dependable pattern is:
- Read the board in full and keep
server_time. - Open the stream. On any frame whose
originis not yours, fetch a delta withupdated_sinceand updateserver_time. - Fetch a delta on a timer anyway — every ten seconds or so — so a dropped connection costs you latency rather than correctness.
- Re-read in full periodically, and whenever the stream reconnects after a long gap.
If you would rather not hold a connection open at all, skip step 2 entirely. Delta polling on its own is correct; it is only slower to notice.
Budgeting your requests#
The API allows 900 requests per minute — see rate limits for how that budget is counted, which matters more than you would expect for API keys. A ten-second delta poll is six requests a minute per board, which leaves plenty of room. A one-second poll across a dozen boards does not.
Prefer, in order: the event stream plus a slow delta poll; a delta poll alone; a full board read on a timer. The last one is the expensive option and is rarely the right one.