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.
1. Install Kirak
Section titled “1. Install Kirak”Choose the database driver that matches your database:
pip install 'kirak[postgres]' # PostgreSQLpip install 'kirak[mysql]' # MySQLVerify the install:
kirak --help2. Create a New Project
Section titled “2. Create a New Project”kirak new task-api # MySQL; add --database postgres for PostgreSQLcd task-apipip install -e . # installs the project's dependencies, driver includedYou 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 hereThe scaffold also creates a starter posts model in models/posts.json. We will replace it with a tasks model.
3. Configure Environment Variables
Section titled “3. Configure Environment Variables”cp .env.example .envSet 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-secretKIRAK_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-keyCORS origins are cors.origins in kirak.json (the scaffold allows http://localhost:3000 and http://localhost:5173).
4. Understand the Mental Model
Section titled “4. Understand the Mental Model”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 dataThese are intentionally separate concerns. Schema is about your data. Access is about your security policy.
5. Define Your Model
Section titled “5. Define Your Model”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–DELETEsetsdeleted_atrather than removing the row. Deleted tasks are invisible to all fetch/search queries automatically.enumonstatus– Kirak validates the value at runtime. Anything outside the list returns a 400 error.searchable: trueontitle– enables full-text search viaGET /tasks/search?search_term=....accessis deny-by-default – if an operation is not listed, all roles are denied. Noaccessblock at all denies every operation for every role; listguestto make an operation public.condition: "user_id = {user_id}"– row-level security. Kirak appends aWHERE 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_idauto-injection – becausecreatehasuser_id = {user_id}as the condition, Kirak automatically injectsuser_idfrom the JWT into every create request. The caller does not need to send it.
6. Run the Migration
Section titled “6. Run the Migration”kirak db initThis generates migrations/{UTC timestamp}_initial.sql and applies it immediately:
Created: migrations/20250801100000_initial.sqlSnapshot: 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.
7. Start the Server
Section titled “7. Start the Server”uvicorn main:app --reloadThe 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.
8. Call Your API
Section titled “8. Call Your API”Register and log in
Section titled “Register and log in”# Register a usercurl -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_tokencurl -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 }}Create and fetch tasks
Section titled “Create and fetch tasks”export TOKEN="eyJhbGc..."
# Create a task -- user_id is injected automatically from the tokencurl -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"
# Searchcurl "http://localhost:8000/tasks/search?q=docs" \ -H "Authorization: Bearer $TOKEN"
# Update -- only works on your own taskcurl -X PUT http://localhost:8000/tasks/update \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"where": {"id": 1}, "data": {"status": "done"}}'
# Soft deletecurl -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}}9. Add a Hook
Section titled “9. Add a Hook”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 returnWire it into main.py:
from dotenv import load_dotenvload_dotenv()
from kirak import create_kirak_appfrom 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
Noneto pass through unchanged.
10. Evolve Your Model
Section titled “10. Evolve Your Model”Add a priority field to tasks:
"priority": {"type": "integer", "default": 0}Generate and apply an incremental migration:
kirak db makemigrations add-priority-fieldkirak db migrateThe new column is now in the database. Existing rows get the default value 0.
11. What to Read Next
Section titled “11. What to Read Next”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/ |