{"record":{"id":"5fc76340bc257725","repo":"paperclipai/paperclip","slug":"plugin-api-routes-accept-json-requests-only","errorCode":null,"errorMessage":"Plugin API routes accept JSON requests only","messagePattern":"Plugin API routes accept JSON requests only","errorType":"http","errorClass":null,"httpStatus":415,"severity":"error","filePath":"server/src/routes/plugins.ts","lineNumber":1881,"sourceCode":"    const match = routes\n      .map((route) => ({ route, params: matchScopedApiRoute(route, req.method, requestPath) }))\n      .find((candidate) => candidate.params !== null);\n    if (!match || !match.params) {\n      res.status(404).json({ error: \"Plugin API route not found\" });\n      return;\n    }\n\n    try {\n      assertScopedApiAuth(req, match.route);\n      const companyId = await resolveScopedApiCompanyId(match.route, match.params, req);\n      if (!companyId) {\n        res.status(400).json({ error: \"Unable to resolve company for plugin API route\" });\n        return;\n      }\n      assertCompanyAccess(req, companyId);\n      await enforceScopedApiCheckout(req, match.route, match.params, companyId);\n      if (req.method !== \"GET\" && req.headers[\"content-type\"] && !req.is(\"application/json\")) {\n        res.status(415).json({ error: \"Plugin API routes accept JSON requests only\" });\n        return;\n      }\n      const requestBody = req.body ?? null;\n      const bodySize = Buffer.byteLength(JSON.stringify(requestBody));\n      if (bodySize > PLUGIN_API_BODY_LIMIT_BYTES) {\n        res.status(413).json({ error: \"Plugin API request body is too large\" });\n        return;\n      }\n\n      const actor = getActorInfo(req);\n      const input: PluginScopedApiRequest = {\n        routeKey: match.route.routeKey,\n        method: req.method,\n        path: requestPath,\n        params: match.params,\n        query: normalizeQuery(req.query),\n        body: requestBody,\n        actor: {","sourceCodeStart":1863,"sourceCodeEnd":1899,"githubUrl":"https://github.com/paperclipai/paperclip/blob/a7e689b3c35347b529cb9f54c9b9a8575a3dcab6/server/src/routes/plugins.ts#L1863-L1899","documentation":"Returned as HTTP 415 when a non-GET scoped API request carries a Content-Type header that req.is(\"application/json\") rejects. The gateway forwards only JSON bodies to plugin workers, so form-encoded, text, and multipart bodies are refused before dispatch. GET requests and bodyless requests without a content-type header pass through.","triggerScenarios":"POST/PUT/PATCH/DELETE to /api/plugins/:pluginId/api/* with Content-Type application/x-www-form-form-urlencoded, multipart/form-data, or text/plain — e.g. a plain HTML form post or fetch with a URLSearchParams body (which sets form-urlencoded automatically).","commonSituations":"fetch call missing the JSON header; native form submission targeting the plugin route; a client library that defaults to form encoding; attempting a file upload through the scoped API.","solutions":["Set Content-Type: application/json and send JSON.stringify(body).","For empty bodies on non-GET verbs, omit the Content-Type header entirely — the check only fires when the header is present.","For file uploads use a dedicated upload surface; the scoped gateway is JSON-only and caps bodies at 1 MB."],"exampleFix":"// before\nawait fetch(`/api/plugins/${id}/api/items`, {\n  method: \"POST\",\n  body: new URLSearchParams({ name: \"x\" }), // sends application/x-www-form-urlencoded\n});\n\n// after\nawait fetch(`/api/plugins/${id}/api/items`, {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({ name: \"x\" }),\n});","handlingStrategy":"validation","validationCode":"function jsonFetch(url: string, init: RequestInit = {}): Promise<Response> {\n  const headers = new Headers(init.headers);\n  const hasBody = init.body != null;\n  if (hasBody) headers.set(\"Content-Type\", \"application/json\");\n  else headers.delete(\"Content-Type\"); // avoid 415 on bodyless non-GET calls\n  return fetch(url, { ...init, headers, body: hasBody ? JSON.stringify(init.body) : undefined });\n}","typeGuard":"function isJsonContentType(contentType: string | null | undefined): boolean {\n  return typeof contentType === \"string\" && /^application\\/json\\b/i.test(contentType.trim());\n}","tryCatchPattern":null,"preventionTips":["Always pair JSON.stringify with an explicit Content-Type: application/json header.","Never pass URLSearchParams or FormData to scoped plugin routes.","Centralize plugin API calls in one client wrapper that enforces JSON headers."],"tags":["plugin","http-415","content-type","json","scoped-api"],"backgroundTag":"unsupported-media-type","analyzedSha":"a7e689b3c35347b529cb9f54c9b9a8575a3dcab6","analyzedAt":"2026-08-18T22:49:45.177Z","schemaVersion":2},"datasetVersion":"2026-08-22T04:17:13.399Z"}