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.
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 asAuthorization: Bearer <token>. - Send
X-Client-Type: mobileon every request.
What's new
- 2026-09-07 — Interests are picked as categories again, but the field is
topicSlugs, nottopicCategories: send the category's slug ("sports-fitness"), 0–2 of them, from the inlinetopicsoptions in form-schema. This guide previously documentedtopicCategorieswith 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 callingsuggest-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:
GET …/form-schema→ render the field list.topicsoptions are inline (no separate fetch). Cache for the session.- If the user uploads photos → use the two-phase media flow.
POST(create) orPATCH(edit) with the assembled body — includingtopicSlugs: [...](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 type | UI |
|---|---|
text | Single-line input. Enforce maxLength. |
textarea | Multi-line input. Enforce maxLength. |
select | Dropdown picker. |
multiselect | Chip / tag picker. |
media | Photo picker, enforce maxItems & acceptedTypes. |
url | URL input. |
date | Date picker. Submit as ISO YYYY-MM-DD. |
select with otherValue + otherInput
When the picked option's value === otherValue:
- Show a text input with
otherInput.label/placeholder/maxLength(and honorotherInput.required). - Submit the typed string under
otherInput.submitAs. Fortype, the sentinel"other"stays intypeand the free text goes in a separatetypeOtherfield (the design's "Activity Description"). So pickingOtherand typingChess Clubsends"type": "other", "typeOther": "Chess Club".typeOtheris required whentype === "other"(server returns400 TYPE_OTHER_REQUIREDotherwise) and ignored for any concrete type.
select with dependsOn + optionsByDependency
role.dependsOn === "type": when the value oftypechanges, look upoptionsByDependency[currentType]for role's option list.- If
typeis empty or its value isn't a key in the map, clear the role field and disable the dropdown. - If
typeis the free-text "Other" value (any string not indropdowns.type), useoptionsByDependency.other(justOther).
multiselect (the topics field)
- Options are inline (
field.options) — nosourcefetch. Render each usinglabel; submit an array ofvalue(the slug) undersubmitAs— 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: trueand 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/shareCaptionare 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. Sendmedia→ replaces 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").
issuerandcertificateUrlare 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_ACTIVITYorSHARED_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/typesomits 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 asinterestSlugs) — a different list for a different screen. See mobile-topics-interests-guide.md.
Keyword suggestions are gone.
POST /api/users/topics/suggest-categoriesstill 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
| Field | Rule |
|---|---|
title | required, 1–120 chars |
topicSlugs | optional, 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) |
description | optional, ≤ 500 chars |
issuer,certificateUrl | optional (legacy — not in form); URL ≤ 2048 for the latter |
media[].url | required per item, valid URL |
media[] count | ≤ 8 items |
shareToFeed | optional bool — also publish a SHARED_ACTIVITY/ACHIEVEMENT feed post |
shareCaption | optional, ≤ 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/topicsismax-age=3600— honor theCache-Controlheader.rolesByTypeis embedded in the form-schema response — no separate call needed whentypechanges; 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 readtopics[]+ the derivedtopicCategories[](response). The legacycategorystring column is unused by this flow. - Fine-grained topics come back later, on semantic matching. When they do,
field.optionssimply 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
orderfield for now.