Skip to main content

Getting started

This page walks you from zero to a moved card: create a token, verify it, list boards, create a board, then create and move a card. Every step is copy-paste ready in cURL, JavaScript, and Python.

All examples read two environment variables so no credential ever appears in your code or shell history:

export KANBAN_API_BASE_URL="https://app.filetag.ai/api/kanban/v1"
export KANBAN_API_TOKEN="<your-personal-access-token>"

Use https://staging.filetag.ai/api/kanban/v1 as the base URL if you were given access to the staging environment instead.

1. Create a scoped Personal Access Token

The API authenticates with a workspace-scoped Personal Access Token (PAT). Create one in the FileTag web app under Settings → API tokens: choose the scopes the integration needs (for this walkthrough you need kanban:write, which includes read access) and copy the token when it is shown — it is displayed exactly once and only a hash is stored server-side.

Read Authentication for the token model and Scopes for what each scope allows. During the controlled beta, token issuance for Kanban scopes is limited to enrolled workspaces; see Changelog and deprecation policy.

2. Verify the token with GET /account

getAccount returns the represented user, the workspace the token is bound to, and the effective scopes — a cheap way to confirm the token works before doing anything else.

cURL:

curl -sS "$KANBAN_API_BASE_URL/account" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

JavaScript:

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

async function main() {
const response = await fetch(`${baseUrl}/account`, {
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})`
);
}
console.log(payload.data);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});

Python:

import json
import os
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"]

request = urllib.request.Request(
f"{BASE_URL}/account",
headers={"Authorization": f"Bearer {TOKEN}"},
)
with urllib.request.urlopen(request) as response:
payload = json.load(response)

print(payload["data"])

A successful response looks like:

{
"data": {
"user": { "id": "5b1e4d9a-6a7e-4b39-a9c1-2f47a1c05a10" },
"workspace": {
"id": "0f2a7c44-9f13-4a8e-8f5f-64c1a9d2b3c4",
"slug": "acme",
"name": "Acme Inc"
},
"scopes": ["kanban:write"]
}
}

3. List boards

listBoards returns the boards visible to the represented user, cursor-paginated. See Pagination for how cursors work.

cURL:

curl -sS "$KANBAN_API_BASE_URL/boards?limit=50" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

JavaScript:

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

async function main() {
const response = await fetch(`${baseUrl}/boards?limit=50`, {
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})`
);
}
for (const board of payload.data) {
console.log(board.id, board.name);
}
}

main().catch((error) => {
console.error(error);
process.exit(1);
});

Python:

import json
import os
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"]

request = urllib.request.Request(
f"{BASE_URL}/boards?limit=50",
headers={"Authorization": f"Bearer {TOKEN}"},
)
with urllib.request.urlopen(request) as response:
payload = json.load(response)

for board in payload["data"]:
print(board["id"], board["name"])

4. Create a board with an Idempotency-Key

Every mutating POST requires an Idempotency-Key header so a retried request can never create a duplicate. Pick any unique string (1–128 printable ASCII characters) per logical operation and reuse it on retries. See Idempotency.

createBoard creates the board and its default columns in one transaction and returns 201 with a Location header and an ETag.

cURL:

curl -sS -i -X POST "$KANBAN_API_BASE_URL/boards" \
-H "Authorization: Bearer $KANBAN_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: getting-started-create-board-001" \
-d '{"name": "Launch plan", "description": "Tasks for the launch"}'

JavaScript:

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

async function main() {
const response = await fetch(`${baseUrl}/boards`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": "getting-started-create-board-001",
},
body: JSON.stringify({
name: "Launch plan",
description: "Tasks for the launch",
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(
`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`
);
}
console.log("created board", payload.data.id);
console.log("etag", response.headers.get("etag"));
}

main().catch((error) => {
console.error(error);
process.exit(1);
});

Python:

import json
import os
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"]

body = json.dumps(
{"name": "Launch plan", "description": "Tasks for the launch"}
).encode("utf-8")
request = urllib.request.Request(
f"{BASE_URL}/boards",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Idempotency-Key": "getting-started-create-board-001",
},
)
with urllib.request.urlopen(request) as response:
payload = json.load(response)
print("created board", payload["data"]["id"])
print("etag", response.headers.get("ETag"))

If the request times out, send it again with the same Idempotency-Key and the same body: you get the original 201 back, not a second board.

5. Create and move a card with If-Match

New boards come with default columns. List them with listColumns to get a column_id, create a card in it with createCard, then move the card with moveCard.

Moves are version-guarded: read the card's version from the ETag response header (for example ETag: "1") and send it back verbatim in If-Match. See Optimistic concurrency.

cURL (replace the placeholder IDs with values from the previous responses):

BOARD_ID="<board-id-from-step-4>"

curl -sS "$KANBAN_API_BASE_URL/boards/$BOARD_ID/columns" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

COLUMN_ID="<first-column-id>"

curl -sS -i -X POST "$KANBAN_API_BASE_URL/columns/$COLUMN_ID/cards" \
-H "Authorization: Bearer $KANBAN_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: getting-started-create-card-001" \
-d '{"title": "Review contract"}'

CARD_ID="<card-id-from-the-create-response>"
TARGET_COLUMN_ID="<another-column-id>"

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: getting-started-move-card-001" \
-H 'If-Match: "1"' \
-d "{\"column_id\": \"$TARGET_COLUMN_ID\", \"before_card_id\": null}"

JavaScript:

const baseUrl =
process.env.KANBAN_API_BASE_URL ?? "https://app.filetag.ai/api/kanban/v1";
const token = process.env.KANBAN_API_TOKEN;
const boardId = process.env.KANBAN_BOARD_ID; // from step 4

async function api(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers,
},
});
if (response.status === 204) {
return { response, payload: null };
}
const payload = await response.json();
if (!response.ok) {
throw new Error(
`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`
);
}
return { response, payload };
}

async function main() {
const columns = await api(`/boards/${boardId}/columns`);
const [firstColumn, secondColumn] = columns.payload.data;

const created = await api(`/columns/${firstColumn.id}/cards`, {
method: "POST",
headers: { "Idempotency-Key": "getting-started-create-card-001" },
body: JSON.stringify({ title: "Review contract" }),
});
const card = created.payload.data;
const etag = created.response.headers.get("etag");
console.log("created card", card.id, "version", etag);

const moved = await api(`/cards/${card.id}/move`, {
method: "POST",
headers: {
"Idempotency-Key": "getting-started-move-card-001",
"If-Match": etag,
},
body: JSON.stringify({ column_id: secondColumn.id, before_card_id: null }),
});
console.log("card now in column", moved.payload.data.column_id);
}

main().catch((error) => {
console.error(error);
process.exit(1);
});

Python:

import json
import os
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"]
BOARD_ID = os.environ["KANBAN_BOARD_ID"] # from step 4


def api(path, method="GET", body=None, headers=None):
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(
f"{BASE_URL}{path}",
data=data,
method=method,
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
**(headers or {}),
},
)
with urllib.request.urlopen(request) as response:
payload = json.load(response) if response.status != 204 else None
return response.headers, payload


_, columns = api(f"/boards/{BOARD_ID}/columns")
first_column, second_column = columns["data"][0], columns["data"][1]

created_headers, created = api(
f"/columns/{first_column['id']}/cards",
method="POST",
body={"title": "Review contract"},
headers={"Idempotency-Key": "getting-started-create-card-001"},
)
card = created["data"]
etag = created_headers.get("ETag")
print("created card", card["id"], "version", etag)

_, moved = api(
f"/cards/{card['id']}/move",
method="POST",
body={"column_id": second_column["id"], "before_card_id": None},
headers={
"Idempotency-Key": "getting-started-move-card-001",
"If-Match": etag,
},
)
print("card now in column", moved["data"]["column_id"])

before_card_id: null means "place at the end of the target column"; to place the card before a specific card, pass that card's ID instead. See Move cards safely.

6. Handle a representative error

Every error response uses the same envelope and carries a request ID. If you now retry the move with the version from before the move (the card's version was incremented by the move itself), the API rejects it with 412 precondition_failed:

{
"error": {
"code": "precondition_failed",
"message": "The resource version does not match the supplied If-Match value.",
"request_id": "7b9f4be2-b790-4d68-b534-c53d87f4e699"
}
}

The fix is always the same: re-read the resource, take the fresh ETag, and retry. Here is the pattern in all three languages.

cURL:

CARD_ID="<card-id>"

# Re-read the card to get the current version from the ETag header.
curl -sS -i "$KANBAN_API_BASE_URL/cards/$CARD_ID" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

# Retry the mutation with the fresh value, e.g. If-Match: "2".

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;
const targetColumnId = process.env.KANBAN_TARGET_COLUMN_ID;

async function moveWithRetry() {
for (let attempt = 0; attempt < 3; attempt += 1) {
const read = await fetch(`${baseUrl}/cards/${cardId}`, {
headers: { Authorization: `Bearer ${token}` },
});
const etag = read.headers.get("etag");

const move = await fetch(`${baseUrl}/cards/${cardId}/move`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": `move-${cardId}-attempt-${attempt}`,
"If-Match": etag,
},
body: JSON.stringify({ column_id: targetColumnId, before_card_id: null }),
});
if (move.ok) {
return move.json();
}
const payload = await move.json();
if (payload.error.code !== "precondition_failed") {
throw new Error(
`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`
);
}
// Someone changed the card between our read and our move; loop and retry.
}
throw new Error("card kept changing; giving up after 3 attempts");
}

moveWithRetry()
.then((result) => console.log(result.data))
.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"]
TARGET_COLUMN_ID = os.environ["KANBAN_TARGET_COLUMN_ID"]


def move_with_retry():
for attempt in range(3):
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")

move = urllib.request.Request(
f"{BASE_URL}/cards/{CARD_ID}/move",
data=json.dumps(
{"column_id": TARGET_COLUMN_ID, "before_card_id": None}
).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Idempotency-Key": f"move-{CARD_ID}-attempt-{attempt}",
"If-Match": etag,
},
)
try:
with urllib.request.urlopen(move) 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
# Someone changed the card between our read and our move; retry.
raise RuntimeError("card kept changing; giving up after 3 attempts")


print(move_with_retry())

When you contact support about an unexpected response, include the request_id from the envelope (also present in the X-Request-Id response header) — it is the correlation key for server-side logs. See Errors and request IDs.

Next steps