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:

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:

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

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.