Trello API attachments: upload files, not Base64 strings
You have an image as a Base64 string — from a screenshot service, a build pipeline, or a converter — and you POST it to POST /1/cards/{cardId}/attachments. Either the request fails with a 400 parse error, or an attachment appears on the card that opens as a text file — a wall of Base64 characters where the picture should be. The image never displays, no previews are generated, and the card cover stays blank.
The cause is simple: Trello's attachment endpoint accepts a file upload as multipart/form-data, or a link through the url parameter. It has no field that takes Base64 text and decodes it. The Base64 has to be decoded into raw bytes on your side, and those bytes have to travel as a real multipart file part.
What actually happens when you POST Base64
Three common outcomes, depending on how the request is built:
| What you sent | What Trello does |
|---|---|
Raw Base64 text as the request body with Content-Type: application/json or text/plain | 400-class error — Trello tries to parse the body and fails; a widely reported message is Error parsing body |
Base64 text inside a multipart part named file, without a filename | The attachment is stored as a text file: it opens as text, isUpload is true, but it is not a valid image and gets no image previews |
A data:image/png;base64,... data URI as the body or as the url parameter | Same as above — a data URI is text, neither bytes Trello can store nor a URL Trello can fetch |
None of these produce a usable image. Trello stores whatever part body it receives; it does not sniff your string and work out that it is encoded content. This is the same class of failure as Slack rejecting Base64 images: most chat and work-management APIs want the bytes, not the text.
Why: Trello reads multipart parts, not JSON text
The documented contract for file attachments has exactly two inputs: a file upload sent as multipart/form-data, or a url parameter pointing at a publicly fetchable link. The official examples and every reliable community answer converge on the same rule: binary data — images, PDFs, anything — must go into a multipart file part.
Base64 is a text encoding of bytes; JSON is a text container. Neither is what this endpoint reads. That differs from other providers you may know: Postmark's mail API, for example, takes Base64 inside a JSON Content field. Each API draws its own line, and the API payload guide shows how much those payload shapes differ.
Copy-paste fixes
curl: multipart with --form
--form (short form -F) makes curl build the multipart body for you, including the boundary and the part headers:
curl --request POST \
"https://api.trello.com/1/cards/${CARD_ID}/attachments?key=${TRELLO_KEY}&token=${TRELLO_TOKEN}" \
--form "file=@logo.png;type=image/png" \
--form "name=logo.png" \
--form "mimeType=image/png"
A successful call returns 200 with the attachment JSON. Check that isUpload is true, bytes matches your file size, and previews is non-empty for images.
Node.js 18+
FormData and Blob are global since Node 18, so no multipart library is needed:
// node trello-attach.mjs <cardId> logo.png
import { readFile } from "node:fs/promises";
const [cardId, filePath] = process.argv.slice(2);
const key = process.env.TRELLO_KEY;
const token = process.env.TRELLO_TOKEN;
if (!cardId || !filePath || !key || !token) {
throw new Error("Usage: node trello-attach.mjs <cardId> <file> (plus TRELLO_KEY/TRELLO_TOKEN)");
}
const bytes = await readFile(filePath);
const form = new FormData();
form.append("file", new Blob([bytes], { type: "image/png" }), "logo.png");
form.append("name", "logo.png");
form.append("mimeType", "image/png");
const res = await fetch(
`https://api.trello.com/1/cards/${cardId}/attachments?key=${key}&token=${token}`,
{ method: "POST", body: form }
);
if (!res.ok) throw new Error(`Trello ${res.status}: ${await res.text()}`);
const attachment = await res.json();
console.log(attachment.id, attachment.mimeType);
Do not set a Content-Type header manually. The fetch implementation generates it together with the multipart boundary; a hand-written Content-Type: application/json — often a leftover session-wide default — is exactly what triggers the 400 parse errors reported against this endpoint.
Python: requests
The same rule in Python: pass files=, never set the header, and never encode the file first.
import requests
card_id = "YOUR_CARD_ID"
key, token = "YOUR_KEY", "YOUR_TOKEN"
url = f"https://api.trello.com/1/cards/{card_id}/attachments"
params = {"key": key, "token": token, "name": "logo.png", "mimeType": "image/png"}
with open("logo.png", "rb") as f:
files = {"file": ("logo.png", f, "image/png")}
r = requests.post(url, params=params, files=files, timeout=30)
r.raise_for_status()
print(r.json()["id"])
If your code sets Content-Type: application/json globally (for example with session.headers.update(...)), remove that before the upload call — that single header has caused more "Trello returns 400" reports than any other detail.
Start from a Base64 string
If your image exists only as Base64 — for example because you ran it through the Image to Base64 tool as an intermediate step — decode it once and upload the bytes. Never send the text itself.
const bytes = Buffer.from(rawBase64, "base64"); // decode exactly once
form.append("file", new Blob([bytes], { type: "image/png" }), "logo.png");
In the browser, without Node:
const bytes = Uint8Array.from(atob(rawBase64), c => c.charCodeAt(0));
If the string carries a data:image/png;base64, prefix, strip everything before the first comma first — otherwise the decoder throws or quietly produces junk. A string that decodes to garbage usually has a broken prefix, stray whitespace, or a double encoding; fixing invalid Base64 image data walks through the diagnosis.
Pitfalls and a pre-flight checklist
- No manual
Content-Type. Let the HTTP client generate the multipart header with its boundary. mimeTypeis a field, not just a part header. The response'smimeTypeis populated from the separatemimeTypefield; the file part's ownContent-Typealone has been reported to leave itnull. Set the field.- Decode once, not twice. Base64 to bytes to Base64 to bytes wastes bandwidth and is the usual cause of "it decodes but will not open".
urlattaches a link, not an image. Theurlparameter stores a reference (isUpload: false); Trello must be able to fetch it, and card-cover behavior differs from uploads.nameis limited to 256 characters.- Judge the response body, not just the status code.
isUpload: true,bytesclose to your file size, and image previews generated are the real success signals.
FAQ
Can I send Base64 in the JSON body like some other APIs?
Not on this endpoint. Trello's attachment API is multipart-only for uploads. Providers differ: Postmark's mail API takes Base64 inside JSON, Trello does not.
Why did my attachment upload successfully but open as text?
The Base64 text itself was stored as the file content. Decode it to bytes and re-upload through a real multipart file part.
Does this apply to card cover images?
Yes. Covers come from an uploaded image attachment (setCover=true) or from a fetchable url. A Base64 string is neither, so the cover stays unset even when the request returns 200.
Related guides
Compare with API payload shapes, fix a broken Base64 string, or read the same failure on Slack, where raw Base64 is rejected with no_file_data.