UPDATED 2026-11-06 — shareToFeed / shareCaption no longer return 400. The server now ignores them and saves the entry. This keeps older app builds working. Still no feed post is created. New builds should still not send them. See §2.5.
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-11-06 —
shareToFeed/shareCaptionno longer return 400. The server now ignores them and saves the entry. This keeps older app builds working. Still no feed post is created. New builds should still not send them. See §2.5. - 2026-10-28 — Breaking: the "Share with others" toggle is removed (
shareToFeed/shareCaption→ 400, §2.5). Dates come back as month + year only ("2025-11"): achievementawardedAt, activitystartDate/endDate. You may send a month or a full date. New: an "AI content" toggle (isAiGenerated) on both screens, and activity start date, ongoing toggle and end date in the form. An ongoing activity cannot have an end date (400). See §1.1, §1.2, §2.2. - 2026-10-08 — New: read another user's entries with
GET /api/users/:userId/activitiesandGET /api/users/:userId/achievements, same rows as the/melists. Use these for the Activities/Achievements tabs on someone else's profile, and stop building those tabs from the user's posts. A repost card embeds the original author's shared entry, so that approach showed reposted entries as the reposter's own. Private profile →403 private_profile, blocked/unknown →404, unverified viewer →403 STUDENT_VERIFICATION_REQUIRED. See §1.5. - 2026-09-23 — §4.1 was wrong: the presign request takes a
filesarray, not one flat object. Send{"files":[{"filename","contentType","size"}]}and readdata[0]— a{fileName, mimeType, fileSize}body has always been rejected with400. Corrected below. Also documented (not new, just never written down here): photos on these screens go through the content check, so create and edit can fail with422 MEDIA_BLOCKED,415 MEDIA_UNDECODABLEor503 MEDIA_CHECK_UNAVAILABLE— see §4.4, and callPOST /api/media/checkright after the PUT if you want the user to learn which photo is the problem while they're still in the picker. And a fix: a rejectedPATCHis now a no-op — it used to save your other edits and delete the photos you dropped before failing. - 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
GET /api/users/:userId/activities → another user's activities (profile tab)
GET /api/users/:userId/achievements → another user's achievements (profile tab)
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": "startDate", "label": "Start date", "type": "month", "required": false },
{ "name": "isOngoing", "label": "Ongoing", "type": "toggle", "required": false },
{
"name": "endDate",
"label": "End date",
"type": "month",
"required": false,
"hiddenWhen": { "field": "isOngoing", "equals": true },
},
{
"name": "media",
"label": "Photos",
"type": "media",
"required": false,
"maxItems": 8,
"acceptedTypes": ["image"],
},
{ "name": "isAiGenerated", "label": "AI content", "type": "toggle", "required": false },
],
"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. |
month | Month + year picker. Submit as YYYY-MM (a full YYYY-MM-DD is also accepted). Responses always return YYYY-MM. |
toggle | On / off switch. Submit a boolean. Off = false (or leave it out on create). |
A field with hiddenWhen: { field, equals } is hidden while that other
field has that value. On the activity form, endDate is hidden while
isOngoing is on. When the user turns isOngoing on, clear the end date
too: the API returns 400 for an ongoing activity with an end date.
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. An entry with no topics 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
"startDate": "2025-06", // optional — month or full date
"isOngoing": true, // optional — then do NOT send endDate
"isAiGenerated": false, // optional — the "AI content" toggle
"media": [
{ "type": "image", "url": "https://cdn.../photo.jpg", "width": 1080, "height": 1080 }
]
}
If
type === "other", also send"typeOther": "<free text>"(required). Finished activity:"isOngoing": false(or leave it out) and"endDate": "2026-03". Do not sendshareToFeed/shareCaption— they are removed and ignored (§2.5).
Response: 201
{
"success": true,
"data": {
"id": "act_...",
"userId": "usr_...",
"title": "School football team",
"type": "club_organisation",
"role": "captain",
"description": "...",
"startDate": "2025-06", // always "YYYY-MM", even if you sent a day; null if unset
"endDate": null, // "YYYY-MM", or null (always null while ongoing)
"isOngoing": true,
"isAiGenerated": false, // show an "AI" tag when true
"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": null // always null — sharing to the feed is removed (§2.5)
},
}
Server validates against activityItemSchema. Required: title (≤ 120),
type, role. typeOther is required when type === "other". topicSlugs,
description, media, startDate, endDate, isOngoing and isAiGenerated
are optional. type/role accept any string (≤ 60 / 80) so the "Other"
free-text submits cleanly.
Dates. startDate / endDate take "YYYY-MM" or "YYYY-MM-DD". Every
response returns "YYYY-MM". Two rules, each a 400 on endDate:
| you sent | error |
|---|---|
isOngoing: true and an endDate | endDate is not allowed when isOngoing is true |
| an end month before the start month | endDate must not be before startDate |
Months are compared, not days: start 2025-11-20 and end 2025-11-03 is fine.
AI content. isAiGenerated is the author's own "AI content" toggle. The
server never sets it. Show an AI tag on the entry when it is true.
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. 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). - Dates — omit a date → unchanged. Send
null→ cleared. The two date rules (§1.2) are checked on the result after your edit. So to turnisOngoingon for an activity that has an end date, send{ "isOngoing": true, "endDate": null }in one call. isAiGenerated—true/falsesets it.nullor omitted leaves it unchanged, so a full form dump is safe.
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.
1.5 — Another user's activities & achievements (profile tabs)
GET /api/users/:userId/activities
GET /api/users/:userId/achievements
Authorization: Bearer <accessToken>
:userId is the profile's id from GET /api/users/:username. The response
is exactly the /me list shape (§1.4 / §2.4):
{ success: true, data: [ { ...row, media: [...], topics: [...] } ] }, in the
same order. One renderer covers your own profile and everyone else's. Passing
your own id works too and returns the same rows as /me.
| Status | When | Show |
|---|---|---|
200 | Profile visible to you | The list (may be []) |
403 private_profile | Private profile and you're not an accepted follower | The same locked state as the posts/followers tabs |
403 STUDENT_VERIFICATION_REQUIRED | You (the viewer) aren't student-verified | The verification prompt. Same as the profile's posts tab |
404 | Unknown user, deleted account, blocked either way, or hidden | "User not found" |
Don't build these tabs from posts. Before this route existed, the only way to show someone else's entries was through their
SHARED_ACTIVITY/SHARED_ACHIEVEMENTposts. That approach is wrong in three ways:
- Entries saved without "Share with others" never appear.
- The posts are snapshots, so edits don't show and deleted entries still do.
- A
REPOSTcard embeds the original author's post inoriginal, so a reposted entry shows up as the reposter's own.Reposts belong only in the posts tab, rendered with the "X reposted" header.
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": "month", // month + year only, no day
"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"],
},
{ "name": "isAiGenerated", "label": "AI content", "type": "toggle", "required": false },
],
"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", // or a full date "2025-11-12"
"isAiGenerated": false, // optional — the "AI content" toggle
"description": "Top score across 800 participants.",
"topicSlugs": ["academics-learning"], // 0–2 slugs from form-schema
"media": [
{ "type": "image", "url": "https://cdn.../medal.jpg" }
]
}
awardedAt: send a month or a full date, get a month back.
- Send
"YYYY-MM"("2025-11") or"YYYY-MM-DD"("2025-11-12"), on create and on edit. Anything else ("2025-13","2025-02-30", a date-time) gets a 400 onawardedAt. - Every response returns only
"YYYY-MM", even when you sent a day, and for achievements saved before 2026-10-28. Show it as month + year (Nov 2025).
Required: title, level, awardedAt. topicSlugs (0–2), description,
media and isAiGenerated (same rules as activities, §1.2 / §1.3) are optional. issuer / certificateUrl still accepted (legacy) but
not part of the form. Do not send shareToFeed / shareCaption — ignored (§2.5).
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 (always null, §2.5).
2.3 — Update achievement
PATCH /api/users/me/achievements/:id
Partial body. Same media, topic and isAiGenerated 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 with others" is removed
Both create screens used to have a "Share with others" toggle. It is removed (2026-10-28). Remove the toggle from both screens.
- Do not send
shareToFeedorshareCaption. If a request still has them, the server ignores them and saves the entry (201). Until 2026-11-06 they got400 SHARE_TO_FEED_REMOVED; that was undone so older app builds keep working. - Creating an entry never creates a feed post. The entry shows only on the profile's Activities / Achievements tabs (§1.5).
sharedPostIdis still in the create response, so old code that reads it does not break. It is alwaysnull.
Old shared posts still show. Posts with type SHARED_ACTIVITY or
SHARED_ACHIEVEMENT that were created before the removal stay in the feed and
on the author's profile timeline. No new ones can be created. Keep the card
that renders them:
- The entry's fields are in the post's
details:details.activityordetails.achievement, plusdetails.source: { kind, id }(the original entry's id). They are a snapshot: an edit or delete of the entry does not change the post. - In an old
details.achievementsnapshot,awardedAtis still a full date ("2025-11-12"). Show only its month and year, to match the entry. - Read photos and interests from the post's own
media[]/topics[]. - These posts have no request flow (
cta: null). Never use them to build a profile's Activities / Achievements tabs. Use §1.5.
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
{
"files": [
{ "filename": "medal.jpg", "contentType": "image/jpeg", "size": 482910 }
]
}
Response — an array, one entry per file, in the order you sent them (up to 4 per call):
{
"success": true,
"data": [
{
"uploadUrl": "https://s3.../...?X-Amz-Signature=...",
"publicUrl": "https://cdn.../medal.jpg",
"key": "media/<userId>-...jpg",
"contentType": "image/jpeg",
},
],
}
The full upload contract — allowed types, caps, the exact error codes — lives in mobile-media-upload-guide.md. This section is the short version for these two screens.
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 creates the Media row keyed to the activity/achievement — after running the photo through the content check (§4.4). It is not auto-approved; nothing is.
4.4 — Photos are content-checked, so create and edit can fail
Every photo you attach here is checked before the entry (or the edit) is saved. Three failures, three different things to say — full detail in mobile-media-upload-guide §9:
| code | means | what to do |
|---|---|---|
422 MEDIA_BLOCKED | The photo was checked and fails the content policy. | Ask for a different photo. Never retry. |
415 MEDIA_UNDECODABLE | The file couldn't be opened at all. | Ask for a different photo. Never retry, and never imply it was rejected. |
503 MEDIA_CHECK_UNAVAILABLE | The check couldn't run right now. | Retry the same call with backoff. Never say the photo was rejected. |
The body names every offending item — blocked / undecodable /
unavailable, each [{ index, url }] — so you can grey out the exact
thumbnails and keep the rest of the form.
- On
PATCH,indexcounts only the photos you are adding (the urls not already on the entry), not your wholemediaarray. Match onurlinstead; it is always present on these screens. - A failed create saves nothing, and a failed edit changes nothing — title,
topics and the photos you dropped are all left exactly as they were. (Before
2026-09-23 a rejected
PATCHdid save the rest of the edit and did delete the dropped photos. Fixed.) - Want the user to find out at the picker instead of at Save? Call
POST /api/media/checkwith the urls right after the PUT — it returnsok/blocked/undecodable/unavailableper photo and never throws. It is advisory; these routes check again regardless, and that re-check is free. - iPhone HEIC works (since 2026-09-23 — it silently 503'd before). No need to convert to JPEG on device.
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, YYYY-MM or YYYY-MM-DD; always returned as YYYY-MM |
startDate, endDate (activity) | optional, YYYY-MM or YYYY-MM-DD, always returned as YYYY-MM; null clears on edit |
isOngoing (activity) | optional bool — when true, endDate must be empty (400) |
| end vs start (activity) | end month must not be before start month (400) |
isAiGenerated | optional bool, both — "AI content" toggle; null ignored on edit |
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, shareCaption | removed — ignored if sent, no feed post (§2.5) |
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.