Skip to content

Build Your First API

This tutorial walks you through building a small but complete API with Kirak. By the end you will understand the core mental model and have a working API you can extend.

The application we are building: a simple task manager with user-owned tasks and role-based access.

Prerequisites: Python 3.10+, MySQL 8+ or PostgreSQL 13+, pip.


Choose the database driver that matches your database:

Terminal window
pip install 'kirak[postgres]' # PostgreSQL
pip install 'kirak[mysql]' # MySQL

Verify the install:

Terminal window
kirak --help

Terminal window
kirak new task-api # MySQL; add --database postgres for PostgreSQL
cd task-api
pip install -e . # installs the project's dependencies, driver included

You now have:

task-api/
+-- main.py # App entry point -- create_kirak_app() lives here
+-- kirak.json # Project settings: modules, CORS, rate limiting
+-- .env.example # The secrets the project needs
+-- .gitignore
+-- pyproject.toml
+-- AGENTS.md # Instructions for AI coding agents
+-- .kirak/ # JSON Schemas, for editors and agents
+-- .vscode/
| +-- settings.json # Maps the config files to the schemas
+-- models/
| +-- posts.json # A model definition -- one model per file
+-- hooks/
| +-- __init__.py
| +-- post_hooks.py # Register business-logic hooks here
+-- migrations/ # Generated SQL migration files live here

The scaffold also creates a starter posts model in models/posts.json. We will replace it with a tasks model.


Terminal window
cp .env.example .env

Set the database in the database block of kirak.json (the scaffold has mysql on localhost; use these values for PostgreSQL):

"database": { "type": "postgres", "host": "localhost", "port": 5432, "name": "task_api", "user": "myuser" }

Open .env and set the secrets:

DB_PASSWORD=mypassword
KIRAK_AUTH_JWT_SECRET_KEY=replace-with-a-32-byte-or-longer-secret
KIRAK_AUTH_JWT_REFRESH_SECRET_KEY=another-32-byte-or-longer-secret
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
KIRAK_AUTH_VERIFICATION_KEY=your-fernet-key

CORS origins are cors.origins in kirak.json (the scaffold allows http://localhost:3000 and http://localhost:5173).


Before writing the model, understand what Kirak does for you automatically:

Runtime-managed fields. Kirak creates and maintains these on every table – never add them to your model:

Field When set
id On INSERT – auto-increment integer
created_at On INSERT
updated_at On INSERT and every UPDATE
deleted_at On soft-delete; cleared on restore

Built-in auth tables. users, auth_tokens, auth_social, auth_mfa, and related tables are part of Kirak Core. You do not define them. They are automatically included in every migration.

What you define. Your model JSON describes two things:

model
+-- schema describes the data structure and database behavior
+-- access describes who can read or modify that data

These are intentionally separate concerns. Schema is about your data. Access is about your security policy.


Each file in models/ is one model, named after the file. Delete models/posts.json and create models/tasks.json:

{
"$schema": "../.kirak/model.schema.json",
"table": "tasks",
"soft_delete": true,
"schema": {
"title": {"type": "string", "required": true, "max_length": 200, "searchable": true},
"description": {"type": "text"},
"status": {"type": "string", "required": true, "default": "pending",
"enum": ["pending", "in_progress", "done"]},
"user_id": {"type": "integer", "required": true}
},
"access": {
"fetch": [{"role": "user", "condition": "user_id = {user_id}"},
{"role": "admin"}],
"create": [{"role": "user", "condition": "user_id = {user_id}"},
{"role": "admin"}],
"update": [{"role": "user", "condition": "user_id = {user_id}"},
{"role": "admin"}],
"delete": [{"role": "user", "condition": "user_id = {user_id}"},
{"role": "admin"}],
"destroy": [{"role": "admin"}]
}
}

Check it with kirak validate models before migrating.

Key things to notice:

  • soft_delete: true – DELETE sets deleted_at rather than removing the row. Deleted tasks are invisible to all fetch/search queries automatically.
  • enum on status – Kirak validates the value at runtime. Anything outside the list returns a 400 error.
  • searchable: true on title – enables full-text search via GET /tasks/search?search_term=....
  • access is deny-by-default – if an operation is not listed, all roles are denied. No access block at all denies every operation for every role; list guest to make an operation public.
  • condition: "user_id = {user_id}" – row-level security. Kirak appends a WHERE user_id = <token_user_id> clause automatically. Users can only see and modify their own tasks. Admins have no condition so they see everything.
  • user_id auto-injection – because create has user_id = {user_id} as the condition, Kirak automatically injects user_id from the JWT into every create request. The caller does not need to send it.

Terminal window
kirak db init

This generates migrations/{UTC timestamp}_initial.sql and applies it immediately:

Created: migrations/20250801100000_initial.sql
Snapshot: migrations/models_snapshot.json
Applying migration...
Done -- 1 applied, 0 already applied.

The migration file contains both MySQL and PostgreSQL DDL in clearly marked sections; only the matching dialect is executed.

The tasks table is now in your database, alongside all the auth tables Kirak created automatically.


Terminal window
uvicorn main:app --reload

The server starts on http://localhost:8000. Open http://localhost:8000/docs for a description of every endpoint, or import http://localhost:8000/openapi.json into an API client.


Terminal window
# Register a user
curl -X POST http://localhost:8000/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "Secret!Pass123", "first_name": "Alice"}'
# Log in -- returns access_token and refresh_token
curl -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "Secret!Pass123"}'

Login response shape:

{
"statusCode": 200,
"status": "success",
"message": "Login successful",
"data": {
"user": {"user_id": 1, "email": "alice@example.com", "role": "user"},
"accessToken": "eyJhbGc...",
"refreshToken": "eyJhbGc...",
"expiresIn": 3600
}
}
Terminal window
export TOKEN="eyJhbGc..."
# Create a task -- user_id is injected automatically from the token
curl -X POST http://localhost:8000/tasks/create \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"data": {"title": "Write docs", "status": "in_progress"}}'
# Fetch tasks -- only your own tasks are returned (RLS condition)
curl "http://localhost:8000/tasks/fetch?limit=10&page=1" \
-H "Authorization: Bearer $TOKEN"
# Search
curl "http://localhost:8000/tasks/search?q=docs" \
-H "Authorization: Bearer $TOKEN"
# Update -- only works on your own task
curl -X PUT http://localhost:8000/tasks/update \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"where": {"id": 1}, "data": {"status": "done"}}'
# Soft delete
curl -X DELETE "http://localhost:8000/tasks/delete?id=1" \
-H "Authorization: Bearer $TOKEN"

Every response uses the same envelope:

{
"statusCode": 200,
"status": "success",
"message": "Records fetched successfully",
"data": [...],
"pagination": {"limit": 10, "page": 1, "total": 3, "total_pages": 1, "has_more": false}
}

Hooks run before or after any operation without touching runtime code. Open hooks/post_hooks.py and add:

def register_hooks(kirak):
@kirak.on("tasks").hook("after_create")
async def log_new_task(result):
task = result.get("data", {})
print(f"Task {task.get('id')} created: {task.get('title')}")
return result # always return

Wire it into main.py:

from dotenv import load_dotenv
load_dotenv()
from kirak import create_kirak_app
from hooks.post_hooks import register_hooks
app = create_kirak_app(
models_path="./models/",
on_kirak_ready=register_hooks,
)

Restart and create a task – you will see the log line in the terminal.

Why on_kirak_ready? This callback runs after the database connects but before routers are mounted. Hooks registered here are guaranteed to be in place for the first request. Never register hooks after startup.

Hook rules:

  • Hooks can be sync (def) or async (async def), and they fail differently. A sync hook that raises stops the operation, so use one to reject a request. An async hook that raises or times out is logged and skipped – the request continues.
  • They must return the value they receive (modified or unchanged). Return None to pass through unchanged.

Add a priority field to tasks:

"priority": {"type": "integer", "default": 0}

Generate and apply an incremental migration:

Terminal window
kirak db makemigrations add-priority-field
kirak db migrate

The new column is now in the database. Existing rows get the default value 0.


You have seen the complete Kirak workflow: model -> migration -> API -> hooks -> evolve. The same pattern scales to any number of models.

Topic Document
All field types and schema options Defining Models
Fetch filters, pagination, ordering, bulk operations CRUD Operations
RBAC, row-level security, field-level access Access Control
Full hook reference – all CRUD and auth events Hooks & Events
JWT, social login, OTP, MFA Authentication
All environment variables and kirak.json Configuration
Notifications, Payments, Storage, AI, Scheduler Notifications / Payments / Storage
Runnable code examples examples/