Kizaki
Guides

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 needs to call your app over HTTP. That usually means a third-party system, a machine-facing API, or a webhook provider. For your own browser app, exposed functions plus @kizaki/client remain the default.

Start With The Boundary

Before adding an endpoint, ask who is calling it and why it is not an exposed function.

  • Generated client for your own app's typed browser-to-server workflows.
  • Session routes when you need HTTP semantics for signed-in users.
  • API-key routes for integrations and machine callers.
  • Public routes only for webhook-style verification flows.

Make that decision early and the rest of the route design falls into place.

A Good Integration Setup

Keep 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))
}

Routes are grouped by API surface. Read and write capabilities are separate. The route table tells you exactly 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 do three things: read HTTP input, call normal application logic or entity operations, and return an HTTP-shaped response. If a handler is full of business rules, too much of the app lives behind raw routes.

Choose Auth Mode On Purpose

Session Routes

Use session when the caller is one of your signed-in users.

routes("/app", auth: session) {
  GET /me/export -> exportMyDataRoute
}

This is useful for HTTP download endpoints, streaming responses, callback URLs, or other browser-facing HTTP shapes that do not fit an exposed function.

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 });
}

This pattern is appropriate for webhooks, not 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 { client } = getContext();

  return client.apiKeys.create({
    scope: "read",
    name,
    metadata: { createdFrom: "settings" },
  });
}

The returned token is shown once. Your UI should treat it like any other secret and never expect to retrieve 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: none for convenience instead of actual webhook verification.
  • Putting authorization logic only in handlers instead of the 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 owns the structural access model and your server code owns the workflow logic. When those responsibilities stay separate, routes stay easy to reason about.

On this page