API Routes
Plugins can expose HTTP endpoints via FastAPI routers. This guide covers creating routes, authentication, and accessing shared state.
See also: Backend Hooks, Security
Creating API Routes
Return a FastAPI APIRouter from the api_router() method on your plugin class:
def api_router(self):
from fastapi import APIRouter, Request
router = APIRouter(tags=["my_plugin"])
@router.post("/action")
async def do_action(request: Request):
return {"result": "success"}
return router
Routes are mounted at /ui/plugins/{plugin_id}/. For example, a plugin with plugin_id = "my_plugin" that defines a /action route will be accessible at:
POST /ui/plugins/my_plugin/action
Authentication (Automatic)
Every route returned from api_router() is automatically protected — no
code required from plugin developers. (The one way to opt out is
public_api_router(), which exists
for callers that cannot carry a session at all and shifts authentication onto
you.)
The system injects authentication middleware that:
- Requires authentication — Every request must have a valid session
- Verifies conversation ownership — If
{conversation_id}is in the route path, the authenticated user must own that conversation
Route Pattern Behavior
| Route Pattern | Behavior |
|---|---|
/votes/{conversation_id} | Auth required + ownership verified |
/stats/all | Auth required only |
Accessing the User
The authenticated user is available via request.state, injected by the auth middleware:
@router.get("/data/{conversation_id}")
async def get_data(conversation_id: str, request: Request):
# User is available via request.state (injected by auth middleware)
user = request.state.user
print(f"Request from user: {user.id}")
# conversation_id ownership is already verified by middleware
# No need to check manually
return {"user_id": user.id, "conversation_id": conversation_id}
Security Notes
- Routes without
conversation_idrequire authentication but allow any logged-in user - For admin-only routes, add explicit role checks in your handler
- Returns 404 (not 403) for unauthorized conversation access to prevent ID enumeration
Unauthenticated Routes (public_api_router())
Some callers cannot present a session: an inbound webhook from a forge, an
OAuth provider redirecting a browser back to you. For those — and only
those — a plugin may also return a router from public_api_router():
def public_api_router(self):
if not self._enabled:
return None # None is fine; nothing is mounted
from .webhooks import build_webhook_router
return build_webhook_router(self._handler)
This router differs from api_router() in two ways that are easy to miss:
api_router() | public_api_router() | |
|---|---|---|
| Mounted at | /ui/plugins/<plugin_id>/… | the app root, with no prefix |
| Session auth | Injected for you | None. You must authenticate the request yourself. |
| Conversation ownership | Verified when {conversation_id} is in the path | Not verified |
You are writing an internet-facing endpoint. Nothing upstream will reject an
anonymous caller, so authenticate inside the handler before any side effect, and
reject rather than degrade when the check fails. The two in-tree consumers show
the shapes this is meant for:
gitea_prverifies a Gitea HMAC signature overconfig.webhooks.secretsbefore it will spawn anything (plugins/gitea_pr/webhooks.py).oauthvalidates the returning user via thestateparameter, because the redirect may land in a different browser session than the one that started the flow (plugins/oauth/plugin.py).
Because the router mounts at the app root, its paths are in the global
namespace and can collide with core routes or another plugin's. Prefix your own
paths distinctively (e.g. /webhooks/my_plugin/…) rather than claiming a bare
generic path.
If your endpoint can be reached with a session, use api_router() instead —
the protection is free and it cannot be forgotten.
Accessing Shared State
Use request.app.state for lazy-initialized shared stores:
@router.get("/data")
async def get_data(request: Request):
# Lazy-init stores via app state
store = getattr(request.app.state, "my_store", None)
if store is None:
store = MyStore()
request.app.state.my_store = store
return store.get_all()
This pattern ensures your store is created once and shared across all requests to your plugin.