paperclipai/paperclip · error

Plugin API route not found

Error message

Plugin API route not found

What it means

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.

Source

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

    const isWorkerRunning = typeof bridgeDeps.workerManager.isRunning === "function"
      ? bridgeDeps.workerManager.isRunning(plugin.id)
      : true;
    if (!isWorkerRunning) {
      res.status(503).json({ error: "Plugin worker is not running" });
      return;
    }
    if (!plugin.manifestJson.capabilities.includes("api.routes.register")) {
      res.status(404).json({ error: "Plugin does not expose scoped API routes" });
      return;
    }

    const requestPath = req.path || "/";
    const routes = plugin.manifestJson.apiRoutes ?? [];
    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));

View on GitHub (pinned to a7e689b3c3)

Solutions

  1. GET /api/plugins/:pluginId and read manifestJson.apiRoutes to get the exact method/path pairs the plugin declares.
  2. Fix the client call to match a declared routeKey's method and segment count exactly (params only where the route has :param segments).
  3. If the route should exist, add it to the manifest's apiRoutes and upgrade/reinstall the plugin so the stored manifest picks it up.

Example fix

// before — manifest declares GET /issues, client posts to a subpath
await fetch(`/api/plugins/${id}/api/issues`, { method: "POST" });

// after — match the declared method and path
await fetch(`/api/plugins/${id}/api/issues`); // GET /issues
Defensive patterns

Strategy: validation

Validate before calling

async function listDeclaredRoutes(apiBase: string, pluginId: string) {
  const plugin = await (await fetch(`${apiBase}/api/plugins/${encodeURIComponent(pluginId)}`)).json();
  return plugin.manifestJson?.apiRoutes ?? [];
}
async function routeIsDeclared(apiBase: string, pluginId: string, method: string, path: string): Promise<boolean> {
  const routes = await listDeclaredRoutes(apiBase, pluginId);
  const norm = (p: string) => p.replace(/\/+$/, "") || "/";
  return routes.some((r: { method: string; path: string }) =>
    r.method === method &&
    norm(r.path).split("/").filter(Boolean).length === norm(path).split("/").filter(Boolean).length
  );
}

Type guard

interface PluginApiRouteDeclaration { routeKey: string; method: string; path: string }
function isDeclaredRoute(
  routes: PluginApiRouteDeclaration[],
  method: string,
  path: string,
): PluginApiRouteDeclaration | undefined {
  const norm = (p: string) => p.replace(/\/+$/, "") || "/";
  return routes.find((r) => r.method === method && norm(r.path) === norm(path));
}

Prevention

When it happens

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

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

Related errors


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