Skip to main content

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.

Three ways binary content travels A raw body streams and carries no metadata; multipart carries fields alongside the file and still streams; base64 inside JSON is atomic and simple but inflates the payload and must be fully buffered. raw body PUT with application/pdf streams — constant memory no size inflation no metadata alongside one file, nothing else multipart/form-data file plus form fields streams — constant memory small framing overhead metadata per part the browser default base64 in JSON one atomic JSON request buffered entirely in memory about 1.37x the bytes metadata is free small and bounded only

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.

What the two transports cost in memory A streamed raw body holds a fixed-size buffer regardless of file size. A base64 JSON body holds the encoded string and the decoded bytes at once, so memory grows with roughly 2.4 times the file size per concurrent request. file size peak memory streamed raw body a fixed buffer, whatever the size base64 in JSON encoded + decoded, per 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.

What the upload response should carry The response returns an identifier, the stored size, a checksum the client can verify against what it sent, and a time-limited retrieval URL — not the content itself. PUT with the bytes streamed, 8 MB stored checksum computed 201 response id, media_type, size_bytes checksum_sha256 content_url — not the content The checksum 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: binary in a 3.1 document. It is meaningless and silently ignored, so a document carrying it looks specified and is not. Replace it with contentMediaType when upgrading.
  • Base64 length arithmetic. Encoded length is 4 * ceil(n / 3), so a 25 MB limit is about 34 MB of characters. Compute maxLength from the decoded ceiling rather than guessing.
  • Base64 variants. Standard base64 and base64url differ in two characters and in padding. Say which you accept; a pattern that allows both accepts values your decoder may reject.
  • Content sniffing. A client can declare application/pdf and send anything. Verify the magic bytes server-side; the Content-Type header 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.