Projects
Projects, membership, saved views, boards, analytics, and imports.
Projects
GET /api/projects
Lists projects visible to the caller. This endpoint has no path or query parameters. It has no request body.
Response: a Project array.
POST /api/projects
Creates a project. This endpoint has no path or query parameters.
Creating a project also gives its lead a lead membership on it. Omitting lead_user_id, or setting it to yourself, is unchanged and needs no recent sign-in: an API key can still create its own projects. Naming somebody else as the lead is a lasting access grant, so it requires a browser session created within the last 15 minutes, and an API key or OAuth token gets 403 recent authentication required. For a connected tool, "yourself" is the human it acts for, which is the identity the project would resolve to in any case.
Request body fields are name (string), identifier (string), description (string, optional, defaults to an empty string), emoji (string or null, optional), and lead_user_id (integer or null, optional). When no lead is supplied and the request has an authenticated user, the creator becomes the lead.
Response: a Project object.
curl -X POST https://your-server.example/api/projects \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"name":"Lific","identifier":"LIF"}'{
"id": 1,
"name": "Lific",
"identifier": "LIF",
"description": "",
"emoji": null,
"lead_user_id": 1,
"sort_order": 0,
"created_at": "2026-07-14 12:00:00",
"updated_at": "2026-07-14 12:00:00"
}GET /api/projects/{id}
Returns a project. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: a Project object.
PUT /api/projects/{id}
Updates supplied project fields. Path parameter: id (integer project ID). It has no query parameters.
Lead-gated. Setting lead_user_id to a user also grants that user a lead membership on the project, so a request that does so requires a recent browser session on the same terms as POST /api/projects/{id}/members, and an API key or OAuth token gets 403 recent authentication required. Every other field, and clearing lead_user_id to null, is ungated.
Request body fields are name (string, optional), identifier (string, optional), description (string, optional), emoji (string, null, or omitted), and lead_user_id (integer, null, or omitted). An omitted nullable field is unchanged. A null nullable field clears its value.
Response: a Project object.
DELETE /api/projects/{id}
Deletes a project. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: the standard delete response.
PUT /api/projects/reorder
Reorders the supplied projects by list position. This endpoint has no path or query parameters.
Request body field: ids (integer array). Response: a Project array in the resulting order.
Project groups
Project groups are the caller's own sidebar grouping. Every endpoint here requires an authenticated user and operates only on that user's groups; another user's group reads as missing rather than forbidden.
GET /api/project-groups
Lists the caller's project groups. This endpoint has no path or query parameters. It has no request body.
Response: an array of objects with id (integer), user_id (integer), name (string), sort_order (integer), project_ids (integer array), created_at (string), and updated_at (string). project_ids lists only projects the caller can still see, so a project whose membership was revoked disappears from the group without being unfiled.
POST /api/project-groups
Creates a group owned by the caller. This endpoint has no path or query parameters.
Request body field: name (string). Response: a project group object with an empty project_ids. A name the caller already uses returns a conflict.
curl -X POST https://your-server.example/api/project-groups \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"name":"Day job"}'{
"id": 1,
"user_id": 1,
"name": "Day job",
"sort_order": 0,
"project_ids": [],
"created_at": "2026-07-14 12:00:00",
"updated_at": "2026-07-14 12:00:00"
}PATCH /api/project-groups/{id}
Renames a caller-owned group. Path parameter: id (integer group ID). It has no query parameters.
Request body field: name (string, optional). Response: a project group object. A group owned by another user returns 404.
DELETE /api/project-groups/{id}
Deletes a caller-owned group. Path parameter: id (integer group ID). It has no query parameters or request body. The projects themselves are untouched.
Response: the standard delete response. A group owned by another user returns 404 and is left intact.
PUT /api/project-groups/assign
Files a project into one of the caller's groups, or takes it out of all of them. This endpoint has no path or query parameters.
Request body fields are project_id (integer) and group_id (integer or null). A null group_id removes the project from every group the caller owns. Unlike the rest of this family, this endpoint checks project access: assigning a project the caller cannot view returns 403.
Response: { "ok": true }.
Project data
GET /api/projects/{id}/board
Returns project issues grouped into named arrays. Path parameter: id (integer project ID). Query parameter: group_by (string, optional). Omit it or use any value other than priority or module to group by status. Use priority or module for those groupings.
It has no request body. Response: an object whose keys are group names and whose values are Issue arrays.
GET /api/projects/{id}/issue-counts
Returns issue counts by status. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: an object with backlog, todo, active, done, cancelled, and total integer fields.
GET /api/projects/{id}/insights
Returns project issue and activity aggregates. Path parameter: id (integer project ID). Query parameter: weeks (integer, optional). The value defaults to 12 and is clamped to 1..=52.
It has no request body. Response: an object with weeks (integer), created_per_week (array of objects with week_start string and count integer), closed_per_week (the same shape), status_counts, priority_counts, module_counts, and top_actors.
status_counts has the same fields as issue counts. priority_counts has urgent, high, medium, low, none, and total integer fields. Each module_counts item has module_id (integer or null), name (string), and count (integer). Each top_actors item has actor_user_id (integer or null), username (string or null), display_name (string or null), is_bot (boolean), actions (integer), last_ts (string), and top_transport (string).
GET /api/projects/{id}/mention-candidates
Lists users who can be mentioned in comments for a project. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: an array of objects with user_id (integer), username (string), and display_name (string).
Delta sync
Issues, pages, and comments each carry seq, a monotonic counter shared across all three and across every project on the instance. A client bootstraps once with /index, remembers the cursor it returns, and then asks /changes what happened above it.
Rows on both endpoints are skinny: no issue description, no page content, no comment body. Fetch those from the entity's own endpoint when a detail view needs them.
Issue and page rows do carry preview, the first non-empty line of the body trimmed and truncated to 200 characters (Unicode scalar values, not bytes), or "" when there is no body. It is enough to render a list row's summary line without fetching anything.
GET /api/projects/{id}/index
Returns every live issue and page in a project, plus the cursor to resume /changes from. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: an object with cursor (integer), issues (array), and pages (array). Deleted rows are excluded, and comments are not included at all.
The cursor is read before the lists, so a write that lands during the request is delivered twice rather than skipped: it may appear in the snapshot and will also come back from the next /changes call, since its seq is above the cursor. Apply changes as upserts keyed by id and duplicates cost nothing.
GET /api/projects/{id}/changes
Returns everything in the project that changed above a cursor, oldest first. Path parameter: id (integer project ID). Query parameters are since (integer, optional, defaults to 0) and limit (integer, optional). limit defaults to 5000 and is clamped to 1..=50000. since=0 returns everything, which works but is what /index is for.
It has no request body. Response: an object with changes (array), cursor (integer), and has_more (boolean). cursor is the highest seq in changes, or the since you supplied when the page is empty, so it never moves backwards. When has_more is true, repeat the call with since set to the returned cursor.
Comments are scoped through whichever parent they hang off, so an issue comment arrives with the issue's project and a page comment with the page's.
Every change carries kind (issue, page, or comment), seq (integer), deleted (boolean), and id (integer). A change with deleted: true is a tombstone and carries nothing else: delete your copy. Live changes add the fields for their kind.
issue:identifier,title,status,priority,module_id,sort_order,start_date,target_date,created_at,updated_at,preview, andlabels(array of label names).page:identifier,title,status,folder_id,pinned,created_at,updated_at,preview, andlabels(array of label names).comment:issue_id,page_id,user_id,username,created_at,updated_at.
{
"changes": [
{
"kind": "issue",
"seq": 41,
"deleted": false,
"id": 7,
"identifier": "LIF-3",
"title": "Ship delta sync",
"status": "active",
"priority": "high",
"module_id": 2,
"sort_order": 1000.0,
"start_date": null,
"target_date": null,
"created_at": "2026-08-20 09:14:02",
"updated_at": "2026-08-27 11:02:55",
"preview": "Bootstrap once, then pull deltas by cursor.",
"labels": ["sync"]
},
{ "kind": "page", "seq": 42, "deleted": true, "id": 4 }
],
"cursor": 42,
"has_more": false
}Membership
GET /api/projects/{id}/members
Lists project members with display identity. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: an array of objects with project_id (integer), user_id (integer), role (string), created_at (string), username (string), and display_name (string).
POST /api/projects/{id}/members
Adds a project member. Path parameter: id (integer project ID). It has no query parameters.
Lead-gated, and requires a recent browser session: the Authorization header must carry a lific_sess_ token created within the last 15 minutes. API keys and OAuth tokens get 403 recent authentication required even when they do lead the project. Adding a member hands out standing access that no lockdown on the granter's own credentials takes back, so it is gated like credential minting. Both the session check and the lead check run inside the transaction that writes the row.
Request body fields are user_id (integer) and role (string, optional). role defaults to viewer. Valid roles are viewer, maintainer, and lead.
Response: an object with project_id (integer), user_id (integer), role (string), and created_at (string).
curl -X POST https://your-server.example/api/projects/1/members \
-H "Authorization: Bearer <SESSION_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"user_id":2,"role":"viewer"}'{
"project_id": 1,
"user_id": 2,
"role": "viewer",
"created_at": "2026-07-14 12:00:00"
}PATCH /api/projects/{id}/members/{user_id}
Changes a member role. Path parameters are id (integer project ID) and user_id (integer user ID). It has no query parameters.
Lead-gated. A recent browser session is required only when the new role is higher than the current one (viewer < maintainer < lead). Raising a role expands access and is gated like POST; lowering it, or setting the role it already has, is not, so an API key can still downgrade people while an incident is being contained. The current role is read inside the write transaction, so the comparison is never made against a stale value.
Request body field: role (string). Valid roles are viewer, maintainer, and lead. Response: a project member object. Demoting the only lead returns a conflict.
DELETE /api/projects/{id}/members/{user_id}
Removes a member. Path parameters are id (integer project ID) and user_id (integer user ID). It has no query parameters or request body.
Lead-gated, with no recency requirement: removal only takes access away. The lead check runs inside the write transaction.
Response: the standard delete response. Removing or demoting the only lead returns a conflict.
GET /api/projects/{id}/my-role
Returns the caller's effective project role. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: an object with role (string or null), enforced (boolean), and is_admin (boolean).
Saved views
GET /api/projects/{id}/views
Lists saved views owned by the caller for one project. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: an array of objects with id (integer), project_id (integer), user_id (integer), name (string), config (string containing JSON), is_default (boolean), created_at (string), and updated_at (string). Views are returned only for the authenticated caller.
POST /api/projects/{id}/views
Creates a saved view owned by the caller. Path parameter: id (integer project ID). It has no query parameters.
Request body fields are name (string), config (string containing valid JSON, at most 8 KiB), and is_default (boolean, optional). Response: a saved view object. The server treats config as opaque JSON; the web client currently uses the shape below and tolerates missing fields when applying older views.
{
"version": 1,
"layout": "list",
"filterStatus": "",
"filterPriority": "",
"filterLabel": "",
"filterModule": "",
"searchQuery": "",
"sortField": "updated",
"sortDir": "desc",
"groupBy": "status",
"density": "comfortable",
"laneBy": "status",
"hiddenStatuses": []
}The recognized client fields are version, layout (list or board), filters, searchQuery, sorting, groupBy, density, laneBy, and hiddenStatuses. Clients may evolve this object without a database migration; invalid JSON is rejected, while unknown or missing fields are tolerated by the web client.
PATCH /api/projects/{id}/views/{view_id}
Updates a caller-owned saved view. Path parameters are id (integer project ID) and view_id (integer saved view ID). It has no query parameters.
Request body fields are name (string, optional), config (string containing valid JSON, at most 8 KiB, optional), and is_default (boolean, optional). Response: a saved view object. The caller must own the view.
DELETE /api/projects/{id}/views/{view_id}
Deletes a caller-owned saved view. Path parameters are id (integer project ID) and view_id (integer saved view ID). It has no query parameters or request body.
Response: the standard delete response.
Activity and import
GET /api/projects/{id}/activity
Returns activity for a project, newest first. Path parameter: id (integer project ID). Query parameters are limit (integer, optional) and offset (integer, optional).
It has no request body. Response: an activity feed object with items (activity array) and has_more (boolean). Each activity item has id (integer), ts (string), actor_user_id (integer or null), actor_username (string or null), actor_display_name (string or null), actor_is_bot (boolean), transport (string), entity_type (string), entity_id (integer), entity_label (string or null), project_id (integer or null), issue_id (integer or null), page_id (integer or null), action (string), field (string or null), old_value (string or null), and new_value (string or null).
GET /api/projects/{id}/activity/actors
Returns activity totals grouped by actor. Path parameter: id (integer project ID). It has no query parameters or request body.
Response: an array of actor objects with actor_user_id (integer or null), username (string or null), display_name (string or null), is_bot (boolean), actions (integer), last_ts (string), and top_transport (string).
POST /api/projects/{id}/import/github
Imports GitHub issues into a project, or returns a preview when dry_run is true. Path parameter: id (integer project ID). It has no query parameters.
Request body fields are repo (string in owner/name form), token (string, optional), state (string, optional, defaults to all), map_open (string, optional, defaults to backlog), map_closed (string, optional, defaults to done), and dry_run (boolean, optional, defaults to false).
Response: an object with dry_run (boolean), issues_created (integer), issues_skipped_existing (integer), comments_created (integer), labels_created (integer), comments_planned (integer), labels_planned (integer), skipped_non_issues (integer), skipped_assignees (integer), and skipped_other (integer).