Skip to main content

Optimistic concurrency

Versioned resources — boards, columns, cards, labels, subtasks, and comments — carry a version that increments on every effective mutation. The API exposes it as a strong HTTP validator:

  • Reads return it in the ETag response header (and as version in the body):

    ETag: "7"
  • Version-guarded writes require it back in the If-Match request header:

    If-Match: "7"

The write succeeds only when the version you send matches the current one. This is the lost-update guard: two clients cannot silently overwrite each other, because the second writer's version is stale by the time it lands.

Which requests need If-Match

Every mutation of a versioned resource: PATCH, DELETE, and the move/archive/restore POST operations — for example updateCard, moveCard, deleteBoard, setSubtask, updateComment, and setBoardVisibility.

Desired-state relationship calls — assignees, applied labels, attachments, covers, and board-member add/remove — do not take If-Match. They declare an end state, so there is no lost update to guard. See Collaboration resources.

The failure modes

You sentResponse
No If-Match at all428 precondition_required
A malformed value (unquoted, *, weak W/"7", a list, zero/negative)400 invalid_request
A well-formed but stale version412 precondition_failed

If-Match accepts exactly one strong, quoted, positive decimal version — "7" — nothing else.

Versions move under you — by design

Two behaviors surprise integrators:

  • The card version covers collaboration state. Because the card representation embeds assignee IDs, label IDs, subtask counts, and comment count, a teammate adding a comment bumps the card version. Your perfectly valid card ETag from a minute ago can already be stale.
  • The board version covers descendant activity. Card and column activity on the board increments the board version too, so a board-metadata PATCH can hit 412 right after unrelated card movement.

So treat 412 as a normal, expected event, not an error to alert on: re-read, re-check, retry.

The read–modify–write loop

cURL:

CARD_ID="<card-id>"

# 1. Read the card; note the ETag header, e.g. ETag: "7".
curl -sS -i "$KANBAN_API_BASE_URL/cards/$CARD_ID" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

# 2. Write with the exact value; on 412, go back to step 1.
curl -sS -i -X PATCH "$KANBAN_API_BASE_URL/cards/$CARD_ID" \
-H "Authorization: Bearer $KANBAN_API_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "7"' \
-d '{"title": "Review contract (updated)"}'

JavaScript:

const baseUrl =
process.env.KANBAN_API_BASE_URL ?? "https://app.filetag.ai/api/kanban/v1";
const token = process.env.KANBAN_API_TOKEN;
const cardId = process.env.KANBAN_CARD_ID;

async function patchCardWithRetry(patch, attempts = 3) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const read = await fetch(`${baseUrl}/cards/${cardId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!read.ok) {
const failure = await read.json();
throw new Error(
`${failure.error.code}: ${failure.error.message} (request ${failure.error.request_id})`
);
}
const etag = read.headers.get("etag");

const write = await fetch(`${baseUrl}/cards/${cardId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"If-Match": etag,
},
body: JSON.stringify(patch),
});
const payload = await write.json();
if (write.ok) {
return payload.data;
}
if (payload.error.code !== "precondition_failed") {
throw new Error(
`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`
);
}
// 412: someone changed the card since our read. Loop: re-read and retry.
}
throw new Error("card kept changing; giving up");
}

patchCardWithRetry({ title: "Review contract (updated)" })
.then((card) => console.log("card now at version", card.version))
.catch((error) => {
console.error(error);
process.exit(1);
});

Python:

import json
import os
import urllib.error
import urllib.request

BASE_URL = os.environ.get(
"KANBAN_API_BASE_URL", "https://app.filetag.ai/api/kanban/v1"
)
TOKEN = os.environ["KANBAN_API_TOKEN"]
CARD_ID = os.environ["KANBAN_CARD_ID"]


def patch_card_with_retry(patch, attempts=3):
for _ in range(attempts):
read = urllib.request.Request(
f"{BASE_URL}/cards/{CARD_ID}",
headers={"Authorization": f"Bearer {TOKEN}"},
)
with urllib.request.urlopen(read) as response:
etag = response.headers.get("ETag")

write = urllib.request.Request(
f"{BASE_URL}/cards/{CARD_ID}",
data=json.dumps(patch).encode("utf-8"),
method="PATCH",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"If-Match": etag,
},
)
try:
with urllib.request.urlopen(write) as response:
return json.load(response)["data"]
except urllib.error.HTTPError as exc:
error = json.load(exc)["error"]
if error["code"] != "precondition_failed":
raise RuntimeError(
f"{error['code']}: {error['message']} "
f"(request {error['request_id']})"
) from exc
# 412: someone changed the card since our read. Re-read and retry.
raise RuntimeError("card kept changing; giving up")


card = patch_card_with_retry({"title": "Review contract (updated)"})
print("card now at version", card["version"])

Successful writes return the fresh ETag, so chained mutations can carry the version forward without an extra read — until a 412 tells you to re-read. For the move-specific version of this pattern, see Move cards safely.