Routes And API Keys
Expose HTTP endpoints from your app and control non-session access with scoped API keys.
Use routes when something outside the generated Kizaki client should call your app over HTTP.
That usually means one of three cases:
- a third-party system is calling you
- you are exposing a machine-facing API
- a webhook provider needs a URL
For your own browser app, exposed functions plus @kizaki/client are still the normal default.
Start With The Boundary
The first question is not "how do I add an endpoint?" It is "who is calling this, and why is it not just an exposed function?"
Use this split:
- generated client for your own app's typed browser-to-server workflows
- session routes when you truly need HTTP semantics for signed-in users
- API-key routes for integrations and machine callers
- public routes only for webhook-style verification flows
If you make that decision early, the rest of the route design usually falls into place.
A Good Integration Setup
A healthy route setup keeps the external API surface small, explicit, and capability-based.
apiKeys {
scopes: {
read: @system("api_read") {
grant read on Project
grant read on Task
},
writer: @system("api_writer") {
grant read on Project
grant read, write on Task
},
}
}
routes("/v1", auth: apiKey(read), rateLimit: 1000/minute) {
GET /projects -> listProjectsRoute
GET /projects/:projectId -> getProjectRoute
POST /projects/:projectId/tasks -> createTaskRoute (auth: apiKey(writer))
}This is a good default shape because:
- routes are grouped by API surface, not by random implementation detail
- read and write capabilities are separate
- the route table tells you what the outside world can call
Implement Thin Handlers
Treat handlers as HTTP adapters, not as a second application architecture.
import { Project, Task } from "@kizaki/schema";
import {
RouteRequest,
badRequest,
created,
insert,
ok,
query,
select,
} from "@kizaki/sdk";
export async function listProjectsRoute(_req: RouteRequest) {
const rows = await query(
select(Project)
.fields(Project.id, Project.name)
.orderBy("createdAt", "desc"),
);
return ok(rows);
}
export async function createTaskRoute(req: RouteRequest) {
const input = req.body<{ title?: string }>();
if (!input?.title) {
return badRequest("title is required");
}
const [task] = await query(
insert(Task)
.values({
projectId: req.params.projectId,
title: input.title,
})
.returning(),
);
return created(task);
}Good handlers usually do only three things:
- read HTTP input
- call normal application logic or entity operations
- return an HTTP-shaped response
If a handler is full of business rules, you are probably putting too much of the app behind raw routes.
Choose Auth Mode On Purpose
Session Routes
Use session when the caller is still one of your signed-in users.
routes("/app", auth: session) {
GET /me/export -> exportMyDataRoute
}This is useful when you need an HTTP download endpoint, streaming response, callback URL, or some other browser-facing HTTP shape that does not fit a normal exposed function well.
API-Key Routes
Use apiKey(scope) when the caller is another system.
routes("/partner", auth: apiKey(read)) {
GET /projects -> listProjectsRoute
}This is the default for integrations, data sync, ingestion, and external automation.
Public Routes
Use none only when the provider gives you another trust mechanism.
routes("/webhooks", auth: none) {
POST /github -> githubWebhookRoute
}Then verify the request inside the handler.
import { RouteRequest, badRequest, ok } from "@kizaki/sdk";
export async function githubWebhookRoute(req: RouteRequest) {
const signature = req.headers["x-hub-signature-256"];
if (!signature) {
return badRequest("missing signature");
}
// Verify req.rawBody with the provider's signing secret here.
return ok({ received: true });
}That pattern is appropriate for webhooks. It is usually not appropriate for general APIs.
Manage Keys Deliberately
For local operations or team workflows, use the CLI.
kizaki api-keys create --scope read --name "Warehouse sync"
kizaki api-keys create --scope writer --name "CRM importer" --expires 90d
kizaki api-keys list
kizaki api-keys rotate <id> --grace-period 24h
kizaki api-keys revoke <id>For customer-facing product flows, manage keys from your own server functions.
import { getContext } from "@kizaki/sdk";
/** @expose */
export async function createIntegrationKey(name: string) {
const { sdk } = getContext();
return sdk.apiKeys.create({
scope: "read",
name,
metadata: { createdFrom: "settings" },
});
}The returned token is shown once. After that, your UI should treat it like any other secret and never expect to fetch it again.
Common Mistakes
- using routes for your whole frontend instead of exposed functions
- creating one broad API-key scope for every integration
- using
auth: nonefor convenience instead of actual webhook verification - putting authorization logic only in handlers instead of the normal data model
- making handlers own business workflows that should live in reusable server code
A Good Mental Model
Routes are your app's public HTTP shell. API keys define which machine callers exist. Your schema still owns the structural access model, and your server code still owns the workflow logic.
When those responsibilities stay separate, routes stay easy to reason about.