TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Test multipart/form-data file uploads

Upload a file in curl, JavaScript, Python and C#, see exactly what the server received, and avoid the boundary mistake.

Examples last run against the API on

To test a file upload, you need to see what the server actually received: which fields, which files, under which names, with which content type and size. POST /api/echo on https://api.testingapis.com parses a multipart/form-datarequest and sends that back as JSON. It's free, needs no key, keeps nothing, and allows requests from any origin, so it works from a browser too. Below: a working upload in curl, JavaScript, Python and C#, the mistakes that break uploads in each, and an xUnit test.

A multipart upload with curl

-F makes curl send multipart/form-data. name=value is a text field, name=@path attaches a file, and ;type= sets its content type. Without ;type=, curl sends application/octet-stream. The response has other request details too; here jq keeps the two parts that matter:

Terminal
$ printf 'id,name\n1,Alice\n2,Bob\n' > report.csv
$ curl -s https://api.testingapis.com/api/echo \
    -F "title=Quarterly report" \
    -F "file=@report.csv;type=text/csv" | jq '{form, files}'
{
  "form": {
    "title": "Quarterly report"
  },
  "files": [
    {
      "field": "file",
      "fileName": "report.csv",
      "contentType": "text/csv",
      "size": 22
    }
  ]
}
POST/api/echo
title=Quarterly report

JavaScript: FormData with fetch

Build a FormData, append fields and files, and pass it as the body. The same code runs in the browser (with a file from an <input type="file"> in place of readFile) and in Node 18 or later.

upload.mjs
import { readFile } from "node:fs/promises";

const form = new FormData();
form.append("title", "Quarterly report");
form.append("file", new Blob([await readFile("report.csv")], { type: "text/csv" }), "report.csv");

// No Content-Type header: fetch adds "multipart/form-data; boundary=..." itself.
const res = await fetch("https://api.testingapis.com/api/echo", { method: "POST", body: form });
const { form: fields, files } = await res.json();
console.log(fields, files);
Output (Node 24)
{ title: 'Quarterly report' } [
  {
    field: 'file',
    fileName: 'report.csv',
    contentType: 'text/csv',
    size: 22
  }
]

Python: requests

Text fields go in data, files in files. Give each file a tuple of file name, file object and content type. Pass a bare file object and the content type is left out.

upload.py
import requests

with open("report.csv", "rb") as f:
    res = requests.post(
        "https://api.testingapis.com/api/echo",
        data={"title": "Quarterly report"},
        files={"file": ("report.csv", f, "text/csv")},  # (file name, file, content type)
    )

body = res.json()
print(body["form"], body["files"])
Output
{'title': 'Quarterly report'} [{'field': 'file', 'fileName': 'report.csv', 'contentType': 'text/csv', 'size': 22}]

C#: MultipartFormDataContent

Add a StringContent per text field and a StreamContent (or ByteArrayContent) per file. The file name argument is what makes a part a file: leave it out and the file's bytes arrive as an ordinary text field.

Program.cs
using System.Net.Http.Headers;
using System.Text.Json;

using var http = new HttpClient();
using var form = new MultipartFormDataContent();

form.Add(new StringContent("Quarterly report"), "title");

var file = new StreamContent(File.OpenRead("report.csv"));
file.Headers.ContentType = new MediaTypeHeaderValue("text/csv");
form.Add(file, "file", "report.csv"); // the third argument, the file name, makes it a file

using var response = await http.PostAsync("https://api.testingapis.com/api/echo", form);
var echo = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine(echo.GetProperty("form"));
Console.WriteLine(echo.GetProperty("files"));
Output (.NET 10)
{"title":"Quarterly report"}
[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22}]

The mistakes that break uploads

Each row was sent to /api/echo; the last column is what the server parsed.

What the code didLibrariesWhat the server got
Set the Content-Type header to multipart/form-data by handfetch, requests, HttpClient (replacing the form's Content-Type)Nothing: Missing content-type boundary. The boundary that separates the parts is gone
Left out the file nameHttpClient (form.Add(content, name))The file's contents as a text field, and no file
Left out the file namefetch (Blob without a name)A file named blob
Left out the content typecurl, requests (bare file object), HttpClientcurl: application/octet-stream; requests and HttpClient: an empty content type
Used data= without files=requestsNot multipart at all: application/x-www-form-urlencoded

The boundary mistake is the most common. This is what the server sees:

JSON
{
  "form": { "parseError": "Missing content-type boundary." },
  "files": null,
  "contentType": "multipart/form-data"
}

An xUnit test for an upload

This asserts what reached the server, not just that the request returned 200. It passes with xUnit 2.9 on .NET 10.

UploadTests.cs
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Xunit;

public class UploadTests
{
    [Fact]
    public async Task The_file_arrives_with_its_name_type_and_size()
    {
        var bytes = Encoding.UTF8.GetBytes("id,name\n1,Alice\n2,Bob\n");
        using var http = new HttpClient();
        using var form = new MultipartFormDataContent();
        form.Add(new StringContent("Quarterly report"), "title");
        var file = new ByteArrayContent(bytes);
        file.Headers.ContentType = new MediaTypeHeaderValue("text/csv");
        form.Add(file, "file", "report.csv");

        using var response = await http.PostAsync("https://api.testingapis.com/api/echo", form);
        response.EnsureSuccessStatusCode();
        var echo = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;

        Assert.Equal("Quarterly report", echo.GetProperty("form").GetProperty("title").GetString());
        var sent = Assert.Single(echo.GetProperty("files").EnumerateArray());
        Assert.Equal("file", sent.GetProperty("field").GetString());
        Assert.Equal("report.csv", sent.GetProperty("fileName").GetString());
        Assert.Equal("text/csv", sent.GetProperty("contentType").GetString());
        Assert.Equal(bytes.Length, sent.GetProperty("size").GetInt32());
    }
}

Checksums and a realistic upload endpoint

Two more endpoints are for signed-in members. POST /api/bodies/formsign in to seereports the same fields and files plus each file's SHA-256, so a test can prove the bytes arrived intact, not just the size. POST /api/formdata/uploadsign in to see behaves like a real upload API: it stores the file and answers 201 Created with an id.

Terminal
$ curl -s https://api.testingapis.com/api/bodies/form -F "file=@report.csv;type=text/csv"
{"contentType":"multipart/form-data; boundary=------------------------8iZt5Fmg2P4QL6fBoNn1Ms","encoding":"multipart","fields":{},"files":[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22,"sha256":"2e9f445b06e5ca0fb9638798078f6a239ea1fcace10e1dcfb70ceb3a3322ad44"}]}
This example uses endpoints for signed-in members.Sign inCreate a free account

What to check in your upload code

  • The Content-Type header is left to the library, so it carries the boundary.
  • Every file part has a file name and a content type.
  • The field names match what the server expects (file vs files vs upload).
  • Text fields and files go in the same multipart body, not as a separate JSON request.
  • A test asserts what the server received: names, types and sizes, not only the status code.

For the rest of a request, headers and query strings included, the echo API reference lists every field it returns.