Skip to main content
Version: Latest
UPDATED 2026-09-07 Create screens (post / activity / achievement) now pick from the 9 categories, not the ~1130 fine-grained topics: same field and shape, you still submit topicSlugs (max 2), the values are just category slugs now ("sports-fitness"). GET /api/users/topics and ?q= return those 9; onboarding is unchanged and still picks fine-grained topics via ?representative=1. Stop calling POST /api/users/topics/suggest-categories — its keyword matching returned unrelated topics, so it's out of the flow until semantic matching lands. When fine-grained topics come back to the create screens, only the option list changes, not the request or the response.
> For mobile development. How to wire **topic pickers** everywhere they appear:> the **onboarding interests** step, and the **keyword-matched + fallback topic> suggestions** on any create screen (post / activity / achievement). Plus> **search** for when the topic isn't in the suggestions.> For mobile development. How to wire **interest pickers** everywhere they> appear: the **onboarding interests** step (fine-grained topics) and the> **create screens** — post / activity / achievement (the 9 categories). Plus> **search** for when the user can't find what they want.
## What's new - **2026-09-07** — Create screens (post / activity / achievement) now pick from the **9 categories**, not the ~1130 fine-grained topics: same field and shape, you still submit `topicSlugs` (max 2), the values are just category slugs now (`"sports-fitness"`). `GET /api/users/topics` and `?q=` return those 9; **onboarding is unchanged** and still picks fine-grained topics via `?representative=1`. Stop calling `POST /api/users/topics/suggest-categories` — its keyword matching returned unrelated topics, so it's out of the flow until semantic matching lands. When fine-grained topics come back to the create screens, only the option list changes, not the request or the response. ---
```GET /api/users/topics → create-screen picker list (the 9 categories)GET /api/users/topics?q=<term> → search that same list (≤50 results)GET /api/users/topics?representative=1 → onboarding interest list (~86 curated topics)GET /api/users/topics?q=<term> → search topics by name (≤50 results)GET /api/users/topics → full taxonomy (~1130) — rarely needed; prefer searchPOST /api/users/topics/suggest-categories → { title, description? } → matched topics (from keywords) + all representatives``` Create screens can skip the first call entirely — `GET …/form-schema` alreadyembeds the same 9 options inline. Then the chosen topic **slugs** are submitted on the create call — **same field,
The user picks **up to 2 topics** on every create screen. Activities/achievementsare optional (0 allowed); a post needs at least 1.The user picks **up to 2** on every create screen. Activities/achievements areoptional (0 allowed); a post needs at least 1. Today those slugs are the 9**categories**; the field and the limit are what's stable, not the list.
| --- | --- | --- || **Topic** | Fine-grained interest (~1130). Submitted by `slug`. | `{ slug: "hip-hop", name: "hip hop", category: "Arts & Creativity", icon: null }` || **Category** | Coarse group (9). The `category` string on a topic. | `"Arts & Creativity"` || **Representative** | Small curated per-category default set (~86 total). The fallback shown when there's no keyword signal. | `"football"`, `"mathematics"`, `"gaming"` || **Matched** | Topics the server inferred from the title/description via keyword matching. | title *"math olympiad"* → `mathematics`, … | **One pick model everywhere:** the user selects **up to 2 topics** by `slug` andsubmits them as **`topicSlugs`** — on posts, activities, and achievements alike.The server writes the picked topics as join rows, bumps their interest weights,and **derives their categories** itself (you never send categories). Posts alsoauto-tag from the post's content on top of the picks. | Surface | `topicSlugs` count || --- | --- || Activity / Achievement | 0–2 (optional) || Post | 1–2 (at least one) || **Category** | One of the 9 coarse interests. Has its own `slug` — **this is what create screens pick today.** | `{ slug: "arts-creativity", name: "Arts & Creativity", category: "Arts & Creativity" }` || **Topic** | Fine-grained interest (~1130). Onboarding picks these. | `{ slug: "hip-hop", name: "hip hop", category: "Arts & Creativity", icon: null }` || **Representative** | Small curated per-category subset (~86 total) — the onboarding list. | `"football"`, `"mathematics"`, `"gaming"` | Both are `Topic` rows on the wire, so they render and submit identically — acategory is just the one whose `name` equals its own `category`. **One pick model everywhere:** the user selects **up to 2** by `slug` and submitsthem as **`topicSlugs`** — posts, activities and achievements alike. The serverwrites the picks as join rows, bumps interest weights, and **derives the categorylabels** itself (you never send labels). | Surface | picks from | `topicSlugs` count || --- | --- | --- || Activity / Achievement | the 9 categories | 0–2 (optional) || Post | the 9 categories | 1–2 (at least one) || Onboarding interests | ~86 representative topics (`interestSlugs`) | ≥3 |
At `/complete`, these become the user's `UserTopic` rows (`source="onboarding"`).Need something not in the ~86? Use **search** (section D).The server *also* records each pick's **category** as a second `UserTopic` row
… change truncated — download the .md for the full update

Mobile Topics & Interests — Integration Guide

For mobile development. How to wire interest pickers everywhere they appear: the onboarding interests step (fine-grained topics) and the create screens — post / activity / achievement (the 9 categories). Plus search for when the user can't find what they want.

  • API base URL (local): http://localhost:3001
  • Auth: Authorization: Bearer <token> — an accessToken (logged-in user) or an onboardingToken (during onboarding). Both work on the topic endpoints.
  • Send X-Client-Type: mobile on every request.

Architecture / server internals: ../../topic-category-matching.md.

What's new

  • 2026-09-07 — Create screens (post / activity / achievement) now pick from the 9 categories, not the ~1130 fine-grained topics: same field and shape, you still submit topicSlugs (max 2), the values are just category slugs now ("sports-fitness"). GET /api/users/topics and ?q= return those 9; onboarding is unchanged and still picks fine-grained topics via ?representative=1. Stop calling POST /api/users/topics/suggest-categories — its keyword matching returned unrelated topics, so it's out of the flow until semantic matching lands. When fine-grained topics come back to the create screens, only the option list changes, not the request or the response.

Short version — the calls

GET /api/users/topics → create-screen picker list (the 9 categories)
GET /api/users/topics?q=<term> → search that same list (≤50 results)
GET /api/users/topics?representative=1 → onboarding interest list (~86 curated topics)

Create screens can skip the first call entirely — GET …/form-schema already embeds the same 9 options inline.

Then the chosen topic slugs are submitted on the create call — same field, same shape everywhere:

POST /api/users/me/activities body.topicSlugs: string[] (0–2 topic slugs)
POST /api/users/me/achievements body.topicSlugs: string[] (0–2 topic slugs)
POST /api/posts body.topicSlugs: string[] (1–2 topic slugs)

The user picks up to 2 on every create screen. Activities/achievements are optional (0 allowed); a post needs at least 1. Today those slugs are the 9 categories; the field and the limit are what's stable, not the list.


Concepts (1 minute)

ThingWhat it isExample
CategoryOne of the 9 coarse interests. Has its own slugthis is what create screens pick today.{ slug: "arts-creativity", name: "Arts & Creativity", category: "Arts & Creativity" }
TopicFine-grained interest (~1130). Onboarding picks these.{ slug: "hip-hop", name: "hip hop", category: "Arts & Creativity", icon: null }
RepresentativeSmall curated per-category subset (~86 total) — the onboarding list."football", "mathematics", "gaming"

Both are Topic rows on the wire, so they render and submit identically — a category is just the one whose name equals its own category.

One pick model everywhere: the user selects up to 2 by slug and submits them as topicSlugs — posts, activities and achievements alike. The server writes the picks as join rows, bumps interest weights, and derives the category labels itself (you never send labels).

Surfacepicks fromtopicSlugs count
Activity / Achievementthe 9 categories0–2 (optional)
Postthe 9 categories1–2 (at least one)
Onboarding interests~86 representative topics (interestSlugs)≥3

A. Onboarding — interest selection

No title exists yet, so no keyword matching — just show the curated representative set and let the user multi-select.

Request

GET /api/users/topics?representative=1
Authorization: Bearer <onboardingToken or accessToken>
X-Client-Type: mobile

Response (~86 topics, all categories):

{
"success": true,
"data": [
{ "id": "…", "slug": "mathematics", "name": "mathematics",
"category": "Academics & Learning", "description": null, "icon": null, "order": 1 },
{ "id": "…", "slug": "football", "name": "football",
"category": "Sports & Fitness", "description": null, "icon": null, "order": 0 }
]
}

Render: group by category (9 groups), show each group's topics as selectable chips. Submit: collect the chosen slugs and PATCH them onto the onboarding session as data.interestSlugs (see the onboarding guide — arrays are replaced in full):

PATCH /api/onboarding/session
{ "data": { "interestSlugs": ["mathematics", "football", "web-development"] } }

At /complete, these become the user's UserTopic rows (source="onboarding"). The server also records each pick's category as a second UserTopic row (source="onboarding:category") so those interests still match content tagged at category level — nothing for you to send, and it isn't in the response.

Need something not in the ~86? See the note in section C — ?q= searches the create-screen list, not the full taxonomy.

Cached 1h — safe to fetch once and reuse. Use ETag / If-None-Match.


B. Create screens — the 9 categories

No suggestion call. GET …/form-schema (activity / achievement) already embeds the full option list inline; for posts, fetch it once from GET /api/users/topics and cache it (1h Cache-Control).

Response — 9 rows, same topic shape as everything else:

{
"success": true,
"data": [
{ "id": "…", "slug": "academics-learning", "name": "Academics & Learning",
"category": "Academics & Learning", "description": null, "icon": null, "order": 0 },
{ "id": "…", "slug": "sports-fitness", "name": "Sports & Fitness",
"category": "Sports & Fitness", "description": null, "icon": null, "order": 7 }
]
}

Render: chips, name as the label. Submit: the chosen slugs as topicSlugs (≤2).

POST /api/posts { "topicSlugs": ["sports-fitness"] } // 1–2
POST /api/users/me/activities { "topicSlugs": ["technology-innovation"] } // 0–2
POST /api/users/me/achievements { "topicSlugs": [] } // 0–2

What the server does with them

  • Writes them as the entry's / post's topics[] (source="USER_SELECTED").
  • Derives the category labels into topicCategories[] on activities and achievements — response-only.
  • Bumps the user's interest weights for what they picked.
  • Sends an unknown slug to the floor: dropped silently, not a 400. A stale cached picker degrades instead of blocking the create.

If the user picks nothing

Allowed on activities and achievements. If such an entry is also shared to the feed (shareToFeed: true), the server may auto-tag the shared post with at most 2 categories inferred from its text. The entry itself stays untagged, and no fine-grained topic is ever auto-attached.

Why suggestions are gone

POST /api/users/topics/suggest-categories still responds, but it is out of the flow — don't build against it. It matched titles character-by-character against ~1130 topic names, which produced confidently wrong suggestions. A suggestion step returns with semantic matching; the request contract (topicSlugs, max 2) won't change when it does.


C. Search — "I can't find my topic"

?q= searches the same list the route already returns — the 9 categories on create screens. With a list that short a search box is optional; it exists so the picker keeps working unchanged when fine-grained topics come back.

Request

GET /api/users/topics?q=sport
Authorization: Bearer <token>
X-Client-Type: mobile

Response — same topic shape, capped at 50:

{ "success": true, "data": [
{ "id": "…", "slug": "sports-fitness", "name": "Sports & Fitness",
"category": "Sports & Fitness", "description": null, "icon": null, "order": 7 }
]}
  • Case-insensitive substring match on name/slug.
  • Not cached (Cache-Control: no-store) — debounce ~300 ms.
  • Rate-limited (30/min per user/session) — the debounce keeps you well under.
  • Use the returned slug as topicSlugs on any create screen.
  • Onboarding is not covered by search. ?representative=1 is its own list; search does not reach the wider ~1130-topic taxonomy while create screens are category-level.

  1. Create screens — 9 category chips, straight from field.options / GET /api/users/topics. No title-dependent step, nothing to debounce.
  2. Onboarding?representative=1, grouped by category into 9 sections.
  3. Selection state is just a set of slugs — ≤2 on create screens, ≥3 in onboarding.

Gotchas

  • Slugs are the wire format. Submit slug / value ("sports-fitness"), never the display label ("Sports & Fitness"). Sending labels is the single most common integration bug here — they're silently dropped, and the entry saves with no interests.
  • Always topicSlugs, max 2. Posts, activities, achievements all submit the same field. topicCategories is response-only — sending it does nothing.
  • Create screens and onboarding use different lists. 9 categories vs ~86 representative topics. Don't share one cached list between them.
  • Unknown slugs are dropped, not rejected. A create with only unknown slugs succeeds with zero topics — validate against the option list client-side.
  • This list will get finer later. Build the picker off field.options / GET /api/users/topics rather than a hardcoded 9, and the switch costs you nothing.
⤓ Download .md