Skip to main content
Version: Latest
UPDATED 2026-09-07 Interests are picked as categories again, but the field is topicSlugs, not topicCategories: send the category's slug ("sports-fitness"), 0–2 of them, from the inline topics options in form-schema. This guide previously documented topicCategories with raw labels — that was wrong since 2026-07-21, when the API stopped reading that field and started silently ignoring it, so entries saved with no interests at all. Also: stop calling suggest-categories (dropped from the form schema — its keyword matching was too noisy), and interests are now optional (0 allowed), not required 1–2.
## What's new - **2026-09-07** — Interests are picked as categories again, but the field is `topicSlugs`, not `topicCategories`: send the category's **slug** (`"sports-fitness"`), 0–2 of them, from the inline `topics` options in form-schema. This guide previously documented `topicCategories` with raw labels — that was **wrong since 2026-07-21**, when the API stopped reading that field and started silently ignoring it, so entries saved with no interests at all. Also: stop calling `suggest-categories` (dropped from the form schema — its keyword matching was too noisy), and interests are now optional (0 allowed), not required 1–2. ---
POST /api/users/topics/suggest-categories → keyword-suggest categories from a titlePOST /api/media/presign → step 1 of media upload
**Interest = category, not individual topic.** The user picks **1–2 categories**(of ~8) from the `categories` multiselect. The server keyword-infers specifictopics from the title and seeds feed weights — the client never sends topicslugs here. See [topic-category-matching.md](../../topic-category-matching.md)for the architecture.**Interest = one of the 9 categories, submitted as a slug.** The user picks **0–2**from the `topics` multiselect; `field.options` holds the whole list inline, andeach option's `value` is the category's **slug** (`"sports-fitness"`, not`"Sports & Fitness"`). Submit them as **`topicSlugs`** — the same field posts use.See [topic-category-matching.md](../../topic-category-matching.md) for why.
1. `GET …/form-schema` → render the field list. `categories` options are **inline** (no separate fetch). Cache for the session.2. (Optional) after the user types the title, `POST …/suggest-categories` and pre-highlight the returned categories.3. If the user uploads photos → use the two-phase media flow.4. `POST` (create) or `PATCH` (edit) with the assembled body — including `topicCategories: [...]` (1–2).1. `GET …/form-schema` → render the field list. `topics` options are **inline** (no separate fetch). Cache for the session.2. If the user uploads photos → use the two-phase media flow.3. `POST` (create) or `PATCH` (edit) with the assembled body — including `topicSlugs: [...]` (0–2).
{ "name": "categories", "name": "topics", "label": "Interests", "type": "multiselect", "required": true, "minItems": 1, "required": false, "maxItems": 2, "options": [ { "value": "Academics & Learning", "label": "Academics & Learning" }, { "value": "Sports & Fitness", "label": "Sports & Fitness" }, /* … the ~8 categories, inline — no separate fetch */ { "value": "academics-learning", "label": "Academics & Learning", "category": "Academics & Learning", "icon": null }, { "value": "sports-fitness", "label": "Sports & Fitness", "category": "Sports & Fitness", "icon": null }, /* … the 9 categories, inline — no separate fetch */ ], "submitAs": "topicCategories", "submitAs": "topicSlugs", "searchEndpoint": "GET /api/users/topics?q=", },
"categories": [ /* same as fields[categories].options */ /* the raw category labels — display only, NOT submittable */ ], "topics": [ /* full topic list — reference only; NOT the selectable unit */ /* same as fields[topics].options — this IS the selectable unit */ ],
#### `multiselect` (the `categories` field)#### `multiselect` (the `topics` field) - Options are **inline** (`field.options`) — no `source` fetch. Render each using `label`; submit an **array** of `value` under `submitAs` — e.g. `"topicCategories": ["Sports & Fitness"]`.
… change truncated — download the .md for the full update

Mobile Activities & Achievements — Integration Guide

For mobile development. Everything needed to power the Activity and Achievement create / edit screens — fetch the form schema, render the fields, hit the dropdown endpoints, attach media, submit.

  • API base URL (local): http://localhost:3001
  • Auth: accessToken (logged-in user). Send as Authorization: Bearer <token>.
  • Send X-Client-Type: mobile on every request.

What's new

  • 2026-09-07 — Interests are picked as categories again, but the field is topicSlugs, not topicCategories: send the category's slug ("sports-fitness"), 0–2 of them, from the inline topics options in form-schema. This guide previously documented topicCategories with raw labels — that was wrong since 2026-07-21, when the API stopped reading that field and started silently ignoring it, so entries saved with no interests at all. Also: stop calling suggest-categories (dropped from the form schema — its keyword matching was too noisy), and interests are now optional (0 allowed), not required 1–2.

Short version — just the calls

GET /api/users/me/activities/form-schema → field list + type/role dropdowns
GET /api/users/me/activities → list user's activities
POST /api/users/me/activities → create activity
PATCH /api/users/me/activities/:id → update (partial)
DELETE /api/users/me/activities/:id → delete

GET /api/users/me/achievements/form-schema → field list + level dropdown
GET /api/users/me/achievements → list user's achievements
POST /api/users/me/achievements → create achievement
PATCH /api/users/me/achievements/:id → update (partial)
DELETE /api/users/me/achievements/:id → delete

POST /api/media/presign → step 1 of media upload
PUT <presignedUrl> → step 2 — direct upload to S3
(no step 3 needed for these flows —
pass the inline media shape in
the create/edit body and the
route attaches it server-side)

Interest = one of the 9 categories, submitted as a slug. The user picks 0–2 from the topics multiselect; field.options holds the whole list inline, and each option's value is the category's slug ("sports-fitness", not "Sports & Fitness"). Submit them as topicSlugs — the same field posts use. See topic-category-matching.md for why.

The flow on both screens is identical:

  1. GET …/form-schema → render the field list. topics options are inline (no separate fetch). Cache for the session.
  2. If the user uploads photos → use the two-phase media flow.
  3. POST (create) or PATCH (edit) with the assembled body — including topicSlugs: [...] (0–2).

1 — Activities

1.1 — Form schema

GET /api/users/me/activities/form-schema
Authorization: Bearer <accessToken>

Response:

{
"success": true,
"data": {
"fields": [
{
"name": "title",
"label": "Title",
"type": "text",
"required": true,
"maxLength": 120,
"placeholder": "e.g., School football team",
},
{
"name": "type",
"label": "Activity Type",
"type": "select",
"required": true,
"options": [
{ "value": "club_organisation", "label": "Club / Organisation" },
{ "value": "project", "label": "Project" },
{ "value": "competition", "label": "Competition" },
{ "value": "internship", "label": "Internship / Work" },
{ "value": "volunteer", "label": "Volunteer" },
{ "value": "course", "label": "Course / Program" },
{ "value": "event", "label": "Event / Workshop" },
{ "value": "research", "label": "Research / Publication" },
{ "value": "other", "label": "Other" },
],
"otherValue": "other",
"otherInput": {
"label": "Activity Description",
"placeholder": "e.g., Chess Club",
"maxLength": 60,
"required": true,
"submitAs": "typeOther",
},
},
{
"name": "role",
"label": "Role",
"type": "select",
"required": true,
"dependsOn": "type",
"optionsByDependency": {
"club_organisation": [
/* Member, Core Team, Lead, … */
],
"project": [
/* Creator, Contributor, Lead, … */
],
"competition": [
/* Participant, Team Member, … */
],
"internship": [
/* Intern, Trainee, Assistant, … */
],
"volunteer": [
/* Volunteer, Contributor, Organiser, … */
],
"course": [
/* Participant, Student, Learner, … */
],
"event": [
/* Attendee, Participant, Volunteer, … */
],
"research": [
/* Researcher, Author, Co-author, … */
],
"other": [{ "value": "other", "label": "Other" }],
},
"otherValue": "other",
"otherInput": {
"label": "Describe your role",
"placeholder": "e.g., Lead Mentor",
"maxLength": 80,
"submitAs": "role",
},
},
{
"name": "description",
"label": "Description",
"type": "textarea",
"required": false,
"maxLength": 500,
"placeholder": "What did you do? What did you learn?",
},
{
"name": "topics",
"label": "Interests",
"type": "multiselect",
"required": false,
"maxItems": 2,
"options": [
{ "value": "academics-learning", "label": "Academics & Learning",
"category": "Academics & Learning", "icon": null },
{ "value": "sports-fitness", "label": "Sports & Fitness",
"category": "Sports & Fitness", "icon": null },
/* … the 9 categories, inline — no separate fetch */
],
"submitAs": "topicSlugs",
"searchEndpoint": "GET /api/users/topics?q=",
},
{
"name": "media",
"label": "Photos",
"type": "media",
"required": false,
"maxItems": 8,
"acceptedTypes": ["image"],
},
],
"dropdowns": {
"type": [
/* same as fields[type].options */
],
"rolesByType": {
/* same as fields[role].optionsByDependency */
},
"categories": [
/* the raw category labels — display only, NOT submittable */
],
"topics": [
/* same as fields[topics].options — this IS the selectable unit */
],
},
},
}

Rendering rules

Field typeUI
textSingle-line input. Enforce maxLength.
textareaMulti-line input. Enforce maxLength.
selectDropdown picker.
multiselectChip / tag picker.
mediaPhoto picker, enforce maxItems & acceptedTypes.
urlURL input.
dateDate picker. Submit as ISO YYYY-MM-DD.

select with otherValue + otherInput

When the picked option's value === otherValue:

  1. Show a text input with otherInput.label / placeholder / maxLength (and honor otherInput.required).
  2. Submit the typed string under otherInput.submitAs. For type, the sentinel "other" stays in type and the free text goes in a separate typeOther field (the design's "Activity Description"). So picking Other and typing Chess Club sends "type": "other", "typeOther": "Chess Club". typeOther is required when type === "other" (server returns 400 TYPE_OTHER_REQUIRED otherwise) and ignored for any concrete type.

select with dependsOn + optionsByDependency

  • role.dependsOn === "type": when the value of type changes, look up optionsByDependency[currentType] for role's option list.
  • If type is empty or its value isn't a key in the map, clear the role field and disable the dropdown.
  • If type is the free-text "Other" value (any string not in dropdowns.type), use optionsByDependency.other (just Other).

multiselect (the topics field)

  • Options are inline (field.options) — no source fetch. Render each using label; submit an array of value (the slug) under submitAs — e.g. "topicSlugs": ["sports-fitness"].
  • 0–2 (maxItems: 2). Optional on both screens — omit the field or send [].
  • What comes back in topics[] is exactly what you sent, resolved to full topic objects. No keyword inference on top: if you send one slug you get one topic.
  • Send an entry with no topics and shareToFeed: true and the server may auto-tag the shared post with at most 2 categories from its text. The entry itself stays untagged.

1.2 — Create activity

POST /api/users/me/activities
Authorization: Bearer <accessToken>
Content-Type: application/json

{
"title": "School football team",
"type": "club_organisation", // sentinel "other" + typeOther when Other
"role": "captain", // or free string if user picked Other
"description": "Led the school team to district finals.",
"topicSlugs": ["sports-fitness"], // 0–2 slugs from form-schema topics.options
"media": [
{ "type": "image", "url": "https://cdn.../photo.jpg", "width": 1080, "height": 1080 }
],
"shareToFeed": true, // optional — also publish as a feed post
"shareCaption": "Proud of the season!" // optional — overrides the auto post body
}

If type === "other", also send "typeOther": "<free text>" (required). shareToFeed/shareCaption are optional — see Share to feed below.

Response: 201

{
"success": true,
"data": {
"id": "act_...",
"userId": "usr_...",
"title": "School football team",
"type": "club_organisation",
"role": "captain",
"description": "...",
"order": 0,
"createdAt": "...",
"updatedAt": "...",
"media": [
{
"id": "med_...",
"type": "image",
"url": "...",
"width": 1080,
"height": 1080,
},
],
"topics": [
{ "id": "top_...", "slug": "sports", "name": "Sports", "icon": "..." },
{ "id": "top_...", "slug": "teamwork", "name": "Teamwork", "icon": "..." },
],
"sharedPostId": "post_..." // present (id) when shareToFeed=true, else null
},
}

Server validates against activityItemSchema. Required: title (≤ 120), type, role. typeOther is required when type === "other". topicSlugs, description and media are optional. type/role accept any string (≤ 60 / 80) so the "Other" free-text submits cleanly.

topics[] in the response is what you submitted, resolved (source="USER_SELECTED", confidence 1.0). topicCategories[] is the server-derived label for each — response-only, never send it. Unknown slugs are dropped silently rather than rejected, so a stale cached picker degrades instead of failing. The legacy category string column is unused by this flow.

1.3 — Update activity

PATCH /api/users/me/activities/:id
Authorization: Bearer <accessToken>
Content-Type: application/json

Send any subset of the create fields. Two diff rules:

  • Media — Omit media → unchanged. Send mediareplaces the current set (URLs present kept, missing dropped + storage cleared, new appended). A re-order with no add/remove still requires the full array.
  • Topics — send topicSlugs → it replaces the whole set (send [] to clear). Omit it → topics untouched, even if you change the title. Media- or title-only edits never touch topics or weights. Weights are never decreased (dropping an interest doesn't lower anything).

1.4 — List & delete

GET /api/users/me/activities
DELETE /api/users/me/activities/:id

List response: { success: true, data: [...] } — each row has the same shape as the create response (inline row fields + media[] + topics[]). Ordered by order ASC then createdAt ASC.

Delete: { success: true }. Cascades media (rows + storage blobs) and topic-join rows via FK.


2 — Achievements

2.1 — Form schema

GET /api/users/me/achievements/form-schema
Authorization: Bearer <accessToken>

Response:

{
"success": true,
"data": {
"fields": [
{
"name": "title",
"label": "Title",
"type": "text",
"required": true,
"maxLength": 120,
},
{
"name": "level",
"label": "Level",
"type": "select",
"required": true,
"options": [
{ "value": "school", "label": "School" },
{ "value": "regional", "label": "Regional" },
{ "value": "district", "label": "District" },
{ "value": "national", "label": "National" },
{ "value": "international", "label": "International" },
],
},
{
"name": "awardedAt",
"label": "Date",
"type": "date",
"required": true,
},
{
"name": "description",
"label": "Description",
"type": "textarea",
"required": false,
"maxLength": 500,
},
{
"name": "topics",
"label": "Interests",
"type": "multiselect",
"required": false,
"maxItems": 2,
"options": [
/* the 9 categories, inline — same as activities */
],
"submitAs": "topicSlugs",
"searchEndpoint": "GET /api/users/topics?q=",
},
{
"name": "media",
"label": "Photos",
"type": "media",
"required": false,
"maxItems": 8,
"acceptedTypes": ["image"],
},
],
"dropdowns": {
"level": [
/* same as fields[level].options */
],
"categories": [
/* raw labels — display only, NOT submittable */
],
"topics": [
/* same as fields[topics].options — this IS the selectable unit */
],
},
},
}

Same rendering rules as activities. No dependsOn / otherValue here — level is a closed enum, and topics behaves exactly like the activity topics field (0–2, inline options, submitAs: "topicSlugs").

issuer and certificateUrl are no longer in the form (dropped from the design). They're still accepted by the API as legacy optional fields, but don't render inputs for them.

2.2 — Create achievement

POST /api/users/me/achievements
Authorization: Bearer <accessToken>
Content-Type: application/json

{
"title": "District Math Olympiad — Gold",
"level": "district",
"awardedAt": "2025-11-12",
"description": "Top score across 800 participants.",
"topicSlugs": ["academics-learning"], // 0–2 slugs from form-schema
"media": [
{ "type": "image", "url": "https://cdn.../medal.jpg" }
],
"shareToFeed": true, // optional — also publish as a feed post
"shareCaption": "So happy about this!" // optional — overrides the auto post body
}

Required: title, level, awardedAt. topicSlugs (0–2), description and media are optional. issuer / certificateUrl still accepted (legacy) but not part of the form. shareToFeed/shareCaption optional — see Share to feed below.

Response: 201 — shape mirrors the activities create response, including topics: [{ id, slug, name, icon }] (what you submitted, resolved), topicCategories: ["Academics & Learning"] (derived, response-only) and sharedPostId (the created post's id when shareToFeed=true, else null).

2.3 — Update achievement

PATCH /api/users/me/achievements/:id

Partial body. Same media + topic diff rules as activities (§1.3): sending topicSlugs replaces topics[] (send [] to clear); omitting it leaves topics alone even when the title changes. Weights never decrease.

2.4 — List & delete

GET /api/users/me/achievements
DELETE /api/users/me/achievements/:id

Same response shape as activities — each row has inline fields + media[] + topics[]. Delete cascades media + topic-join rows.


2.5 — Share to feed (the "Share with others" toggle)

Both create screens have a "Share with others" toggle. When on, send shareToFeed: true in the create body and the server — in the same transaction as the entry — also publishes a feed post that snapshots the whole entry. The 201 response returns sharedPostId (the new post's id, or null when the toggle is off). Optionally send shareCaption (≤ 2000 chars) to set the post's lead text; omit it and the body defaults to title + description.

  • Post type is a dedicated kind — SHARED_ACTIVITY or SHARED_ACHIEVEMENT. These are internal: they render in the feed and on the author's profile timeline but never appear in the post-composer picker (GET /api/posts/types omits them).
  • Snapshot, not a live link. Sharing is create-only and captures the entry as it was. Editing the activity later does not update the post, and deleting the activity keeps the post (its media blob is preserved).
  • The post carries the entry's media (same photos/videos) and the same interests (topics[]), exactly like any other post.

The full field set lives in the post's details JSON — render the feed card from it. Shape (a SHARED_ACTIVITY example):

{
"id": "post_...",
"type": "SHARED_ACTIVITY",
"title": "School football team",
"content": "Proud of the season!", // shareCaption, or title + description
"author": { "id": "usr_...", "username": "...", "displayName": "...", "avatarUrl": "..." },
"media": [ { "id": "med_...", "type": "image", "url": "...", "width": 1080, "height": 1080 } ],
"topics": [ { "id": "top_...", "slug": "sports", "name": "Sports", "icon": "..." } ],
"details": {
"source": { "kind": "activity", "id": "act_..." }, // the origin entry
"activity": { // every form field, snapshotted
"title": "School football team",
"type": "club_organisation",
"typeOther": null,
"role": "captain",
"description": "Led the school team to district finals.",
"organisation": null,
"duration": null,
"area": null,
"category": null,
"startDate": null,
"endDate": null,
"isOngoing": false,
"location": null
}
}
// …plus the usual post fields (_count, cta:null, isLiked, isSaved, createdAt, …)
}

For a SHARED_ACHIEVEMENT, details.achievement carries { title, level, awardedAt, description, area, issuer, category, certificateUrl }.

details does not duplicate media/topics — read those from the post's own media[] / topics[]. The two shared kinds have no request/CTA flow (cta: null).


3 — Interests

The interest picker on both screens is the topics multiselect from form-schema — 0–2 values, options inline, submitted as topicSlugs. Each value is a category slug.

// field.options — the whole list, inline
[
{ "value": "academics-learning", "label": "Academics & Learning" },
{ "value": "arts-creativity", "label": "Arts & Creativity" },
{ "value": "business-leadership", "label": "Business & Leadership" },
{ "value": "competitive-activities", "label": "Competitive Activities" },
{ "value": "entertainment-pop-culture","label": "Entertainment & Pop Culture" },
{ "value": "gaming-digital-culture", "label": "Gaming & Digital Culture" },
{ "value": "lifestyle", "label": "Lifestyle" },
{ "value": "sports-fitness", "label": "Sports & Fitness" },
{ "value": "technology-innovation", "label": "Technology & Innovation" }
]

GET /api/users/topics returns the same 9 (cached 1h), and ?q=<term> filters them — use it only if you want a search box over such a short list.

Not the onboarding picker. Onboarding still selects fine-grained topics (?representative=1, ~86 of them, submitted as interestSlugs) — a different list for a different screen. See mobile-topics-interests-guide.md.

Keyword suggestions are gone. POST /api/users/topics/suggest-categories still responds, but don't build against it — it matched on characters, not meaning, and the suggestions were often unrelated to the title. Semantic matching will bring a suggestion step back later; the request field (topicSlugs) won't change when it does.

4 — Media upload (two-phase)

Same flow as everywhere else in the app. Don't use the multipart POST /api/media/upload route except as a fallback — it costs the API a proxy hop.

4.1 — Presign

POST /api/media/presign
Authorization: Bearer <accessToken>
Content-Type: application/json

{
"fileName": "medal.jpg",
"mimeType": "image/jpeg",
"fileSize": 482910
}

Response:

{
"success": true,
"data": {
"uploadUrl": "https://s3.../...?X-Amz-Signature=...",
"publicUrl": "https://cdn.../medal.jpg",
"objectKey": "...",
},
}

4.2 — Direct PUT to uploadUrl

PUT <uploadUrl>
Content-Type: image/jpeg
<binary body>

No auth header on this one (signature carries auth). Images only (jpeg/png/ webp max 10 MB; heic/heif iOS photos max 15 MB) — video is rejected at presign (415). Presign now requires the file size (bytes); your PUT's Content-Length must match it, or storage returns 403.

4.3 — Attach (optional during create)

For activity / achievement create + edit you can skip /api/media/attach and pass the inline shape directly inside media[]:

{
"type": "image", // image ONLY — video/gif are rejected
"url": "<publicUrl>",
"width": 1080, // optional but recommended
"height": 1080,
"size": 482910, // bytes
"altText": "Gold medal photo",
}

The route's attachMediaToOwner() helper creates the polymorphic Media row keyed to the activity/achievement and marks it auto-approved.


5 — Validation cheat-sheet

FieldRule
titlerequired, 1–120 chars
topicSlugsoptional, 0–2 category slugs (from form-schema options)
type (activity)required, ≤ 60 chars ("other" sentinel or option value)
typeOther (activity)required iff type === "other", ≤ 60 chars
role (activity)required, ≤ 80 chars
level (achievement)required (school/regional/district/national/international)
awardedAt (achievement)required, ISO date (YYYY-MM-DD)
descriptionoptional, ≤ 500 chars
issuer,certificateUrloptional (legacy — not in form); URL ≤ 2048 for the latter
media[].urlrequired per item, valid URL
media[] count≤ 8 items
shareToFeedoptional bool — also publish a SHARED_ACTIVITY/ACHIEVEMENT feed post
shareCaptionoptional, ≤ 2000 chars — overrides the auto post body

Validation errors come back as 400 with a Zod-style payload:

{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"issues": [{ "path": ["certificateUrl"], "message": "Invalid url" }],
},
}

Surface the first issue per field next to the input.


6 — Caching tips

  • Form schemas are deterministic per release — fetch once on screen mount, cache for the session, refetch on app foreground after a long background.
  • /api/users/topics is max-age=3600 — honor the Cache-Control header.
  • rolesByType is embedded in the form-schema response — no separate call needed when type changes; just swap the option list in-memory.

7 — Open items (backend will follow up)

  • Discovery endpoints ("users with similar activities", "find people who did the same achievement") are not yet exposed — the (now keyword-inferred) ActivityTopic / AchievementTopic join rows already power them server-side.
  • Clients send topicSlugs (request) and read topics[] + the derived topicCategories[] (response). The legacy category string column is unused by this flow.
  • Fine-grained topics come back later, on semantic matching. When they do, field.options simply carries the finer list — the request field, the limit (2) and the response shape stay exactly as documented here, so nothing you build now has to be rewritten.
  • Achievement-list reorder endpoint not yet exposed; reorder is one PATCH per row using the order field for now.
⤓ Download .md