paperclipai/paperclip · error

Plugin API routes accept JSON requests only

Error message

Plugin API routes accept JSON requests only

What it means

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.

Source

Thrown at server/src/routes/plugins.ts:1881

    const match = routes
      .map((route) => ({ route, params: matchScopedApiRoute(route, req.method, requestPath) }))
      .find((candidate) => candidate.params !== null);
    if (!match || !match.params) {
      res.status(404).json({ error: "Plugin API route not found" });
      return;
    }

    try {
      assertScopedApiAuth(req, match.route);
      const companyId = await resolveScopedApiCompanyId(match.route, match.params, req);
      if (!companyId) {
        res.status(400).json({ error: "Unable to resolve company for plugin API route" });
        return;
      }
      assertCompanyAccess(req, companyId);
      await enforceScopedApiCheckout(req, match.route, match.params, companyId);
      if (req.method !== "GET" && req.headers["content-type"] && !req.is("application/json")) {
        res.status(415).json({ error: "Plugin API routes accept JSON requests only" });
        return;
      }
      const requestBody = req.body ?? null;
      const bodySize = Buffer.byteLength(JSON.stringify(requestBody));
      if (bodySize > PLUGIN_API_BODY_LIMIT_BYTES) {
        res.status(413).json({ error: "Plugin API request body is too large" });
        return;
      }

      const actor = getActorInfo(req);
      const input: PluginScopedApiRequest = {
        routeKey: match.route.routeKey,
        method: req.method,
        path: requestPath,
        params: match.params,
        query: normalizeQuery(req.query),
        body: requestBody,
        actor: {

View on GitHub (pinned to a7e689b3c3)

Solutions

  1. Set Content-Type: application/json and send JSON.stringify(body).
  2. For empty bodies on non-GET verbs, omit the Content-Type header entirely — the check only fires when the header is present.
  3. For file uploads use a dedicated upload surface; the scoped gateway is JSON-only and caps bodies at 1 MB.

Example fix

// before
await fetch(`/api/plugins/${id}/api/items`, {
  method: "POST",
  body: new URLSearchParams({ name: "x" }), // sends application/x-www-form-urlencoded
});

// after
await fetch(`/api/plugins/${id}/api/items`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "x" }),
});
Defensive patterns

Strategy: validation

Validate before calling

function jsonFetch(url: string, init: RequestInit = {}): Promise<Response> {
  const headers = new Headers(init.headers);
  const hasBody = init.body != null;
  if (hasBody) headers.set("Content-Type", "application/json");
  else headers.delete("Content-Type"); // avoid 415 on bodyless non-GET calls
  return fetch(url, { ...init, headers, body: hasBody ? JSON.stringify(init.body) : undefined });
}

Type guard

function isJsonContentType(contentType: string | null | undefined): boolean {
  return typeof contentType === "string" && /^application\/json\b/i.test(contentType.trim());
}

Prevention

When it happens

Trigger: 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).

Common situations: 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.

Related errors


AI-assisted analysis of paperclipai/paperclip@a7e689b3c3 (2026-08-18). Data as JSON: /api/errors/5fc76340bc257725. Report an issue: GitHub.