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 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:

  1. read HTTP input
  2. call normal application logic or entity operations
  3. 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: none for 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.

On this page