---
title: Concurrency and polling
description: Why tickets carry a version, what a 409 gives you, and how to follow a board without re-reading it.
section: Agents and API
order: 3
---

# 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

```json
{
    "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:

```python
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](/docs/agents-and-api/api-reference).

## Polling: `server_time` as a cursor

Every board read hands back the server's clock:

```json
{
    "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`:

```bash
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:

```json
{
    "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 `tasks` is filtered. `statuses`, `groups`, `members`,
  `mentionable_members`, `labels` and `card_preferences` arrive **in full** every
  time, so a renamed column or a new member needs no special handling — replace
  your copy wholesale.
- `removed_task_uuids` lists 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_name` and `workspace_uuid`, but
  **not** `cover_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 `series` object 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. Poll `GET /tasks/<uuid>/comments` for 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 `404` on 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:

```bash
curl -N https://api.laver.app/boards/22222222-3333-4444-8555-666666666666/events \
  -H "Authorization: Bearer laver_..."
```

```text
retry: 3000

data: {"origin":null}

: ping
```

What it is, precisely:

- **It carries no data about the change.** The only field is `origin`, which
  echoes the `x-client-id` request 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-id` on your writes** to a value unique to your process, and
  ignore frames whose `origin` matches it. Otherwise every write you make wakes
  you up to fetch your own change back.
- **It needs an `Authorization` header**, which means a browser's built-in
  `EventSource` cannot open it — `EventSource` sends no custom headers. Read it
  with `fetch` and 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: 3000` asks
  a reconnecting client to wait three seconds.
- **It is exempt from rate limiting**, because every reconnect would otherwise
  spend a request and a `429` would 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:

1. Read the board in full and keep `server_time`.
2. Open the stream. On any frame whose `origin` is not yours, fetch a delta with
   `updated_since` and update `server_time`.
3. Fetch a delta on a timer anyway — every ten seconds or so — so a dropped
   connection costs you latency rather than correctness.
4. 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](/docs/agents-and-api/api-reference) 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.
