Skip to main content

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-Key header (it is a mutating POST);
  • an If-Match header 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

  1. GET /cards/{card_id} and keep the ETag header value (for example "7").
  2. POST /cards/{card_id}/move with If-Match: "7" and the target.
  3. On 200: done — the response body is the moved card and the new version is in the response ETag.
  4. 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 identifier and alias history, subtasks, attachments, comments, assignees, and dependencies all carry over unchanged. Only board_id and column_id change.
  • 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/428 handling, 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 forgot If-Match entirely.
  • 400 invalid_request — malformed If-Match (must be exactly one quoted positive integer, like "7").
  • 422 validation_error — e.g. the card is archived (restore it first via restoreCard) or before_card_id names 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.