payloadcms/payload · error · APIError

Content-Type expected to be application/json

Error message

Content-Type expected to be application/json

What it means

The `POST /upload-instructions` endpoint reads the body via `req.json()`. Before doing so it asserts that `req.json` is truthy — a property the request has only when the framework detected a JSON-parseable body (i.e. a JSON-compatible `Content-Type`). If absent, the endpoint rejects with HTTP 400 rather than letting `req.json()` throw an opaque parse error. This guards all four downstream fields (`collectionSlug`, `filename`, `filesize`, `mimeType`).

Source

Thrown at packages/payload/src/uploads/endpoints/uploadInstructions.ts:92

    const collectionPermissions = (await getAccessResults({ req })).collections?.[
      upload.collectionSlug
    ]

    if (!collectionPermissions?.create && !collectionPermissions?.update) {
      throw new Forbidden(req.t)
    }
  }

  return uploadInstructions
    ? uploadInstructions.generate({ ...upload, overrideAccess, req })
    : generateStagedUploadInstructions({ ...upload, req })
}

export const uploadInstructionsEndpoint: Endpoint = {
  handler: async (req) => {
    if (!req.json) {
      throw new APIError('Content-Type expected to be application/json', 400)
    }

    const upload: unknown = await req.json()
    if (!isUploadInstructionsRequest(upload)) {
      throw new APIError('Invalid upload instructions request', 400)
    }

    return Response.json(await getUploadInstructions({ ...upload, req }))
  },
  method: 'post',
  path: '/upload-instructions',
}

/**
 * Stores or removes temporary files when no adapter-specific upload instructions are available.
 * PUT keeps the file until it is used in a document request.
 * DELETE removes a file the client no longer needs.
 */

View on GitHub (pinned to 00c58b35c0)

Solutions

  1. Set `Content-Type: application/json` on the request and send a stringified JSON body.
  2. Ensure no middleware/proxy strips or rewrites the header before it reaches Payload.
  3. If uploading bytes directly, use the staged `PUT /upload-instructions/:uploadId` route (raw body) instead of the JSON instructions route.
  4. Re-derive the request with `fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) })`.

Example fix

// before
await fetch(`${url}/api/upload-instructions`, {
  method: 'POST',
  // missing Content-Type, body is a Blob
  body: new Blob([JSON.stringify(payload)]),
})

// after
await fetch(`${url}/api/upload-instructions`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
})
Defensive patterns

Strategy: validation

Validate before calling

const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (headers['Content-Type'] !== 'application/json') {
  throw new Error('upload-instructions endpoint requires application/json')
}
await fetch(`${url}/api/upload-instructions`, { method: 'POST', headers, body: JSON.stringify(payload) })

Type guard

function isJsonContentType(headers: Record<string, string>): boolean {
  const ct = headers['Content-Type'] ?? headers['content-type'] ?? ''
  return ct.toLowerCase().includes('application/json')
}
if (!isJsonContentType(headers)) {
  headers['Content-Type'] = 'application/json'
}

Try / catch

try {
  const res = await fetch(`${url}/api/upload-instructions`, { method: 'POST', headers, body })
  if (res.status === 400) {
    const { message } = await res.json().catch(() => ({}))
    if (/application\/json/i.test(message ?? '')) {
      headers['Content-Type'] = 'application/json'
      // retry with corrected header
    }
  }
} catch (err) { /* network */ }

Prevention

When it happens

Trigger: Calling `POST /api/upload-instructions` with a missing or non-JSON `Content-Type` header (e.g. `multipart/form-data`, `text/plain`, `application/x-www-form-urlencoded`, or no header at all).

Common situations: A client built for the legacy multipart upload flow POSTs to the new instructions endpoint. A proxy/CDN rewrites or strips the `Content-Type`. A fetch call forgets to set the header, defaulting the body to a Blob/FormData. Cross-origin preflight issues cause the header to be dropped.

Related errors


AI-assisted analysis of payloadcms/payload@00c58b35c0 (2026-08-12). Data as JSON: /api/errors/5da797415385c3e9. Report an issue: GitHub.