REST API File Upload: Best Practices, Design Patterns & Code Examples

April 3, 2026 · Updated April 23, 2026 · 14 min read

This guide is the reference for designing and implementing REST API file uploads correctly. It covers the protocol-level details (multipart/form-data anatomy, status codes, idempotency), the design decisions you have to make (POST vs PUT, sync vs async, response schema), and working code in cURL, Python, Node.js, Java, and PHP. If you just want a quick copy-paste example in one language, see How to Upload Files via API instead.

What Is a REST API File Upload?

A REST API file upload is an HTTP POST request that transmits binary file data from a client to a server. The file is encoded using the multipart/form-data content type, which is the standard defined in RFC 7578 for sending files over HTTP.

Unlike JSON payloads where the body is a single text block, multipart requests split the body into named parts separated by a boundary string. Each part can carry different data: one part holds the file bytes, another might hold a description or metadata field. This design allows a single request to transmit both binary and text data simultaneously.

REST APIs that accept file uploads typically return a JSON response containing a URL where the file can be accessed, a unique file identifier, and the file size. The API handles storage, CDN distribution, and URL generation, so your application only needs to make one HTTP call.

HTTP Request Anatomy

Understanding the raw HTTP request helps you debug problems when uploads fail. Here is what a file upload request looks like on the wire:

POST /v1/upload HTTP/1.1
Host: filepost.dev
X-API-Key: your_api_key_here
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 84532

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf

[binary file bytes]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

The key elements are:

The critical rule: never set the Content-Type header manually when sending multipart requests. Your HTTP library needs to generate the boundary string and embed it in both the header and the body. If you override the header, the boundary will not match and the server will reject the request with a 400 error.

REST API Design Decisions for File Uploads

Before writing any code, a REST API that accepts file uploads has to settle several design decisions. Getting these right at the start avoids painful rewrites later.

HTTP Verb: POST vs PUT

Use POST when the server generates the resource identifier. The client uploads a file, the server returns a new file_id and URL. This is the default for most file upload APIs because the client usually does not know (or care) what the canonical ID will be.

Use PUT when the client controls the resource identifier. A good example is S3: the client decides the object key and PUTs the bytes to that exact path. PUT is idempotent — repeating the request replaces the resource at the same URL. POST is not, and a retry may create a duplicate.

If you are choosing between the two for a new API, pick POST unless you have a specific reason to let clients name resources. POST is what developers expect from upload endpoints, and it matches the conventions of every major file upload provider.

Response Schema: What to Return

A well-designed upload response includes everything the client needs to store and reference the file later. At minimum:

{
  "file_id": "a1b2c3d4e5f6",
  "url": "https://cdn.example.com/f/a1b2c3d4e5f6.pdf",
  "filename": "report.pdf",
  "content_type": "application/pdf",
  "size": 84210,
  "created_at": "2026-04-23T10:32:14Z"
}

Two common mistakes to avoid:

Status Codes

REST conventions for file upload responses:

Idempotency and Upload Deduplication

File uploads are inherently not idempotent: a naive retry creates a duplicate. For any upload flow where retries can happen (mobile apps on flaky networks, workflow engines with retry policies), your API should support deduplication.

The standard pattern is an idempotency key: the client generates a UUID per logical upload and sends it in a header like Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000. The server caches the response keyed by that UUID for some window (commonly 24 hours). Retries with the same key return the cached response instead of storing the file twice.

A simpler alternative is content hashing: the client computes the file's SHA-256 before upload and sends it as a header. The server uses it as a deduplication key on its side. Stripe and most payment APIs use idempotency keys; most file storage APIs use content hashes. Both work.

Sync vs Async Upload Patterns

Simple synchronous upload works for most use cases:

POST /v1/upload (file bytes)
  → 200 OK { "file_id": "...", "url": "..." }

However, if your API does non-trivial processing on uploaded files (virus scanning, image transformations, video transcoding), a single synchronous request either ties up an HTTP worker for a long time or hits client/proxy timeouts. The async pattern returns immediately and lets the client poll for completion:

POST /v1/upload (file bytes)
  → 202 Accepted { "job_id": "...", "status_url": "/v1/uploads/jobs/abc123" }

GET /v1/uploads/jobs/abc123
  → 200 OK { "status": "processing" }
  → ... later ...
  → 200 OK { "status": "complete", "file_id": "...", "url": "..." }

For very large files, the third option is presigned URLs: the API returns a short-lived upload URL pointing directly at object storage, and the client PUTs bytes to that URL. This offloads the upload from your API servers entirely and is what S3, GCS, and Azure Blob use. The tradeoff is that you cannot inspect the bytes during upload — validation has to happen via a post-upload webhook or storage event.

URL Structure and Resource Design

Resource-oriented REST would suggest POST /v1/files (collection) for uploads and DELETE /v1/files/{id} for deletion, with the upload endpoint named for the noun rather than the verb. POST /v1/upload is also common and acceptable, but less "RESTful" in strict interpretations.

If you support multiple upload modes (direct, presigned, chunked), name the routes clearly:

Version your API from day one (/v1/), even if v2 feels far away. The alternative is painful migrations when you inevitably need to change the response schema.

cURL Example

cURL is the simplest way to upload a file to a REST API. The -F flag automatically handles multipart encoding:

curl -X POST https://filepost.dev/v1/upload \
  -H "X-API-Key: your_api_key_here" \
  -F "file=@./document.pdf"

The @ prefix tells cURL to read the file from the filesystem. Without it, cURL would send the literal string as text. cURL sets Content-Type: multipart/form-data and generates the boundary automatically when you use -F.

A successful response:

{
  "url": "https://cdn.filepost.dev/file/filepost/uploads/a1/a1b2c3.pdf",
  "file_id": "a1b2c3d4e5f6",
  "size": 84210
}

Python Example

Python's requests library is the standard for HTTP file uploads. The files parameter handles multipart encoding automatically:

import requests

response = requests.post(
    "https://filepost.dev/v1/upload",
    headers={"X-API-Key": "your_api_key_here"},
    files={"file": open("document.pdf", "rb")},
    timeout=30
)

data = response.json()
print(f"URL: {data['url']}")
print(f"File ID: {data['file_id']}")
print(f"Size: {data['size']} bytes")

Important details for Python uploads:

Node.js Example

Node.js 18+ includes built-in fetch and FormData, so no external packages are needed:

import { readFile } from "fs/promises";

const fileBuffer = await readFile("./document.pdf");
const file = new File([fileBuffer], "document.pdf", {
  type: "application/pdf",
});

const form = new FormData();
form.append("file", file);

const response = await fetch("https://filepost.dev/v1/upload", {
  method: "POST",
  headers: { "X-API-Key": "your_api_key_here" },
  body: form,
});

const data = await response.json();
console.log(`URL: ${data.url}`);
console.log(`File ID: ${data.file_id}`);
console.log(`Size: ${data.size} bytes`);

The fetch API detects that the body is a FormData instance and sets the Content-Type header with the boundary automatically. This example uses top-level await, which requires "type": "module" in your package.json.

Java Example

Java 11+ includes HttpClient in the standard library, but building multipart requests requires manual boundary handling since there is no built-in FormData equivalent:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;

public class FileUpload {
    public static void main(String[] args) throws Exception {
        String apiKey = "your_api_key_here";
        Path filePath = Path.of("document.pdf");
        String boundary = "----FilePostBoundary" + System.currentTimeMillis();

        byte[] fileBytes = Files.readAllBytes(filePath);
        String mimeType = Files.probeContentType(filePath);
        if (mimeType == null) mimeType = "application/octet-stream";

        String bodyPrefix = "--" + boundary + "\r\n"
            + "Content-Disposition: form-data; name=\"file\"; filename=\""
            + filePath.getFileName() + "\"\r\n"
            + "Content-Type: " + mimeType + "\r\n\r\n";
        String bodySuffix = "\r\n--" + boundary + "--\r\n";

        byte[] prefixBytes = bodyPrefix.getBytes();
        byte[] suffixBytes = bodySuffix.getBytes();
        byte[] body = new byte[prefixBytes.length + fileBytes.length + suffixBytes.length];
        System.arraycopy(prefixBytes, 0, body, 0, prefixBytes.length);
        System.arraycopy(fileBytes, 0, body, prefixBytes.length, fileBytes.length);
        System.arraycopy(suffixBytes, 0, body, prefixBytes.length + fileBytes.length, suffixBytes.length);

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://filepost.dev/v1/upload"))
            .header("X-API-Key", apiKey)
            .header("Content-Type", "multipart/form-data; boundary=" + boundary)
            .POST(HttpRequest.BodyPublishers.ofByteArray(body))
            .build();

        HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println("Status: " + response.statusCode());
        System.out.println("Body: " + response.body());
    }
}

Java's HttpClient does not generate multipart boundaries for you, so you must construct the body manually. The boundary string in the Content-Type header must exactly match the delimiters in the body. For production applications, consider using Apache HttpComponents or OkHttp, which provide multipart builders that handle this automatically.

PHP Example

PHP's built-in cURL extension handles file uploads cleanly using CURLFile. This is the correct approach for PHP 5.5+ : the old @/path/to/file string syntax is deprecated and disabled by default in modern PHP.

<?php
$apiKey   = 'your_api_key_here';
$filePath = '/path/to/document.pdf';

$curl = curl_init();
curl_setopt_array($curl, [
    CURLOPT_URL            => 'https://filepost.dev/v1/upload',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ['X-API-Key: ' . $apiKey],
    CURLOPT_POSTFIELDS     => ['file' => new CURLFile($filePath)],
]);

$response = curl_exec($curl);
curl_close($curl);

$data = json_decode($response, true);
echo 'URL: '     . $data['url']     . PHP_EOL;
echo 'File ID: ' . $data['file_id'] . PHP_EOL;
echo 'Size: '    . $data['size']    . ' bytes' . PHP_EOL;

Do not set the Content-Type header yourself. When you pass an array to CURLOPT_POSTFIELDS containing a CURLFile object, PHP sets the correct multipart/form-data header with the boundary automatically.

Common Errors and Troubleshooting

These are the errors developers hit most often when implementing REST API file uploads, along with how to fix each one:

413 Request Entity Too Large

The file exceeds the server's maximum upload size. Check the API documentation for size limits. FilePost allows up to 50MB on the free tier, 200MB on Starter, and 500MB on Pro. If you are behind a reverse proxy like Nginx, also check its client_max_body_size setting, as it defaults to 1MB and will reject larger uploads before they reach your API.

401 Unauthorized

The API key is missing, invalid, or expired. Verify that you are sending the correct header name. FilePost uses X-API-Key, but other APIs may use Authorization: Bearer <token>. Double-check for leading or trailing whitespace in the key value.

400 Bad Request

Usually means the multipart body is malformed. The most common cause is setting the Content-Type header manually instead of letting your HTTP library generate it. If you must set the header (as in Java), ensure the boundary string matches exactly in both the header and the body delimiters.

Request Timeout

Large file uploads on slow connections can exceed default timeout values. Increase your client timeout proportional to the file size. A reasonable formula is: base_timeout + (file_size_mb * 2) seconds. Also check if a proxy or load balancer has its own timeout that is lower than your client timeout.

Connection Reset or Broken Pipe

This typically happens when the server closes the connection before the client finishes sending the body. Common causes include exceeding the max upload size (the server rejects early) or a network interruption. Implement retry logic with exponential backoff for transient failures, but check for 413 errors first to avoid retrying permanently rejected uploads.

Best Practices

Upload Your First File in 30 Seconds

FilePost gives you 300 free uploads per month with a single API call. No buckets, no IAM roles, no SDK required.

Get Your Free API Key