CRUD and browse for projects. Sub-resources (members, updates, buzz, help-wanted) have their own files; this file covers the project itself plus the directory browse.
See data-model.md for the entity shape, behaviors/project-stages.md for stage rules, and behaviors/tags.md for tag filtering.
| Method | Path | Auth | Summary |
|---|---|---|---|
GET |
/api/projects |
public | List/browse projects with filters and sort. |
GET |
/api/projects/:slug |
public | Fetch a single project. |
POST |
/api/projects |
user | Create a new project (caller becomes maintainer). |
PATCH |
/api/projects/:slug |
maintainer | staff | Update fields. |
DELETE |
/api/projects/:slug |
staff | Soft-delete a project. |
POST |
/api/projects/:slug/restore |
staff | Undo a soft-delete. |
POST |
/api/projects/:slug/change-maintainer |
maintainer | staff | Reassign the maintainer to another existing member. |
| Param | Type | Notes |
|---|---|---|
q |
string | Free-text search across title, summary, overview; the last word matches as a prefix, so it serves find-as-you-type (SQLite FTS5 in-memory index — see behaviors/storage.md). Results in relevance order. |
stage |
enum | commenting | bootstrapping | prototyping | testing | maintaining | drifting | hibernating |
stageIn |
csv-of-enum | Multi-value filter. |
tag |
string | Tag handle in <namespace>.<slug> form (e.g., tech.flutter). Repeatable; OR within namespace, AND across namespaces. ?tag=topic.transit&tag=topic.mapping&tag=tech.python returns projects that are tagged (topic.transit OR topic.mapping) AND tech.python. Matches the conventional faceted-search behavior — within a facet group users widen, across groups they narrow. |
maintainer |
string | Person slug. |
memberSlug |
string | Person slug — returns projects where this person is in project-memberships. |
helpWanted |
bool | If true, only projects with at least one helpWantedRoles.status = 'open'. |
featured |
bool | If true, only projects with featured = true. Used by the home page. |
sort |
sort spec | Default -updatedAt, except that a request with q and no sort lists in relevance order. Allowed keys: createdAt, updatedAt, title, stage (stage ordered by project-stages.md rank), activity — the project's most recent sign of life: the latest of an RSVP naming it (updatedAt), a project update (createdAt), or a buzz item logged (createdAt, not the article's publishedAt). Projects with no activity sort after every project that has some, in either direction, then by -updatedAt. Used by the home page to top up "Join a Project". |
page, perPage |
int | See conventions.md. |
Soft-deleted projects (deletedAt is not null) are excluded for non-staff and included for staff. Staff filter: ?includeDeleted=true.
{
"success": true,
"data": [ProjectListItem, ...],
"metadata": {
"page": 1, "perPage": 30, "totalItems": 268, "totalPages": 9,
"facets": {
"byTopic": [{ "tag": "topic.transit", "title": "Transit", "count": 28 }, ...],
"byTech": [...],
"byEvent": [...],
"byStage": [{ "stage": "prototyping", "count": 41 }, ...]
}
}
}The facets object replaces laddr's projectsTags.{byTopic,byTech,byEvent} and projectsStages. Top 10 per facet group; full list via api/tags.md.
Counts reflect the filtered corpus with self-namespace exclusion:
- For
byTopic,byTech,byEvent— count over the project set filtered by every active criterion except tag filters in that same namespace. So if the user has selectedtopic.transit, the topic facet still shows other topics (with counts narrowed by the rest of the filters) so they can widen their topic selection without losing track. Selecting another topic adds to the topic set (OR within namespace). - For
byStage— same pattern, count over the set filtered by everything exceptstage/stageIn.
Active selections that fall below the top 10 in their facet group are pinned into the response so the SPA can render them as still-selected. Counts on the pinned entries reflect the same self-exclusion rule.
Counts are honest about the search the user is composing — "if you click this, this is how many projects you'd see" for OR-widening within the group, and a stable reference for what's still available across the rest of the corpus.
The summary shape used in lists; smaller than the detail response.
{
"id": "<uuid>",
"slug": "squadquest",
"title": "SquadQuest",
"summary": "Realtime community events without Facebook.",
"stage": "testing",
"overviewExcerpt": "SquadQuest is a different kind of civic technology...", // <= 600 chars, plain text (markdown stripped)
"maintainer": { "slug": "chris", "fullName": "Chris Alfano", "avatarUrl": "..." } | null,
"memberCount": 5,
"members": [PersonAvatar, ...], // first 10 by role then alpha
"links": {
"usersUrl": "https://squadquest.app",
"developersUrl": "https://github.com/SquadQuest/SquadQuest",
"chatChannel": "squadquest"
},
"openHelpWantedCount": 2,
"tags": [{ "namespace": "tech", "slug": "flutter", "title": "Flutter" }, ...],
"updatedAt": "2026-04-02T14:11:00Z"
}Fetches a single project by slug.
{
"success": true,
"data": Project
}{
"id": "<uuid>",
"slug": "squadquest",
"title": "SquadQuest",
"summary": "...",
"overview": "## Markdown source ...",
"overviewHtml": "<h2>Markdown source ...", // sanitized HTML rendered server-side
"stage": "testing",
"stageProgress": 0.5, // derived; see project-stages.md
"maintainer": Person | null,
"memberships": [ProjectMembership, ...], // includes all members
"openHelpWantedRoles": [HelpWantedRole, ...],
"tags": { "topic": [Tag, ...], "tech": [Tag, ...], "event": [Tag, ...] },
"links": { "usersUrl": "...", "developersUrl": "...", "chatChannel": "..." },
"counts": { "updates": 12, "buzz": 3, "members": 5 },
"permissions": {
"canEdit": true,
"canManageMembers": false,
"canPostUpdate": true,
"canLogBuzz": true,
"canPostHelpWanted": true,
"canDelete": false
},
"createdAt": "...",
"updatedAt": "...",
"deletedAt": "..." | null
}permissions is the current caller's permissions on this project — the frontend uses it to decide which actions to render. The server still enforces the same rules on each mutation endpoint.
deletedAt is null for active projects. It is non-null only when a soft-deleted project is fetched by staff (non-staff get 404), so the SPA can render the soft-delete banner with a Restore action.
404 not_found— slug doesn't match (or is soft-deleted and caller can't see deleted).
Create a new project. Caller becomes the maintainer, is added as a Founder membership, and is recorded as the project's createdById (never changed afterwards; not part of any request or update).
{
"title": "My New Project",
"slug": "my-new-project", // optional; derived from title if omitted
"summary": "Tagline (optional)",
"overview": "Markdown source",
"usersUrl": "https://...",
"developersUrl": "https://...",
"chatChannel": "my-channel",
"stage": "commenting", // optional; default 'commenting'
"tags": { // optional
"topic": ["transit", "mapping"],
"tech": ["typescript"],
"event": []
}
}Tags reference existing tag slugs by namespace + slug. Unknown tag slugs auto-create new tags only when the caller has accountLevel ∈ {staff, administrator}; otherwise unknown tags return 422 with a hint to ask staff to add them.
{ "success": true, "data": Project }422 validation_failed409 conflict— slug already taken
Update one or more fields. Field-level. Omitted fields are unchanged. Tags, if present, fully replace the existing set within each namespace included in the request.
| Field | maintainer | staff |
|---|---|---|
| title, summary, overview, usersUrl, developersUrl, chatChannel | ✓ | ✓ |
| stage | ✓ | ✓ |
| tags.{topic,tech,event} | ✓ | ✓ |
| slug | – | ✓ |
| featured, featuredImageKey | – | ✓ |
| maintainerId | – | – (use change-maintainer) |
{ "success": true, "data": Project }403 forbidden— caller is not maintainer or staff422 validation_failed409 conflict— slug change collides
Soft-delete. Sets deletedAt = now(). Removes the project from public lists, leaves its row and sub-resources intact for restore.
403 forbidden404 not_found
Unset deletedAt.
{ "success": true, "data": Project }{ "personSlug": "another-member" }The new maintainer must already be a member (a project-memberships record exists for them). If not, respond 409 conflict with error.code = "not_a_member".
The previous maintainer remains a member with their old role (or "Maintainer (former)" if their role was previously null).
{ "success": true, "data": Project }- Slug changes create a 301 redirect from the old slug for 90 days (handled at the web layer, not the API). The API itself accepts the new slug only after the change. See behaviors/slug-handles.md.
- Overview rendering happens server-side via the markdown pipeline in behaviors/markdown-rendering.md. The
overviewHtmlfield is always derived fromoverviewon read. - Stage transitions are free-form in v1 — any role permitted to edit can set any stage. We don't enforce ordering. See behaviors/project-stages.md for the rationale and what we want eventually.