Calling the API
The apps call the backend through a typed tRPC client. Here's how a call gets from a button to the database.
The client
Each app has one tRPC client, typed by the backend's AppRouter. In the web app it's web-app/src/trpc.ts:
import { trpc } from "./trpc";
const roles = await trpc.roles.list.query();
await trpc.roles.create.mutate({ name: "Support" });
Queries use .query(), and mutations use .mutate(). Inputs and results are typed from the server, so a wrong field name fails the build.
The client batches calls made in the same tick into one request. Queries with input are sent as POST, so a large input can't overflow a URL.
Where requests go
| Path | What handles it |
|---|---|
/trpc/* | Every tRPC procedure |
/api/auth/* | Better Auth — sign-in, sign-up, sessions |
/chatbot/stream | Streaming chatbot replies |
/health | A plain health check |
In development, the web app's Vite server proxies all four to localhost:3000, so requests are same-origin and the session cookie is sent automatically.
Auth on each request
- Web app — the session cookie, sent with
credentials: "include". - Desktop app —
Authorization: Bearer <token>, since its webview can't share the browser's cookies.
The backend reads the session from the request headers and hands it to every procedure as ctx.session.
Errors
A failed call throws a TRPCClientError. Check its data.code:
| Code | Meaning |
|---|---|
UNAUTHORIZED | No session |
FORBIDDEN | Signed in, but missing the permission — or the terms aren't accepted |
BAD_REQUEST | Input failed validation, or a rule refused it |
NOT_FOUND | The thing doesn't exist, or you can't see it |
Routers
The root router in backend/src/trpc/router.ts has 13 routers: health, db, userSettings, profile, admin, authProtocols, termsAndConditions, features, featureRoles, roles, organizations, chatbot, and blog.