API Reference
Base URL: {APP_URL}/api/v1. These are the exact endpoints the klenua desktop app itself calls to sign in, sync a project's state, and upload backups — not a general-purpose public API. Everything is optional: the app works fully signed out.
Response envelope
Success:
{ "success": true, "data": { } }
Error:
{ "success": false, "error": { "code": "INVALID_CREDENTIALS", "message": "Incorrect email or password." } }
error.code is a stable, machine-readable string — branch on it, not on the HTTP status or the message text. A validation error additionally includes error.details, a map of field → array of messages.
Authentication
POST /auth/login
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | yes | |
password | string | yes | Never stored by the app. |
stay_signed_in | boolean | no | With client_name set to klenua build, issues a refresh token that lasts 30 days instead of a normal session. |
client_name | string | no | Max 80 characters. Labels the session. |
{
"success": true,
"data": {
"user": { "id": 1, "username": "jane", "display_name": "Jane Doe", "email": "jane@example.com", "role": "user", "avatar_path": null },
"access_token": "<jwt>",
"refresh_token": "<opaque>"
}
}
Send the access token on authenticated requests:
Authorization: Bearer <access_token>
The access token is short-lived. When a request returns 401, exchange the refresh token for a new pair and retry once.
POST /auth/refresh — body { "refresh_token": "<opaque>" }. Returns the same user, access_token, refresh_token shape as login. The refresh token is rotated, so store the new one.
POST /auth/logout — body { "refresh_token": "<opaque>" }. Revokes that refresh token. Always succeeds.
Changing or resetting your password on the website signs out every session, including the desktop app.
Syncing a project
POST /build/projects — requires a bearer token.
| Field | Type | Required | Notes |
|---|---|---|---|
local_id | string | yes | A SHA-256 digest (64 lowercase hex characters) of the project's local folder path. Never the path itself. |
project_name | string | yes | Max 160 characters. |
project_type | string | no | e.g. flutter, android, ios. Max 120 characters. |
app_name | string | no | The app's display name. Max 160 characters. |
bundle_id | string | no | Bundle ID / application ID. Max 255 characters. |
version | string | no | Max 80 characters. |
build | string | no | Build number. Max 80 characters. |
platforms | string[] | no | Up to 12 entries, each up to 40 characters. |
source_of_truth | string | no | Which file the version was read from. Max 255 characters. |
This is an upsert — syncing the same local_id again updates the existing record instead of creating a duplicate. A signed-in user can see everything they've synced at /account/synced on the website.
{ "success": true, "data": { "message": "Project synced to your klenua account." } }
GET /build/projects — requires a bearer token. Returns the signed-in user's synced projects, most recently synced first. Each record has local_id, project_name, project_type, app_name, bundle_id, version, build_number, platforms, source_of_truth, and synced_at. The app uses local_id and synced_at to show when a project was last synced.
Uploading backups
POST /build/projects/backups — requires a bearer token. Sent as multipart/form-data.
| Field | Type | Required | Notes |
|---|---|---|---|
local_id | string | yes | Same digest as above. |
relative_path | string | yes | The file's path inside the backup folder, e.g. android/app/build.gradle. 1–500 characters, letters, digits, ., _, -, space and / only; no leading /, no ... |
backup | file | yes | The backup file itself. Max 1 MB. |
Accepted file types: gradle, java, json, kt, kts, pbxproj, plist, properties, toml, xml, yaml, yml. Uploading the same relative_path for the same project replaces the previous backup. Returns 201 with { "message": "Backup file uploaded." }. Uploaded backups appear next to the project at /account/synced and can be downloaded from there.
Versioning
There is no /v2 yet. A breaking change to this API would ship as a new version prefix, not a silent change to /v1.