Skip to content

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.


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 are multipart/form-data.
  • The SDK must treat the base URL + kirak_prefix as one string it prepends to every CRUD path, and /auth / /storage as separate (they are not under kirak_prefix).
  • No trailing slash on any path.

Three mechanisms. Priority when more than one is present: Bearer JWT > X-API-Key.

  • Obtained from POST /auth/login (or register+verify, OTP, social).
  • Sent as Authorization: Bearer <accessToken>.
  • Access token TTL: access_token_expire_minutes, else access_token_expire_hours, in kirak.json’s auth section (default 1h). Claims include sub (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-token returns a new access and refresh token and invalidates the old refresh token. The SDK MUST persist the new refreshToken from every refresh response.
  • Logout blacklists the access token’s jti (Redis when KIRAK_REDIS_URL is set, else DB).

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 the Authorization header and sends credentials: 'include'. Requires the deployed app’s CORS to echo the exact browser origin (wildcard * will not work with credentialed requests). kirak.json accepts cors.origins (exact list) and cors.allow_origin_regex (for preview / branch subdomains like pr-123.myapp.kirak.dev).
  • SameSite=Lax covers same-site subdomain deployments (frontend on app.example.com, API on api.example.com). Cross-site deployments use the Authorization: Bearer header.
  • Refresh is still an explicit POST /auth/refresh-token call.

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 as X-API-Key: kk_....
  • Resolves to the user who created it. Default expiry 365 days (expires_at: null is not supported). Managed via POST/GET/DELETE /auth/api-keys/*.
  • Use for SSR/BFF, cron, MCP clients (as X-API-Key or Authorization: Bearer), FlutterFlow server-side calls. The SDK exposes it as a serverKey option, mutually exclusive with the auth session.
  • No credential is required for a model whose access rule 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.

{
"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. statusCode is camelCase; status is the string "success" or "error".
  • Success HTTP status is always 200 (create is 200, not 201; delete is 200, not 204).
  • pagination is present only when the request passed page or offset. When absent, data is still the full result array for that page (default limit 10 - see 4.1).
  • No token key. No other top-level keys.
{
"statusCode": 403,
"status": "error",
"error": "PERMISSION_DENIED",
"message": "Role 'user' is not authorized to destroy 'posts'",
"data": null
}
  • error is a stable machine code (below). message is human-readable and may change.
  • details is 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 its KirakError.
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.

  • 429 with the canonical error envelope, error: "RATE_LIMIT_EXCEEDED", plus a Retry-After: <seconds> response header.
  • Enabled per model via rate_limit in the model config, and on sensitive auth routes (login, register, otp, mfa, reset) unconditionally.
  • The SDK should surface Retry-After on the KirakError and not auto-retry a 429.

/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.


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.

  • limit default 10 (silent). Max per model via max_limit (default 1000).
  • Pass page (1-based) or offset to get the pagination object. page wins if both.
  • pagination: { limit, page, offset, total, total_pages, has_more }.
  • The SDK exposes .page(n, size) / .limit(n) / .offset(n) and returns pagination separately from data (undefined when the caller did not page).

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.

order_by=<field>, order=ASC|DESC (default ASC). Runtime columns (id, created_at, updated_at, deleted_at) are orderable.

  • 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 returns 400 VALIDATION_ERROR with details.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.
  • where is mandatory for a non-bulk update (and for delete / destroy / restore). Omitting it is 400 VALIDATION_ERROR, not a full-table write. To touch every row, pass an explicit always-true filter.
  • upsert: { "data": {...} } including the unique-field value(s) that decide insert-vs-update. Returns data: { id }.
  • soft delete sets deleted_at; restore clears it (soft-delete models only).

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_by are 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 }.

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.

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

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).

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)

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.


  • 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.
  • id is an integer by default, a UUID string when the model sets id_type: "uuid".
  • Soft-deleted rows are invisible to fetch/search; a user-supplied deleted_at filter is stripped.
  • Bulk create never partially “succeeds silently” - the report always tells you which rows failed.