Overview

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.

Updated

Was this page helpful?