Skip to main content

Attach an existing file

This guide attaches a file that already lives in your FileTag workspace to a card, handles the one error unique to attachments — 409 file_not_ready — and detaches it again. Background: File attachments.

Three facts frame everything here:

  1. The API never uploads bytes. You reference an existing workspace file by its canonical file_id; uploads happen in FileTag's file surface.
  2. Only clean files attach. A file that has not passed FileTag's malware scanning — pending, quarantined, or rejected — returns 409 with code file_not_ready, without revealing which of those it is.
  3. Detach never deletes. Removing an attachment only removes the card-file relationship; the file and its versions are untouched.

The endpoints

cURL

CARD_ID="<card-id>"
FILE_ID="<workspace-file-id>"

# Attach. A file still being scanned returns 409 file_not_ready:
# {
# "error": {
# "code": "file_not_ready",
# "message": "...",
# "request_id": "..."
# }
# }
curl -sS -i -X PUT \
"$KANBAN_API_BASE_URL/cards/$CARD_ID/attachments/$FILE_ID" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

# Verify.
curl -sS "$KANBAN_API_BASE_URL/cards/$CARD_ID/attachments" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

# Detach (the file itself is untouched).
curl -sS -i -X DELETE \
"$KANBAN_API_BASE_URL/cards/$CARD_ID/attachments/$FILE_ID" \
-H "Authorization: Bearer $KANBAN_API_TOKEN"

JavaScript

A freshly uploaded file may still be scanning, so retry file_not_ready with backoff for a bounded time:

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 fileId = process.env.KANBAN_FILE_ID;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function attachWhenReady(attempts = 6) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const response = await fetch(
`${baseUrl}/cards/${cardId}/attachments/${fileId}`,
{ method: "PUT", headers: { Authorization: `Bearer ${token}` } }
);
const payload = await response.json();
if (response.ok) {
return payload.data;
}
if (payload.error.code !== "file_not_ready") {
throw new Error(
`${payload.error.code}: ${payload.error.message} (request ${payload.error.request_id})`
);
}
// Still scanning (or not eligible). Back off and try again.
await sleep(5000 * attempt);
}
throw new Error(
"file still not ready; check it in FileTag before retrying"
);
}

async function main() {
const attachment = await attachWhenReady();
console.log(
"attached",
attachment.name,
`(${attachment.size_bytes} bytes)`
);

const list = await fetch(`${baseUrl}/cards/${cardId}/attachments`, {
headers: { Authorization: `Bearer ${token}` },
});
const attachments = (await list.json()).data;
console.log(attachments.length, "attachment(s) on the card");

const detach = await fetch(
`${baseUrl}/cards/${cardId}/attachments/${fileId}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } }
);
console.log("detached with status", detach.status); // 204; file untouched
}

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

Python

import json
import os
import time
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"]
FILE_ID = os.environ["KANBAN_FILE_ID"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}


def attach_when_ready(attempts=6):
for attempt in range(1, attempts + 1):
request = urllib.request.Request(
f"{BASE_URL}/cards/{CARD_ID}/attachments/{FILE_ID}",
method="PUT",
headers=HEADERS,
)
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"] != "file_not_ready":
raise RuntimeError(
f"{error['code']}: {error['message']} "
f"(request {error['request_id']})"
) from exc
# Still scanning (or not eligible). Back off and try again.
time.sleep(5 * attempt)
raise RuntimeError(
"file still not ready; check it in FileTag before retrying"
)


attachment = attach_when_ready()
print("attached", attachment["name"], f"({attachment['size_bytes']} bytes)")

list_request = urllib.request.Request(
f"{BASE_URL}/cards/{CARD_ID}/attachments", headers=HEADERS
)
with urllib.request.urlopen(list_request) as response:
attachments = json.load(response)["data"]
print(len(attachments), "attachment(s) on the card")

detach = urllib.request.Request(
f"{BASE_URL}/cards/{CARD_ID}/attachments/{FILE_ID}",
method="DELETE",
headers=HEADERS,
)
with urllib.request.urlopen(detach) as response:
print("detached with status", response.status) # 204; file untouched

Failure modes and what they mean

ResponseMeaningWhat to do
404 not_foundThe file does not exist in your workspace or is not visible to you — indistinguishable by designVerify the file_id and the file's sharing in FileTag
409 file_not_readyThe file has not (yet) passed the clean gateRetry with backoff; surface to a human if it persists
422 validation_errorMalformed IDs, or (for covers) a non-image fileFix the request

Do not treat file_not_ready as permanent — a file mid-scan becomes attachable minutes later. Do not treat it as transient forever either: a quarantined file will never become ready, which is why the loops above are bounded.

Covers work the same way

PUT /cards/{card_id}/cover/{file_id} applies the same visibility + clean-gate rules and additionally requires an image file. DELETE /cards/{card_id}/cover clears it, returning 204 even when there was no cover. Neither touches the card version. See Cards and completion.