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:
- The API never uploads bytes. You reference an existing workspace file
by its canonical
file_id; uploads happen in FileTag's file surface. - Only clean files attach. A file that has not passed FileTag's malware
scanning — pending, quarantined, or rejected — returns
409with codefile_not_ready, without revealing which of those it is. - Detach never deletes. Removing an attachment only removes the card-file relationship; the file and its versions are untouched.
The endpoints
PUT /cards/{card_id}/attachments/{file_id}— desired-state attach; repeatable, noIf-Match, noIdempotency-Key.GET /cards/{card_id}/attachments— lists the attachments still visible to you.DELETE /cards/{card_id}/attachments/{file_id}— desired-state detach;204even when already absent.
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
| Response | Meaning | What to do |
|---|---|---|
404 not_found | The file does not exist in your workspace or is not visible to you — indistinguishable by design | Verify the file_id and the file's sharing in FileTag |
409 file_not_ready | The file has not (yet) passed the clean gate | Retry with backoff; surface to a human if it persists |
422 validation_error | Malformed IDs, or (for covers) a non-image file | Fix 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.