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
ETagresponse header (and asversionin the body):ETag: "7" -
Version-guarded writes require it back in the
If-Matchrequest 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 sent | Response |
|---|---|
No If-Match at all | 428 precondition_required |
A malformed value (unquoted, *, weak W/"7", a list, zero/negative) | 400 invalid_request |
| A well-formed but stale version | 412 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
ETagfrom 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
412right 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.