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.
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>— anaccessToken(logged-in user) or anonboardingToken(during onboarding). Both work on the topic endpoints. - Send
X-Client-Type: mobileon 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/topicsand?q=return those 9; onboarding is unchanged and still picks fine-grained topics via?representative=1. Stop callingPOST /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)
| Thing | What it is | Example |
|---|---|---|
| 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 — 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).
| 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 |
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
slugastopicSlugson any create screen. - Onboarding is not covered by search.
?representative=1is its own list; search does not reach the wider ~1130-topic taxonomy while create screens are category-level.
Recommended picker UX (all surfaces)
- Create screens — 9 category chips, straight from
field.options/GET /api/users/topics. No title-dependent step, nothing to debounce. - Onboarding —
?representative=1, grouped bycategoryinto 9 sections. - 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.topicCategoriesis 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/topicsrather than a hardcoded 9, and the switch costs you nothing.