Concurrency and polling
Why tickets carry a version#
Every ticket has an integer version, starting at 1, incremented by every
accepted write. Writes say "set the priority to high, and I believe this
ticket is at version 7" — if it's since moved to 8, the write is refused
instead of quietly overwriting the other writer. No locks, no leases, nothing
to release if your process dies.
What a 409 looks like#
{ "error": "Task was updated by another request.", "version": 9 }The current version travels with the rejection, so you never have to re-read just to find out what it is. A wiki page conflict has the same shape.
If all you're doing is adding to a wiki page,
POST /wiki-pages/<uuid>/append takes markdown, no version, and never
returns a 409 — reach for it before reaching for the retry loop.
The correct retry loop#
A blind write ("move to Done") can retry immediately with the version from the response body. A read-modify-write (appending to a description, toggling something) must re-read the ticket first — the conflict tells you that it changed, not what it changed to.
def move(task_uuid, column, version, attempts=3):
for _ in range(attempts):
r = post(f"/tasks/{task_uuid}/move", json={"version": version, "status": column})
if r.status_code != 409:
return r
conflict = r.json()
if "version" not in conflict:
return r
version = conflict["version"]
raise RuntimeError("gave up after repeated conflicts")Bound retries to three attempts, don't retry a claim (someone else got there), and back off briefly between attempts.
Retrying a create: Idempotency-Key#
version protects edits, not creates. Send an Idempotency-Key header (any
string, up to 255 chars) and a repeated request with the same key replays the
first response instead of creating a duplicate. Works on
POST /tasks/<uuid>/comments and POST /boards/<uuid>/tasks; any other
endpoint refuses it with 400. Keys last 24 hours.
Polling: server_time as a cursor#
Every board read hands back server_time. Keep the most recent one and send
it back as updated_since to get only what changed:
curl -G https://api.laver.app/boards/<uuid> \
--data-urlencode "updated_since=2026-08-01T09:20:00.512Z" \
-H "Authorization: Bearer laver_..."Always use the server's clock, never your own — a local clock running fast skips changes permanently with no later signal that it happened.
What a delta does not contain#
Comments, success criteria and attachment changes don't touch updated_at,
so they never appear in a delta — poll ticket comments separately. Access
changes and board deletion are invisible to a delta too. Re-read the board
in full periodically (roughly hourly, plus on restart) — it's the only
thing that guarantees your copy matches the server's.
Live updates: the event stream#
A server-sent-events stream per board carries no data about the change — one
field, origin, echoing the x-client-id of the write that triggered it.
Treat it strictly as a latency optimisation on top of polling, never a
replacement: it is best-effort, not persisted, and a missed frame while
disconnected is simply gone.
Budgeting your requests#
900 requests per minute, per API key (not shared across keys on the same host). A ten-second delta poll is six requests a minute per board — plenty of room. Prefer, in order: event stream + slow delta poll, delta poll alone, full board read on a timer.