Skip to main content
Version: Latest
UPDATED 2026-10-28 — Removed the note about auto-tagging a shared post. Activities and achievements are no longer shared to the feed (the toggle is removed), so an entry with no picks just stays untagged. Details: [activities guide §2.5](./mobile-activities-achievements-guide.md).
- **2026-10-28** — Removed the note about auto-tagging a shared post. Activities and achievements are no longer shared to the feed (the toggle is removed), so an entry with no picks just stays untagged. Details: [activities guide §2.5](./mobile-activities-achievements-guide.md).- **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.
Allowed on activities and achievements. If such an entry is also shared to thefeed (`shareToFeed: true`), the server may auto-tag **the shared post** with atmost **2** categories inferred from its text. The entry itself stays untagged,and no fine-grained topic is ever auto-attached.Allowed on activities and achievements. The entry stays untagged, and no topicis ever auto-attached to it.

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-10-28 — Removed the note about auto-tagging a shared post. Activities and achievements are no longer shared to the feed (the toggle is removed), so an entry with no picks just stays untagged. Details: activities guide §2.5.
  • 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 slug — this 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. The entry stays untagged, and no topic is ever auto-attached to it.

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