Freshdesk API attachments: multipart only, no Base64 field
You want to attach a screenshot to a Freshdesk ticket through the API, and the image on your side exists as a Base64 string. The instinct is to put that string into the JSON payload next to body and send it. The result: either a validation error, or a ticket where the "attachment" is a broken or text-typed file that agents cannot open.
The reason is a hard boundary in Freshdesk's API: the ticket and reply endpoints take attachments as multipart/form-data file parts, period. There is no JSON field that accepts a Base64 string and stores the decoded file. The JSON-side fields (attachment_ids, inline_attachment_ids, cloud_files) only reference attachments that already exist in Freshdesk or files already in cloud storage.
What happens when you send Base64
Depending on how the request is mangled, you will see one of these:
400 Client or Validation Error— the request body is not in the format the endpoint expects. Common when Base64 text is pushed into a JSON field that does not exist, or when a hand-setContent-Typeheader (usuallyapplication/json) overrides the multipart encoding your HTTP library would have produced.- A ticket or reply with a corrupted attachment — the string got stored as file content, so the "image" is a text file. It renders as a page of Base64 characters for every agent who opens it.
- Nothing at all — unknown fields in a JSON payload are silently dropped, so the attachment never appears and no error explains why.
Freshdesk's own error table lists 415 Unsupported Content-type for content types like application/xml, and notes under 406 that "when uploading files multipart/form-data is supported" — the multipart boundary is the documented path, and the JSON body is simply the wrong container for file bytes.
Where attachments are actually allowed
Attachments work only on specific endpoints, per the official docs: Create a Ticket (POST /api/v2/tickets), Update a Ticket, Create a Contact, Update a Contact, Reply to a Ticket (POST /api/v2/tickets/{id}/reply), and Update a Conversation. For a private note (POST /api/v2/tickets/{id}/notes), attach the file the same multipart way when uploading it, or reference an existing attachment by ID where the endpoint supports it.
Two constraints to design around:
- Files come from your machine. The docs state that only local files can be attached — meaning your HTTP client reads bytes and sends them as multipart parts. There is no fetch-a-URL attachment option in the v2 API; if the image lives on a server, download the bytes first.
- 20 MB total. Ticket attachments combined cannot exceed 20 MB. Base64-inflated payloads (about +37% versus raw bytes) waste that budget and slow the request.
Copy-paste fixes
curl: -F with attachments[]
curl -v -u "${FRESHDESK_KEY}:X" \
-F "body=<p>Here is the screenshot you asked for.</p>" \
-F "attachments[]=@logo.png;type=image/png" \
-X POST "https://${FRESHDESK_DOMAIN}.freshdesk.com/api/v2/tickets/${TICKET_ID}/reply"
-u "${FRESHDESK_KEY}:X" is the documented auth scheme — the API key as username, X as password. -F builds the multipart body; do not add a Content-Type header yourself.
Node.js 18+
// node freshdesk-reply.mjs
import { readFile } from "node:fs/promises";
const domain = process.env.FRESHDESK_DOMAIN;
const key = process.env.FRESHDESK_KEY;
const ticketId = process.env.TICKET_ID;
if (!domain || !key || !ticketId) {
throw new Error("Set FRESHDESK_DOMAIN, FRESHDESK_KEY, and TICKET_ID");
}
const bytes = await readFile("logo.png");
const form = new FormData();
form.append("body", "<p>Here is the screenshot you asked for.</p>");
form.append("attachments[]", new Blob([bytes], { type: "image/png" }), "logo.png");
const auth = Buffer.from(`${key}:X`).toString("base64");
const res = await fetch(
`https://${domain}.freshdesk.com/api/v2/tickets/${ticketId}/reply`,
{ method: "POST", headers: { Authorization: `Basic ${auth}` }, body: form }
);
if (!res.ok) throw new Error(`Freshdesk ${res.status}: ${await res.text()}`);
console.log((await res.json()).id);
Leave the Content-Type header unset — the fetch implementation adds multipart/form-data; boundary=... automatically. A manual application/json header here is the classic cause of a 400 on an otherwise correct request.
Python: requests
import base64
import requests
domain = "yourdomain"
api_key = "YOUR_KEY"
ticket_id = 12345
with open("logo.png", "rb") as f:
files = [("attachments[]", ("logo.png", f, "image/png"))]
data = {"body": "<p>Here is the screenshot you asked for.</p>"}
r = requests.post(
f"https://{domain}.freshdesk.com/api/v2/tickets/{ticket_id}/reply",
auth=(api_key, "X"),
files=files,
data=data,
timeout=30,
)
r.raise_for_status()
print(r.json()["id"])
requests sets the multipart boundary itself when files= is present; adding headers={"Content-Type": "multipart/form-data"} without a boundary breaks the request.
Start from a Base64 string
If the image reaches your code as Base64 — from a converter like the Image to Base64 tool, a message queue, or a database column — decode it once and hand the raw bytes to your HTTP client as a file part:
import base64
image_bytes = base64.b64decode(raw_base64) # decode exactly once
files = [("attachments[]", ("logo.png", image_bytes, "image/png"))]
Node equivalent:
const bytes = Buffer.from(rawBase64, "base64");
form.append("attachments[]", new Blob([bytes], { type: "image/png" }), "logo.png");
Strip a data:image/png;base64, prefix before decoding — everything before the first comma is metadata, not payload. If decoding succeeds but the stored file will not open, the string itself is probably damaged; fixing invalid Base64 image data covers the usual suspects (whitespace, wrong prefix, double encoding).
Pitfalls and a pre-flight checklist
- No manual
Content-Type. The library buildsmultipart/form-data; boundary=...; an override header is the top cause of400errors on this API. - Field name is
attachments[]. The bracket matters for multiple files; sendingattachments(no brackets) or a bare JSON array field is a common silent failure. - Auth is
key:Xas Basic auth. Not a bearer token, not the password field. - 20 MB combined limit. Count all attachments on the ticket, and remember Base64 is ~37% larger than the bytes it represents — decode before you count.
- Notes vs replies.
POST /tickets/{id}/notesis for private notes;POST /tickets/{id}/replyemails the requester. Attaching to the wrong one either hides the file from the customer or surprises them with an email. - JSON-side fields reference, not upload.
attachment_idson reply/bulk endpoints point at attachments Freshdesk already stores; they are not an upload channel. - Watch the 429s. Freshdesk rate-limits per domain; batch reply loops with attachments hit the ceiling quickly.
FAQ
Does Freshdesk have any endpoint that accepts Base64 attachments?
No. Across the v2 ticket, contact, reply, and conversation endpoints, attachments are multipart file uploads only. If a tutorial shows Base64 in a JSON body, it is describing a different product or is wrong.
Why did my ticket get created but the attachment vanished?
Most likely the JSON payload carried an unknown attachment field (silently ignored), or the request was sent as application/json where multipart was required. Re-send with the file as a multipart part.
Can I attach by URL, like Trello's url parameter?
No. Freshdesk v2 has no attach-by-URL option for tickets. Download the bytes yourself and upload them.
Related guides
Compare the multipart-only pattern with providers that do take Base64 JSON — Postmark inline images and SendGrid attachments — and keep Base64 validation close for upstream data.