File upload is the endpoint most teams hand-wave in an OpenAPI document. The request works in the one client they tested, so nobody notices that the spec says type: string, omits the multipart encoding, or models three files as a single one. Then generated SDKs send JSON, the mock server cannot accept a body, and a static scan of the code disagrees with what the route actually does.

There are exactly three upload shapes in HTTP. Model the right one and everything downstream works.

The three shapes

ShapeContent-TypeWhen you use itOpenAPI body
Mixed formmultipart/form-dataFile plus metadata fields, or several filesobject schema, file props are string: binary
Raw single fileapplication/octet-stream (or the real media type)Only the file, nothing else, often a PUTstring: binary
URL / base64application/jsonSmall files or references stored elsewhereNormal JSON property

Sending JSON with a base64 blob is a fourth option in practice, but it is just the JSON shape with a 33 percent size penalty. Prefer one of the first two for anything beyond a tiny icon.

multipart/form-data, field by field

An avatar upload that also takes an alt description and an optional make_primary flag is an object. Each part of the multipart body is a property. The file part uses type: string with format: binary; everything else is a normal field.

paths:
  /users/me/avatar:
    post:
      summary: Upload the current user's avatar
      operationId: uploadAvatar
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: PNG or JPEG, up to 5 MB.
                alt:
                  type: string
                  maxLength: 200
                  description: Accessible description. Omit to keep the current one.
                make_primary:
                  type: boolean
                  default: false
            encoding:
              file:
                contentType: image/png, image/jpeg
      responses:
        '201':
          description: Avatar stored
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Avatar'
        '413':
          description: File exceeds 5 MB
        '422':
          description: Unsupported media type or corrupt image

Two details matter. required: [file] marks the file part itself as mandatory while alt stays optional. The encoding.file.contentType hint tells codegen and docs that this part is an image, not an opaque blob. Do not set contentType: multipart/form-data on the part; that is the wire type of the whole body, not of one part.

Sending a raw binary body

When the URL already identifies the resource and there is no metadata, PUT the bytes directly. The body schema is a single binary string and the media type is the file's real type, or application/octet-stream when it is genuinely arbitrary.

paths:
  /objects/{storageKey}:
    put:
      summary: Store or replace one object
      operationId: putObject
      parameters:
        - $ref: '#/components/parameters/StorageKey'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '201': { description: Object created }
        '204': { description: Existing object replaced }

Raw binary is simpler for clients (no multipart assembly) and cheaper on the wire, but it cannot carry a second field. The moment you need metadata alongside the file, move to multipart.

Multiple files

An ordered list of files under one field name is an array of binary strings.

paths:
  /expenses/{id}/receipts:
    post:
      summary: Attach one or more receipts
      operationId: attachReceipts
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [receipts]
              properties:
                receipts:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items:
                    type: string
                    format: binary
      responses:
        '201': { description: Receipts stored }

The client sends repeated parts, all named receipts. That is the multipart convention for an array. Distinct named files with different meanings are different properties instead:

properties:
  front: { type: string, format: binary }
  back:  { type: string, format: binary }

Use an array for "zero to N of the same thing" and named properties for "this exact file and that exact file."

What to put in the spec, and what not to

OpenAPI describes the contract, not every server setting. Be precise about what is expressible.

ConcernHow to express it
File requiredrequired on the property
Count of filesminItems / maxItems on the array
Accepted media typesencoding.<field>.contentType, plus a 415/422 response
Text field encodingencoding.<field>.contentType: text/plain (defaults to text/plain)
JSON partencoding.<field>.contentType: application/json with an object schema
Max sizeNot enforceable in JSON Schema; document it and add a 413 response
File extension allowlistNot a schema concern; document and validate server-side

Do not invent maxFileSize keywords. Validators ignore unknown annotations, so they give false confidence. A documented limit paired with a 413 Payload Too Large response is the honest contract.

What codegen produces

With the shapes above, generators emit code a developer can use directly. A TypeScript generator turns the multipart example into a FormData body:

const form = new FormData();
form.append("file", fileBlob, "avatar.png");
form.append("alt", "Profile photo");
form.append("make_primary", "true");
await sdk.uploadAvatar(form);

The raw PUT generates a method that takes a Blob or Buffer. When the spec instead says type: string with no format: binary, generators emit a method expecting a JSON string, and every caller has to fix the SDK by hand.

What static scanners get wrong

When you reverse-engineer OpenAPI from code, upload routes are where naive parsers fail. Express with Multer distinguishes single("file"), array("receipts", 10), and fields([{ name: "front" }, { name: "back" }]); ASP.NET uses [FromForm] alongside IFormFile and List<IFormFile>; Go and Gin bind multipart through distinct tags; Django REST uses FileField and ListSerializer. A parser that only reads the handler signature usually types the body as string or marks it unknown.

A deterministic scanner should trace the actual multipart middleware to recover the field names, single-versus-array shape, and required parts, and report a gap only when the file handling is built dynamically. It should never flatten every upload to application/octet-stream, and it should never guess a 5 MB limit the code does not enforce.

Checklist before you ship the endpoint

  1. File parts are type: string, format: binary, never bare string or object.
  2. Mixed metadata uses multipart/form-data with an object schema; file-only uploads use raw binary.
  3. Repeated same-kind files are an array of binary; distinct files are named properties.
  4. encoding declares image or JSON parts; accepted types are paired with 415/422.
  5. Size and extension limits are documented with a 413, not hidden in fake keywords.
  6. Generate the client once and confirm it builds a FormData body or sends a Blob.

Get these six right and the same spec drives correct docs, a mock that accepts a real upload, a working SDK, and an honest code scan.

You can model each of these shapes, generate a client, and spin up a mock that accepts a real multipart body in one local-first workspace, right in your browser. If you are recovering upload contracts from an existing codebase, see how deterministic framework tracing handles multipart middleware in the Express and NestJS scan overview.