{"record":{"id":"3453ac6d0c0b66db","repo":"paperclipai/paperclip","slug":"plugin-api-route-not-found","errorCode":null,"errorMessage":"Plugin API route not found","messagePattern":"Plugin API route not found","errorType":"http","errorClass":null,"httpStatus":404,"severity":"error","filePath":"server/src/routes/plugins.ts","lineNumber":1867,"sourceCode":"    const isWorkerRunning = typeof bridgeDeps.workerManager.isRunning === \"function\"\n      ? bridgeDeps.workerManager.isRunning(plugin.id)\n      : true;\n    if (!isWorkerRunning) {\n      res.status(503).json({ error: \"Plugin worker is not running\" });\n      return;\n    }\n    if (!plugin.manifestJson.capabilities.includes(\"api.routes.register\")) {\n      res.status(404).json({ error: \"Plugin does not expose scoped API routes\" });\n      return;\n    }\n\n    const requestPath = req.path || \"/\";\n    const routes = plugin.manifestJson.apiRoutes ?? [];\n    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));","sourceCodeStart":1849,"sourceCodeEnd":1885,"githubUrl":"https://github.com/paperclipai/paperclip/blob/a7e689b3c35347b529cb9f54c9b9a8575a3dcab6/server/src/routes/plugins.ts#L1849-L1885","documentation":"Returned as HTTP 404 when the plugin exposes scoped routes but no entry in manifestJson.apiRoutes matches the request. matchScopedApiRoute requires the HTTP method to match exactly (case-sensitive) and the path to have the same number of segments; trailing slashes are stripped, and \":param\" segments capture values. Any extra/missing segment or method mismatch yields null and this 404.","triggerScenarios":"POST to a route declared as method GET; requesting /api/plugins/:id/api/issues/123/comments when only /issues/:issueId is declared; calling a path that was renamed in a newer manifest version; case mismatch in a literal segment (comparison is exact).","commonSituations":"Client and plugin version drift after a route rename; typo in the client path; using the wrong HTTP verb; assuming Express-style optional segments or wildcard matching (the matcher supports neither).","solutions":["GET /api/plugins/:pluginId and read manifestJson.apiRoutes to get the exact method/path pairs the plugin declares.","Fix the client call to match a declared routeKey's method and segment count exactly (params only where the route has :param segments).","If the route should exist, add it to the manifest's apiRoutes and upgrade/reinstall the plugin so the stored manifest picks it up."],"exampleFix":"// before — manifest declares GET /issues, client posts to a subpath\nawait fetch(`/api/plugins/${id}/api/issues`, { method: \"POST\" });\n\n// after — match the declared method and path\nawait fetch(`/api/plugins/${id}/api/issues`); // GET /issues","handlingStrategy":"validation","validationCode":"async function listDeclaredRoutes(apiBase: string, pluginId: string) {\n  const plugin = await (await fetch(`${apiBase}/api/plugins/${encodeURIComponent(pluginId)}`)).json();\n  return plugin.manifestJson?.apiRoutes ?? [];\n}\nasync function routeIsDeclared(apiBase: string, pluginId: string, method: string, path: string): Promise<boolean> {\n  const routes = await listDeclaredRoutes(apiBase, pluginId);\n  const norm = (p: string) => p.replace(/\\/+$/, \"\") || \"/\";\n  return routes.some((r: { method: string; path: string }) =>\n    r.method === method &&\n    norm(r.path).split(\"/\").filter(Boolean).length === norm(path).split(\"/\").filter(Boolean).length\n  );\n}","typeGuard":"interface PluginApiRouteDeclaration { routeKey: string; method: string; path: string }\nfunction isDeclaredRoute(\n  routes: PluginApiRouteDeclaration[],\n  method: string,\n  path: string,\n): PluginApiRouteDeclaration | undefined {\n  const norm = (p: string) => p.replace(/\\/+$/, \"\") || \"/\";\n  return routes.find((r) => r.method === method && norm(r.path) === norm(path));\n}","tryCatchPattern":null,"preventionTips":["Generate client call sites from the plugin's manifest apiRoutes instead of hand-writing paths.","Treat route renames as breaking changes — bump the plugin major version and migrate clients.","Remember the matcher is exact: no wildcards, no optional segments, case-sensitive literal segments."],"tags":["plugin","routing","http-404","scoped-api","manifest"],"backgroundTag":"route-not-found","analyzedSha":"a7e689b3c35347b529cb9f54c9b9a8575a3dcab6","analyzedAt":"2026-08-18T22:49:45.177Z","schemaVersion":2},"datasetVersion":"2026-08-22T04:17:13.399Z"}