Skip to main content
Version: Latest
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.
- **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.- **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"`): achievement `awardedAt`, activity `startDate` / `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.
> `"endDate": "2026-03"`.> Do not send `shareToFeed` / `shareCaption` — they are removed (§2.5).> Do not send `shareToFeed` / `shareCaption` — they are removed and ignored (§2.5).
`media` and `isAiGenerated` (same rules as activities, §1.2 / §1.3) are optional. `issuer` / `certificateUrl` still accepted (legacy) butnot part of the form. Do not send `shareToFeed` / `shareCaption` (§2.5).not part of the form. Do not send `shareToFeed` / `shareCaption` — ignored (§2.5).
- Do not send `shareToFeed` or `shareCaption`. Either key gets **`400`**, code `SHARE_TO_FEED_REMOVED`. The entry is not saved.- Do not send `shareToFeed` or `shareCaption`. If a request still has them, the server ignores them and saves the entry (`201`). Until 2026-11-06 they got `400 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
| `media[]` count | ≤ 8 items || `shareToFeed`, `shareCaption` | **removed** — either key → `400 SHARE_TO_FEED_REMOVED` (§2.5) || `shareToFeed`, `shareCaption` | **removed** — ignored if sent, no feed post (§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 as Authorization: Bearer <token>.
  • Send X-Client-Type: mobile on every request.

What's new​

  • 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.
  • 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"): achievement awardedAt, activity startDate / 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/activities and GET /api/users/:userId/achievements, same rows as the /me lists. 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 files array, not one flat object. Send {"files":[{"filename","contentType","size"}]} and read data[0] — a {fileName, mimeType, fileSize} body has always been rejected with 400. 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 with 422 MEDIA_BLOCKED, 415 MEDIA_UNDECODABLE or 503 MEDIA_CHECK_UNAVAILABLE — see §4.4, and call POST /api/media/check right 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 rejected PATCH is 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, 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

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:

  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": "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 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.
monthMonth + year picker. Submit as YYYY-MM (a full YYYY-MM-DD is also accepted). Responses always return YYYY-MM.
toggleOn / 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:

  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. 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 send shareToFeed / 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 senterror
isOngoing: true and an endDateendDate is not allowed when isOngoing is true
an end month before the start monthendDate 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. Send media → 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 turn isOngoing on for an activity that has an end date, send { "isOngoing": true, "endDate": null } in one call.
  • isAiGenerated — true / false sets it. null or 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.

StatusWhenShow
200Profile visible to youThe list (may be [])
403 private_profilePrivate profile and you're not an accepted followerThe same locked state as the posts/followers tabs
403 STUDENT_VERIFICATION_REQUIREDYou (the viewer) aren't student-verifiedThe verification prompt. Same as the profile's posts tab
404Unknown 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_ACHIEVEMENT posts. 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 REPOST card embeds the original author's post in original, 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").

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", // 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 on awardedAt.
  • 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 shareToFeed or shareCaption. If a request still has them, the server ignores them and saves the entry (201). Until 2026-11-06 they got 400 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).
  • sharedPostId is still in the create response, so old code that reads it does not break. It is always null.

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.activity or details.achievement, plus details.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.achievement snapshot, awardedAt is 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 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

{
"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:

codemeanswhat to do
422 MEDIA_BLOCKEDThe photo was checked and fails the content policy.Ask for a different photo. Never retry.
415 MEDIA_UNDECODABLEThe file couldn't be opened at all.Ask for a different photo. Never retry, and never imply it was rejected.
503 MEDIA_CHECK_UNAVAILABLEThe 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, index counts only the photos you are adding (the urls not already on the entry), not your whole media array. Match on url instead; 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 PATCH did 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/check with the urls right after the PUT — it returns ok / blocked / undecodable / unavailable per 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​

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, 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)
isAiGeneratedoptional bool, both — "AI content" toggle; null ignored on edit
descriptionoptional, ≤ 500 chars
issuer,certificateUrloptional (legacy — not in form); URL ≤ 2048 for the latter
media[].urlrequired per item, valid URL
media[] count≤ 8 items
shareToFeed, shareCaptionremoved — 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/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