Move cards safely
Moving a card is the operation most exposed to concurrency: boards are
shared, and the card you are about to move may have been moved, edited, or
commented on since you last read it. This guide shows the robust pattern:
read the version, send it back, and treat 412 as "re-read and retry".
POST /cards/{card_id}/move requires:
- an
Idempotency-Keyheader (it is a mutating POST); - an
If-Matchheader with the card's current version; - a body naming the target position by identifier, never by number:
{
"column_id": "1b9ab0d3-2dd9-4d6e-ad38-7ff812303bea",
"before_card_id": null
}
before_card_id: null places the card at the end of the target column;
a card ID places it immediately before that card. The server computes the
numeric position atomically — you never write positions.
Step by step
GET /cards/{card_id}and keep theETagheader value (for example"7").POST /cards/{card_id}/movewithIf-Match: "7"and the target.- On
200: done — the response body is the moved card and the new version is in the responseETag. - On
412 precondition_failed: the card changed after your read. Re-read, decide whether the move still makes sense, and retry with the fresh version and the same idempotency key.
cURL
CARD_ID="<card-id>"
TARGET_COLUMN_ID="<target-column-id>"
# 1. Read the card and 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. Move it to the end of the target column.
curl -sS -i -X POST "$KANBAN_API_BASE_URL/cards/$CARD_ID/move" \
-H "Authorization: Bearer $KANBAN_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: guide-move-card-001" \
-H 'If-Match: "7"' \
-d "{\"column_id\": \"$TARGET_COLUMN_ID\", \"before_card_id\": null}"
# A 412 body looks like this; re-read the card and retry with the new ETag:
# {
# "error": {
# "code": "precondition_failed",
# "message": "...",
# "request_id": "..."
# }
# }
JavaScript
const crypto = require("node:crypto");
const baseUrl =
process.env.KANBAN_API_BASE_URL ?? "https://app.filetag.ai/api/kanban/v1";
const token = process.env.KANBAN_API_TOKEN;
async function readCard(cardId) {
const response = await fetch(`${baseUrl}/cards/${cardId}`, {
headers: { Authorization: `Bearer ${token}` },
});
const payload = await response.json();
if (!response.ok) {
throw new Error(
`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`
);
}
return { card: payload.data, etag: response.headers.get("etag") };
}
async function moveCardSafely(cardId, columnId, beforeCardId = null) {
// One idempotency key for the whole logical move, kept across retries.
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; attempt <= 5; attempt += 1) {
const { card, etag } = await readCard(cardId);
if (card.column_id === columnId) {
return card; // Already where we want it; nothing to do.
}
const response = await fetch(`${baseUrl}/cards/${cardId}/move`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
"If-Match": etag,
},
body: JSON.stringify({
column_id: columnId,
before_card_id: beforeCardId,
}),
});
const payload = await response.json();
if (response.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: the card changed between read and move. Loop to re-read.
}
throw new Error("card kept changing; giving up after 5 attempts");
}
moveCardSafely(process.env.KANBAN_CARD_ID, process.env.KANBAN_TARGET_COLUMN_ID)
.then((card) =>
console.log("card", card.id, "now in column", card.column_id)
)
.catch((error) => {
console.error(error);
process.exit(1);
});
Python
import json
import os
import urllib.error
import urllib.request
import uuid
BASE_URL = os.environ.get(
"KANBAN_API_BASE_URL", "https://app.filetag.ai/api/kanban/v1"
)
TOKEN = os.environ["KANBAN_API_TOKEN"]
def read_card(card_id):
request = urllib.request.Request(
f"{BASE_URL}/cards/{card_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)["data"], response.headers.get("ETag")
def move_card_safely(card_id, column_id, before_card_id=None):
# One idempotency key for the whole logical move, kept across retries.
idempotency_key = str(uuid.uuid4())
for _ in range(5):
card, etag = read_card(card_id)
if card["column_id"] == column_id:
return card # Already where we want it; nothing to do.
request = urllib.request.Request(
f"{BASE_URL}/cards/{card_id}/move",
data=json.dumps(
{"column_id": column_id, "before_card_id": before_card_id}
).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
"If-Match": etag,
},
)
try:
with urllib.request.urlopen(request) 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: the card changed between read and move. Re-read and retry.
raise RuntimeError("card kept changing; giving up after 5 attempts")
card = move_card_safely(
os.environ["KANBAN_CARD_ID"], os.environ["KANBAN_TARGET_COLUMN_ID"]
)
print("card", card["id"], "now in column", card["column_id"])
Moving across boards
POST /cards/{card_id}/move is not limited to
the card's own board: the column_id you send may belong to any board in
the same workspace, and the card moves there in the same call — there is
no separate "transfer board" endpoint.
- You need access to both boards: the card's current board and the
target column's board. Missing access to either fails with
404 not_found. - The card's identity is untouched: its
identifierand alias history, subtasks, attachments, comments, assignees, and dependencies all carry over unchanged. Onlyboard_idandcolumn_idchange. - Board-scoped labels are removed. Labels belong to a board's palette
(see Collaboration resources),
so a label applied on the source board has no meaning on the destination
board. A cross-board move drops the card's
label_ids— reapply the equivalent labels on the new board afterward if you need them. - Everything else about the request is identical to a same-board move: same
headers, same
409/412/422/428handling, same idempotent replay.
Moving boards and transferring workstreams are independent operations: a cross-board move never changes the card's identifier, and a workstream transfer never changes its board. See Workstreams and identifiers.
Why 412 happens more than you expect
The card version covers the whole enriched representation — assignees,
labels, subtask counts, comment count. A teammate leaving a comment bumps
the version, so your If-Match can be stale even though nobody moved the
card. That is intentional: the version tells you "the card you saw is no
longer exactly what's there". The retry loop above absorbs this cheaply. See
Optimistic concurrency.
Other failure modes to handle:
428 precondition_required— you forgotIf-Matchentirely.400 invalid_request— malformedIf-Match(must be exactly one quoted positive integer, like"7").422 validation_error— e.g. the card is archived (restore it first viarestoreCard) orbefore_card_idnames a card that is not in the target column.404 not_found— the card or target column is not visible to your token.
Chained moves do not need a read between steps: each successful move returns
the fresh ETag, which you can send on the next move — falling back to a
re-read only when a 412 arrives.