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.
A wiki page conflict has the same shape — "Wiki page was updated by another request." with the current version beside it — and the retry below works
identically for PATCH /wiki-pages/<page-uuid>, POST .../move and
POST .../versions/<n>/restore.
If all you are doing is adding to a wiki page, there is no conflict to
handle at all: POST /wiki-pages/<page-uuid>/append takes markdown, takes no
version, and never returns a 409. Two callers appending at the same moment
both get their text. Reach for it before reaching for the retry loop.
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. Those carry no 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.
Retrying a create: Idempotency-Key#
version protects edits. It cannot protect a create, because before the
write there is nothing to hold a version.
That leaves a gap you will eventually fall into. You post a comment, the
connection drops before the 201 comes back, and you cannot tell whether the
comment exists. Retry and there may be two; don't and the work is lost.
Searching for it first is racy — the write may land between your search and your
decision.
Send an Idempotency-Key header instead. It is any string you choose, up to 255
characters; a UUID per operation is the right shape.
curl -X POST https://api.laver.app/tasks/<uuid>/comments \
-H "Authorization: Bearer laver_..." \
-H "Idempotency-Key: 9f1c4a2e-6b7d-4c8a-9e10-2f3b4c5d6e7f" \
-H "Content-Type: application/json" \
-d '{"body": "Claimed this ticket."}'
Repeat that request with the same key and the first response is replayed —
same status, same body — with Idempotency-Replay: true on it. Nothing is
written a second time. It is safe to retry until you get an answer.
Where it works#
| Endpoint | Idempotency-Key |
|---|---|
POST /tasks/<uuid>/comments |
Yes |
POST /boards/<uuid>/tasks |
Yes |
| Anything else | 400 |
Sending the header to an endpoint that does not support it is a 400 naming the
route, deliberately: a header that were silently ignored would leave you
believing in protection you do not have.
The rules, and why each one exists#
- A key is one operation, not a session. Reuse it with a different body, a
different ticket or a different endpoint and you get a
422, not the original object. Being handed back something you did not ask for is worse than a duplicate, because nothing about it looks wrong. - Failures replay too. A stored
4xxor5xxcomes back exactly as it went out. If the first attempt genuinely failed, retrying the same key will not make it succeed — use a new key, having decided that a second attempt is what you want. - A request rejected by schema validation does not consume its key. Fix the body and send the same key again.
- A key already in flight is a
409— "still in flight, retry in a moment". Not a version conflict; there is noversionfield on it. - Keys last 24 hours. After that the row is pruned and the same key simply makes a new request. A retry a day later is not a retry.
- Keys are scoped to the caller. Your key and somebody else's key are different keys even if the strings match. An API key's writes are scoped to the key's owner.
- Send no header and nothing changes. Idempotency is entirely opt-in.
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. - The same 25 seconds re-checks your credential, and the server hangs up if
it has stopped being good — the key revoked or expired, the member behind it
deactivated, the board archived or no longer yours. Access is otherwise only
evaluated when you connect, and a stream that lives for hours would outlast a
revocation without this. So a stream can end for a reason reconnecting will
not fix: if the reconnect answers
401or404, stop and look at the credential rather than retrying every 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.