Kirak HTTP API Reference
The complete wire contract for a deployed kirak-core app. This is the single source of
truth for the client SDK (kirak on npm), the planned kirak gen types command and the FlutterFlow REST
library.
Applies to: kirak-core after the unified response contract, SP-0a contract
polish (error-code normalization, frozen delete/restore shapes,
cors.allow_origin_regex, GraphQL model-not-found -> 404), and SP-0b (branch
kirak-fixes-bf-sdk): /auth/* and /storage/* converged onto the canonical
envelope; /storage/upload/* now require a Bearer token.
1. Base URL, prefixes, transport
Section titled “1. Base URL, prefixes, transport”A deployed app is a single FastAPI service. Route groups:
| Group | Default mount | Configurable |
|---|---|---|
| CRUD + GraphQL | / (e.g. /posts/fetch, /graphql) |
kirak_prefix (e.g. /api -> /api/posts/fetch) |
| Auth | /auth/* |
auth_prefix replaces /auth (e.g. /api/auth -> /api/auth/login). The Kirak client SDK calls the default /auth/* routes |
| Storage | /storage/* |
mounted at the storage module slug; only present when the storage module is enabled |
- All request and response bodies are JSON (
Content-Type: application/json) except storage uploads, which aremultipart/form-data. - The SDK must treat the base URL +
kirak_prefixas one string it prepends to every CRUD path, and/auth//storageas separate (they are not underkirak_prefix). - No trailing slash on any path.
2. Authentication
Section titled “2. Authentication”Three mechanisms. Priority when more than one is present: Bearer JWT > X-API-Key.
2.1 User JWT (the browser/mobile path)
Section titled “2.1 User JWT (the browser/mobile path)”- Obtained from
POST /auth/login(or register+verify, OTP, social). - Sent as
Authorization: Bearer <accessToken>. - Access token TTL:
access_token_expire_minutes, elseaccess_token_expire_hours, in kirak.json’sauthsection (default 1h). Claims includesub(user id),role,email,type: "access",jti,iat,exp; the caller’s identity on each request comes from these claims, not from the database. - Refresh token TTL: default 14 days. Rotates on every use -
POST /auth/refresh-tokenreturns a new access and refresh token and invalidates the old refresh token. The SDK MUST persist the newrefreshTokenfrom every refresh response. - Logout blacklists the access token’s
jti(Redis whenKIRAK_REDIS_URLis set, else DB).
2.2 Cookie mode (optional)
Section titled “2.2 Cookie mode (optional)”When the deployed app sets cookie_name (kirak.json’s auth section), login, refresh-token,
social callbacks and OTP verification also set an HttpOnly; Secure; SameSite=Lax cookie named
that value, holding the access token. Subsequent requests authenticated by that cookie need no
Authorization header; Bearer and X-API-Key take priority when present.
- The SDK’s
auth.flow: 'cookie'skips theAuthorizationheader and sendscredentials: 'include'. Requires the deployed app’s CORS to echo the exact browser origin (wildcard*will not work with credentialed requests).kirak.jsonacceptscors.origins(exact list) andcors.allow_origin_regex(for preview / branch subdomains likepr-123.myapp.kirak.dev). SameSite=Laxcovers same-site subdomain deployments (frontend onapp.example.com, API onapi.example.com). Cross-site deployments use theAuthorization: Bearerheader.- Refresh is still an explicit
POST /auth/refresh-tokencall.
2.3 API keys (server-to-server only - never in a browser/mobile bundle)
Section titled “2.3 API keys (server-to-server only - never in a browser/mobile bundle)”- Raw format:
kk_+ 64 hex chars. Sent asX-API-Key: kk_.... - Resolves to the user who created it. Default expiry 365 days (
expires_at: nullis not supported). Managed viaPOST/GET/DELETE /auth/api-keys/*. - Use for SSR/BFF, cron, MCP clients (as
X-API-KeyorAuthorization: Bearer), FlutterFlow server-side calls. The SDK exposes it as aserverKeyoption, mutually exclusive with the auth session.
2.4 Anonymous / public access
Section titled “2.4 Anonymous / public access”- No credential is required for a model whose
accessrule for that operation includes{"role": "*"}. This is the anon pattern - the SDK makes an unauthenticated request for public data and only attaches a session token for the rest. - Rate limiting still applies, keyed by IP when there is no principal.
- There is no browser-safe “publishable key” in v1.
publishableKey/kp_is a config seam reserved for later; passing it today is a no-op.
3. The response envelope
Section titled “3. The response envelope”3.1 Success
Section titled “3.1 Success”{ "statusCode": 200, "status": "success", "message": "Fetched 20 records", "data": <payload>, "pagination": { "limit": 20, "page": 1, "offset": 0, "total": 156, "total_pages": 8, "has_more": true }}- HTTP status always equals
statusCode.statusCodeis camelCase;statusis the string"success"or"error". - Success HTTP status is always 200 (create is 200, not 201; delete is 200, not 204).
paginationis present only when the request passedpageoroffset. When absent,datais still the full result array for that page (defaultlimit10 - see 4.1).- No
tokenkey. No other top-level keys.
3.2 Error
Section titled “3.2 Error”{ "statusCode": 403, "status": "error", "error": "PERMISSION_DENIED", "message": "Role 'user' is not authorized to destroy 'posts'", "data": null}erroris a stable machine code (below).messageis human-readable and may change.detailsis present only when the failure carries structured context (e.g.{"field": "email"}on a validation error,{"failed_records": [...]}on a bulk).- The SDK maps
{ error, statusCode, message, details }to itsKirakError.
3.3 Machine error codes
Section titled “3.3 Machine error codes”error |
Status | From | Meaning |
|---|---|---|---|
VALIDATION_ERROR |
400 | core | The request, a field value or a GraphQL query is invalid, including a duplicate of a unique field. |
AUTHENTICATION_ERROR |
401 | core | A credential is required or was rejected. |
PERMISSION_DENIED |
403 | core | The caller’s role may not do this, or not on these records or fields. |
NOT_FOUND |
404 | core | No such route, model or record, or a valid token’s user no longer exists. |
RATE_LIMIT_EXCEEDED |
429 | core | Too many requests; retry after the Retry-After header’s seconds. |
DATABASE_ERROR |
500 | core | The database operation failed; the details are logged, not returned. |
INTERNAL_ERROR |
500 | core | An unexpected server error. |
HTTP_ERROR |
varies | core | Any other HTTP error; see statusCode. |
MISSING_TOKEN |
401 | auth | The route needs a credential and none was sent. |
INVALID_TOKEN |
401 | auth | The access or refresh token is malformed or not valid. |
TOKEN_EXPIRED |
401 | auth | The token has expired: refresh an access token, sign in again for a refresh token. |
TOKEN_REVOKED |
401 | auth | The token was revoked (logout, password change); sign in again. |
AUTH_UNAVAILABLE |
503 | auth | The token blacklist could not be read or written, so the token was not accepted (or the logout not completed); retry. |
INVALID_API_KEY |
401 | auth | The API key is unknown, revoked or expired. |
INVALID_CREDENTIALS |
401 | auth | Wrong email or password, or a wrong current password on change-password. |
INVALID_OTP |
401 | auth | The one-time code is wrong or expired. |
INVALID_MFA_CODE |
401 | auth | The MFA or backup code is wrong. |
SOCIAL_AUTH_FAILED |
401 | auth | The social provider sign-in did not complete. |
MOBILE_SIGN_IN_NOT_SUPPORTED |
400 | auth | Mobile sign-in accepts only apple-id, facebook and google-oauth2, whose tokens Kirak can check were issued to this app. |
SOCIAL_EMAIL_NOT_VERIFIED |
409 | auth | An account has the email of this social sign-in, and the provider did not confirm the email is verified, so it is not linked. |
MFA_REQUIRED |
403 | auth | The account has MFA: repeat the login with mfa_code. |
ACCOUNT_INACTIVE |
403 | auth | The account is deactivated. |
EMAIL_NOT_VERIFIED |
403 | auth | The email address must be verified before signing in. |
WEAK_PASSWORD |
400 | auth | The new password does not meet the password rules; message says which. |
INVALID_RESET_TOKEN |
400 | auth | The password reset link is invalid or already used. |
EMAIL_ALREADY_EXISTS |
409 | auth | An account with this email exists. |
USER_DATA_ERROR |
500 | auth | The signed-in user’s record could not be read. |
STORAGE_ERROR |
500 | storage | The storage provider failed to delete, sign a URL for or list files. |
SP-0a normalized the pure-input failures that used to return 403 to VALIDATION_ERROR/400: |
|||
No data provided for creation/update, ... requires query filters for safety (delete / |
|||
destroy / exists), No valid filter conditions provided, `No valid WHERE conditions |
|||
provided. A non-bulk updatewith nowhereis now400` (was a silent proceed). |
Modules not in the table (payments, notifications, ai, …) answer codes of their own, not
catalogued yet; statusCode gives their class.
3.4 Rate limiting
Section titled “3.4 Rate limiting”429with the canonical error envelope,error: "RATE_LIMIT_EXCEEDED", plus aRetry-After: <seconds>response header.- Enabled per model via
rate_limitin the model config, and on sensitive auth routes (login, register, otp, mfa, reset) unconditionally. - The SDK should surface
Retry-Afteron theKirakErrorand not auto-retry a 429.
3.5 GraphQL
Section titled “3.5 GraphQL”/graphql returns the same canonical envelope, not the spec {data, errors[]} shape.
Any operation error fails the whole request (no partial data). An unknown model is
NOT_FOUND/404 (matching REST); an unknown mutation verb and a syntactically malformed
query are VALIDATION_ERROR/400. No subscriptions. Introspection (__schema, __type) is
always rejected with a permission error.
4. CRUD
Section titled “4. CRUD”Per model. {model} is the model’s slug (defaults to the model key). A model with
"internal": true returns 404 for every route.
| Op | Method | Path | Body / params |
|---|---|---|---|
| fetch | GET | /{model}/fetch |
query-string filters + limit page offset order_by order select_fields |
| search | GET | /{model}/search |
search_term + filters + pagination |
| count | GET | /{model}/count |
filters -> data: { count: <int> } |
| exists | GET | /{model}/exists |
filters -> data: { exists: <bool> } |
| create | POST | /{model}/create |
{ "data": {...} } or { "data": [ ... ] } (bulk) |
| update | PUT | /{model}/update |
{ "where": {...}, "data": {...} } |
| upsert | PUT | /{model}/upsert |
{ "data": {...} } (conflict resolved on unique fields) |
| delete | DELETE | /{model}/delete |
canonical: { "query": {...} } (filter) or { "ids": [...] } (id list) in the JSON body - soft delete |
| destroy | DELETE | /{model}/destroy |
same two body shapes - hard delete |
| restore | PATCH | /{model}/restore |
canonical: { "data": {...}, "where": {...} } (both required) - only on soft_delete: true models |
The URL query-string form (DELETE /{model}/delete?status__eq=old) and ?id= are still
accepted for convenience / curl, but are not part of the typed contract - the SDK only
sends the JSON body shapes above with Content-Type: application/json. (DELETE with a body
is legal per RFC 9110; a few proxies/CDNs strip it - not observed on the Studio Traefik
path.)
4.1 Filters (fetch / search / count / exists / delete-query)
Section titled “4.1 Filters (fetch / search / count / exists / delete-query)”Query-string keys are field or field__operator. All conditions are AND-combined; no
OR at the REST layer (use GraphQL or a hook for OR).
| Suffix | SQL | Value |
|---|---|---|
| (none) | = |
scalar |
__ne |
!= |
scalar |
__gt __gte __lt __lte |
> >= < <= |
scalar |
__like |
LIKE |
string; auto-wrapped %value% if it has no % |
__ilike |
ILIKE (case-insensitive) |
string; same auto-wrap |
__in |
IN (...) |
JSON array or comma-separated string (a,b,c) |
__not_in |
NOT IN (...) |
array or CSV string |
__between |
BETWEEN x AND y |
2-element array (exactly two) |
__is_null |
IS NULL |
any (value ignored) |
__is_not_null |
IS NOT NULL |
any |
A value on a date or datetime field (created_at, updated_at, deleted_at, and timestamp
/ datetime / date fields) is an ISO-8601 string: 2026-09-01, 2026-09-01T12:00:00, or
with Z / an offset (converted to UTC). A date field takes a date only. Any other string is a
400 VALIDATION_ERROR. The same applies to GraphQL where arguments and to create / update
bodies.
The SDK’s query builder maps 1:1: .eq .neq .gt .gte .lt .lte .like .ilike .in .notIn .between .isNull .isNotNull. Unknown operators are a client-side error.
4.2 Pagination
Section titled “4.2 Pagination”limitdefault 10 (silent). Max per model viamax_limit(default 1000).- Pass
page(1-based) oroffsetto get thepaginationobject.pagewins if both. pagination:{ limit, page, offset, total, total_pages, has_more }.- The SDK exposes
.page(n, size)/.limit(n)/.offset(n)and returnspaginationseparately fromdata(undefinedwhen the caller did not page).
4.3 select_fields
Section titled “4.3 select_fields”select_fields=a,b,c (CSV) or a JSON array. A field the caller’s role cannot read
-> 403 PERMISSION_DENIED (the request fails; it is not silently filtered). The SDK’s
.select() should document this.
4.4 Ordering
Section titled “4.4 Ordering”order_by=<field>, order=ASC|DESC (default ASC). Runtime columns (id,
created_at, updated_at, deleted_at) are orderable.
4.5 create - single vs bulk
Section titled “4.5 create - single vs bulk”- Single:
{ "data": { ... } }->data: { id: <new id>, ... }. - Bulk:
{ "data": [ {...}, {...} ] }->data: { inserted_count, inserted_ids, failed_count, failed_records, total_attempted }. Bulk is a report, not a throw - partial success returns 200 with the report; every record failing returns400 VALIDATION_ERRORwithdetails.failed_records. - The SDK:
.create(row)(single) vs.createMany(rows)->BulkResult.
4.6 update / upsert / delete / destroy / restore
Section titled “4.6 update / upsert / delete / destroy / restore”- update / delete / destroy operate on a filter; the SDK sends
.where(obj)or a filter chain, never a raw id in three different shapes. whereis mandatory for a non-bulkupdate(and fordelete/destroy/restore). Omitting it is400 VALIDATION_ERROR, not a full-table write. To touch every row, pass an explicit always-true filter.- upsert:
{ "data": {...} }including theunique-field value(s) that decide insert-vs-update. Returnsdata: { id }. - soft delete sets
deleted_at;restoreclears it (soft-delete models only).
5. GraphQL
Section titled “5. GraphQL”POST /graphql with { "query": "...", "variables": { ... } }.
- Same access-control as REST (checked per model/operation).
- Standard columns (
id,created_at,updated_at,deleted_at) are selectable, filterable, orderable. - Aggregates +
groupBy/group_byare supported. - Multi-root mutations run in one DB transaction (all commit or all roll back).
- Multi-root queries either all succeed or the whole request fails.
- No subscriptions. Introspection off by default.
- The SDK’s
.graphql(query, vars)is a thin pass-through returning{ data, error }.
6. Auth endpoints
Section titled “6. Auth endpoints”All under /auth. Every response uses the canonical envelope (section 3). Failures
carry the specific auth error codes - see docs/concepts/response-envelope.md “Auth machine
codes”. Request shapes and success data payloads below are the stable contract.
6.1 Session
Section titled “6.1 Session”| Route | Request | Success data |
|---|---|---|
POST /auth/login |
{ email, password, mfa_code?, device_token? } (the session’s IP and user agent are taken from the request) |
{ user: {user_id, email, first_name, last_name, phone_number, role, is_verified, created_at, last_login_at}, accessToken, refreshToken, expiresIn } |
POST /auth/register |
{ email, password, first_name?, last_name? } (the role is always "user"; a role in the body is ignored) |
{ user: {...}, verification_email_sent: bool } - no tokens when email verification is required; user then verifies + logs in |
POST /auth/refresh-token |
{ refreshToken } |
{ accessToken, refreshToken, expiresIn } - rotated; persist the new refreshToken |
POST /auth/logout |
{ token, logout_type? } (Bearer) - token is the refresh token (logout_type: single|all|others) |
{ deleted_tokens, logout_type } - blacklists the Bearer access token’s jti |
GET /auth/me |
(Bearer) | { user_id, email, role, first_name, last_name, is_verified, is_active, created_at, last_login_at } - guest (no/absent credential) -> 401; an invalid credential -> 401 |
POST /auth/get-current-user |
{} (Bearer) |
same as /auth/me |
6.2 Password + email verification
Section titled “6.2 Password + email verification”| Route | Request |
|---|---|
POST /auth/change-password |
{ current_password, new_password } (Bearer) |
POST /auth/request-reset-password |
{ email } |
POST /auth/reset-password |
{ token, new_password } |
GET /auth/forgot-password |
HTML page (not for the SDK) |
GET /auth/verify-email?token=... |
returns an HTML page, not the envelope - a browser lands here from the email link, not the SDK |
POST /auth/generate-verification-link |
{ user_id, email, type } (admin/system role) -> data: { secure_link, type } |
6.3 OTP (phone)
Section titled “6.3 OTP (phone)”| Route | Request | Note |
|---|---|---|
POST /auth/request-otp |
{ phone_number } |
sends the code; data: {} |
POST /auth/verify-otp |
{ phone_number, otp } |
data: { user: {user_id, first_name, last_name, email, phone_number, role, is_active, created_at, last_login_at}, accessToken, refreshToken, expiresIn } |
6.4 MFA (TOTP)
Section titled “6.4 MFA (TOTP)”| Route | Request | Success data |
|---|---|---|
POST /auth/setup-mfa |
{} (Bearer) |
{ provisioning_uri, backup_codes } (backup codes shown once) |
POST /auth/verify-mfa |
{ code, action? } (Bearer) |
{ enabled: true } on action:"activate", else { backup_code_used, backup_codes_remaining } |
POST /auth/disable-mfa |
{ code } (Bearer) |
{ enabled: false } |
(login then requires mfa_code in the body for MFA-enabled users.)
6.5 Social (OAuth) - redirect-based
Section titled “6.5 Social (OAuth) - redirect-based”Designed for redirect flows (mobile + FlutterFlow). For an SPA the SDK helper is thin:
| Route | Purpose |
|---|---|
GET /auth/{provider}/login?platform=web&flow=login |
-> 302 to the provider’s authorization_url |
GET /auth/{provider}/callback |
provider redirects here; kirak exchanges the code |
POST /auth/apple/callback |
Apple’s form-post callback |
POST /auth/{provider}/mobile |
native SDK token exchange: { id_token / access_token, ... } |
POST /auth/verify-web-auth |
exchange the secure web-callback token for the session |
Providers: google, github, apple, instagram, tiktok (enabled per app).
The SDK’s signInWithProvider(provider) returns the redirect URL; the app handles the
round trip and calls verify-web-auth (or reads the callback token).
6.6 API keys
Section titled “6.6 API keys”| Route | Request | Note |
|---|---|---|
POST /auth/api-keys/create |
{ name, user_id?, expires_at? } (Bearer) |
HTTP 200; returns the raw key once in data.api_key (+ id, key_prefix, name, expires_at, created_at) |
GET /auth/api-keys/list |
(Bearer) | data: [{ id, name, key_prefix, expires_at, last_used_at, created_at }] - never the hash |
DELETE /auth/api-keys/revoke |
{ id } (Bearer) |
7. Storage endpoints
Section titled “7. Storage endpoints”Only present when the storage module is enabled. Every response uses the canonical
envelope. All five routes require a Bearer token (SP-0b closed the
anonymous-upload hole).
| Route | Method | Body | Auth |
|---|---|---|---|
/storage/upload/image |
POST multipart | file (binary), path (string), thumbnails ("true" or a JSON array of {name,width,height}) |
Bearer |
/storage/upload/file |
POST multipart | file, path |
Bearer |
/storage/delete?path=&with_thumbnails= |
DELETE | - | Bearer |
/storage/url?path=&expires= |
GET | - (expires = presigned TTL, cloud instances only) |
Bearer |
/storage/list?prefix= |
GET | - | Bearer |
Success data shapes: upload/image -> { original, thumbnails?, processing? };
upload/file -> { url, filename, size, mime_type }; delete -> { path };
url -> { url }; list -> { files: [{ path, size, url }] }. Provider failures
return STORAGE_ERROR (500). The SDK’s .storage.* are thin multipart wrappers and
give a clear “storage module not enabled” error on 404.
8. models.json v2 - the schema the planned kirak gen types reads
Section titled “8. models.json v2 - the schema the planned kirak gen types reads”{ "posts": { "table": "posts", // physical table (also accepts "table_name") "slug": "articles", // optional URL segment override; defaults to the key "soft_delete": true, // adds deleted_at; enables delete/restore "internal": false, // true -> 404 on all HTTP routes "max_limit": 1000, "rate_limit": { "max_requests": 100, "window_seconds": 60, "per": "ip" }, "schema": { // also accepts "fields" "title": { "type": "string", "required": true, "searchable": true }, "status": { "type": "string", "default": "draft", "enum": ["draft","published"] }, "views": { "type": "integer", "required": false }, "secret": { "type": "password" } // never returned; omit from generated Row type }, "access": { "fetch": [ { "role": "*" } ], // public read "create": [ { "role": "user" } ], "update": [ { "role": "admin" }, { "role": "user", "condition": "author_id = {user_id}" } ], "delete": [ { "role": "admin" } ], "fields": { // field-level read/write gating "secret": { "read": [], "write": ["system"] } } } }}Field types: string, text, integer, float, boolean, datetime, date,
email, password, json, uuid, plus relationship types. Runtime columns always
present on a Row: id (int or uuid per id_type), created_at, updated_at, and
deleted_at when soft_delete.
Planned kirak gen types output per model: Row (schema fields + runtime columns, minus
password/secret), Insert (required-vs-optional per required/default), Update (all
partial), enum unions from enum. Aggregated into a Database type the client is generic
over.
9. Conventions the SDK must respect
Section titled “9. Conventions the SDK must respect”- One canonical request shape per operation. The SDK never exposes “three ways to delete.”
- Expected API errors are returned, not thrown -
{ data: null, error: KirakError }. A thrown error means a bug or a network failure. - Timestamps are ISO 8601 strings in responses.
idis an integer by default, a UUID string when the model setsid_type: "uuid".- Soft-deleted rows are invisible to
fetch/search; a user-supplieddeleted_atfilter is stripped. - Bulk create never partially “succeeds silently” - the report always tells you which rows failed.