Encoding Binary and File Uploads in OpenAPI
Problem: the API accepts document uploads, and the specification describes the endpoint as type: string, format: binary — a construction that means nothing in OpenAPI 3.1, generates a string in every client, and tells a consumer nothing about size limits or accepted media types. This guide is part of Data Format and Precision Contracts and covers the three ways binary content legitimately travels, and how to describe each.
Root Cause: 3.0 Habits in a 3.1 Document
OpenAPI 3.0 used format: binary and format: byte as pseudo-formats. OpenAPI 3.1 aligns with JSON Schema 2020-12, where neither exists. The replacements are contentMediaType and contentEncoding — real JSON Schema keywords — and, for a raw body, the media type of the content entry itself.
Step 1: Raw Body for a Single File
The simplest transport, and the right default when there is no metadata to send alongside.
paths:
/documents/{id}/content:
put:
operationId: uploadDocumentContent
summary: Replace a document's content.
description: |
The request body is the raw file. Maximum decoded size is 25 MB;
larger bodies are rejected at the edge with 413 before reaching the
service. Accepted media types are listed below — the Content-Type
header determines how the document is indexed.
parameters:
- name: id
in: path
required: true
schema: { type: string, pattern: '^doc_[a-zA-Z0-9]{16}$' }
requestBody:
required: true
content:
application/pdf: { schema: { type: string, contentMediaType: application/pdf } }
image/png: { schema: { type: string, contentMediaType: image/png } }
image/jpeg: { schema: { type: string, contentMediaType: image/jpeg } }
responses:
'204': { description: Content stored. }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'415': { $ref: '#/components/responses/UnsupportedMediaType' }
Listing each accepted media type as its own content entry is what makes 415 enforceable by tooling rather than by prose — a request-validating gateway can reject an unlisted type without reaching your code.
Step 2: Multipart When Metadata Travels With the File
paths:
/documents:
post:
operationId: createDocument
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file, title]
properties:
title:
type: string
maxLength: 200
tags:
type: array
maxItems: 10
items: { type: string, maxLength: 32 }
file:
type: string
contentMediaType: application/octet-stream
description: The document. Maximum decoded size 25 MB.
encoding:
# `encoding` is where per-part transport details are declared.
file:
contentType: application/pdf, image/png, image/jpeg
tags:
style: form
explode: true
responses:
'201':
description: Created.
content:
application/json:
schema: { $ref: '#/components/schemas/Document' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'415': { $ref: '#/components/responses/UnsupportedMediaType' }
The encoding object is the piece most documents omit. Without it, a consumer cannot tell which media types the file part accepts, or how a repeated field such as tags is serialised across parts.
Step 3: Base64 Only for Small, Bounded Content
components:
schemas:
Signature:
type: object
required: [algorithm, value]
additionalProperties: false
properties:
algorithm: { type: string, enum: [ed25519] }
value:
type: string
contentEncoding: base64
contentMediaType: application/octet-stream
maxLength: 128 # encoded characters — 64 raw bytes becomes 88
pattern: '^[A-Za-z0-9+/]+={0,2}$'
description: Base64-encoded 64-byte signature.
Thumbnail:
type: object
required: [content]
properties:
content:
type: string
contentEncoding: base64
contentMediaType: image/png
maxLength: 200000 # ~146 KB decoded — a real ceiling, not a hope
Two constraints, not one. maxLength bounds the string the parser must hold; the pattern rejects a value that is not base64 at all, before any decoder runs. Both are ordinary JSON Schema keywords and both are enforced unconditionally.
Why the ceiling matters: the JSON body is parsed in full before your handler runs. A 40 MB base64 string is a 53 MB string in memory plus the decoded bytes, per concurrent request.
Step 4: Document the Rejections
Two failure modes are specific to uploads and both deserve their own problem document:
components:
responses:
PayloadTooLarge:
description: The body exceeded the documented size limit.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
example:
type: https://docs.example.com/errors/payload-too-large
title: Payload too large
status: 413
code: PAYLOAD_TOO_LARGE
detail: The maximum decoded size is 26214400 bytes.
UnsupportedMediaType:
description: The Content-Type is not one this operation accepts.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
example:
type: https://docs.example.com/errors/unsupported-media-type
title: Unsupported media type
status: 415
code: UNSUPPORTED_MEDIA_TYPE
detail: Accepted types are application/pdf, image/png, image/jpeg.
Naming the limit and the accepted types in detail turns both errors into something a client can fix without reading documentation.
Step 5: Return a Reference, Not the Bytes
components:
schemas:
Document:
type: object
required: [id, media_type, size_bytes, content_url]
properties:
id: { type: string, pattern: '^doc_[a-zA-Z0-9]{16}$' }
media_type: { type: string, example: application/pdf }
size_bytes: { type: integer, minimum: 0, description: Decoded size. }
checksum_sha256:
type: string
pattern: '^[a-f0-9]{64}$'
description: Of the decoded bytes — lets the client verify what we stored.
content_url:
type: string
format: uri
description: Time-limited URL for retrieval. Not part of the identity.
The checksum is worth including. It gives the client a way to confirm the stored bytes match what it sent, which is the only end-to-end integrity check an upload API can offer.
Verification
# A raw upload of an accepted type succeeds.
curl -s -o /dev/null -w '%{http_code}\n' -X PUT \
-H 'Content-Type: application/pdf' --data-binary @contract.pdf \
https://api.example.com/v2/documents/doc_0123456789abcdef/content
# 204
# An unlisted media type is refused with a usable message.
curl -s -X PUT -H 'Content-Type: application/zip' --data-binary @archive.zip \
https://api.example.com/v2/documents/doc_0123456789abcdef/content | jq -r '.code, .detail'
# UNSUPPORTED_MEDIA_TYPE
# Accepted types are application/pdf, image/png, image/jpeg.
# An oversized body is rejected without being buffered.
head -c 30000000 /dev/urandom > /tmp/big.pdf
curl -s -o /dev/null -w '%{http_code}\n' -X PUT \
-H 'Content-Type: application/pdf' --data-binary @/tmp/big.pdf \
https://api.example.com/v2/documents/doc_0123456789abcdef/content
# 413
# The base64 ceiling is enforced by the schema, not by the handler.
node -e 'console.log(JSON.stringify({content:"A".repeat(200001)}))' \
| curl -s -XPOST -H 'content-type: application/json' -d @- \
https://api.example.com/v2/thumbnails | jq -r '.errors[0].path'
# content
Edge Cases and Caveats
format: binaryin a 3.1 document. It is meaningless and silently ignored, so a document carrying it looks specified and is not. Replace it withcontentMediaTypewhen upgrading.- Base64 length arithmetic. Encoded length is
4 * ceil(n / 3), so a 25 MB limit is about 34 MB of characters. ComputemaxLengthfrom the decoded ceiling rather than guessing. - Base64 variants. Standard base64 and base64url differ in two characters and in padding. Say which you accept; a
patternthat allows both accepts values your decoder may reject. - Content sniffing. A client can declare
application/pdfand send anything. Verify the magic bytes server-side; theContent-Typeheader is a claim, not evidence. - Mock servers. A Prism mock will answer a binary endpoint with an example or an empty body and will not check the uploaded bytes, so the 413 and 415 paths need a contract test against the real service.
- Gateway limits below your documented limit. If the edge caps bodies at 10 MB and the contract says 25 MB, consumers hit a generic gateway error rather than your problem document. Align the two and test at the boundary.
Choosing the Size Threshold
The rule of thumb above — a hundred kilobytes — is a starting point rather than a law, and it is worth deriving your own from two numbers. The first is the memory your service can afford per concurrent request, since a base64 payload occupies roughly 2.4 times the file size while it is being decoded. The second is the timeout at your edge, because a large body arriving over a slow connection can exhaust it before your handler ever runs. Whichever of those two limits binds first is your real threshold, and it is usually smaller than the one people assume.
Documenting the Retrieval Side
An upload endpoint is half of a pair. Document the retrieval endpoint with the same care — its media types, whether the URL expires, and whether a range request is supported — because a client that can upload and cannot reliably fetch has an integration that only half works.
Frequently Asked Questions
What replaced format binary in OpenAPI 3.1?
contentMediaType, and contentEncoding for encoded payloads. OpenAPI 3.1 aligns with JSON Schema 2020-12, where format: binary has no meaning; a raw body is expressed by the media type of the content entry itself.
When is base64 in JSON acceptable?
For small, bounded payloads where a single atomic request matters — a signature, a thumbnail, a short attachment. It inflates by roughly a third and is parsed entirely into memory, so anything that can grow needs its own endpoint.
How do I declare a size limit in the contract?
maxLength on a base64 string field, counted in encoded characters, and prose stating the decoded limit for raw and multipart bodies — OpenAPI has no size keyword for those, so the gateway enforces it and the description publishes it.
Should uploads return the file or a reference?
A reference. Return an identifier and a URL, and let the client fetch the content separately. Echoing the uploaded bytes doubles the transfer for no benefit and makes the response impossible to cache.
Do mock servers handle binary endpoints?
Partially. Most will return an example or an empty body of the right media type but will not validate the uploaded bytes. Cover the rejection paths — too large, wrong media type — with a contract test against the real service.