Mobile Media Upload — Integration Guide
The one place for how media uploads work end-to-end in the RN app: allowed types, size caps, the presign → PUT → reference flow, and the rules the backend enforces. Every media-bearing feature (posts, chat, onboarding, activities, achievements, events, avatar) uses the flow described here — the feature guides only add their own "attach/send" step and link back to this.
- API base URL (local):
http://localhost:3001 - Auth:
Authorization: Bearer <accessToken>on every API call (required) — onboarding uses theonboardingTokeninstead (see §4) - Header:
X-Client-Type: mobileon every API call
⚠️ Separate repos — nothing is shared as a package. The API's limits live in its
@ksn/validatorspackage; the RN app is a different repository and does not import it. The allowlist + caps below are duplicated by hand in the app — treat this doc as the contract and keep the app's copy in sync (see §2). The server is always the source of truth: it re-checks everything and rejects mismatches.
What's new
- 2026-10-05 — Avatar confirm (
PUT /api/users/me/avatar) now returns400 MEDIA_KEY_REJECTEDunless theavatarUrlis thepublicUrlyou got from your own/me/avatar/presign— nothing to change if you already use that flow. The same rule applies to a group's image: it must be your own/media/presignupload and is content-checked (422MEDIA_BLOCKED). - 2026-10-05 — Uploads not attached within 24 hours are deleted, so
upload when the user taps Post, not when they pick the photo. Also,
/api/media/checknow deletes ablockedphoto: reusing its URL returns 422MEDIA_BLOCKED, so replace the photo instead of resubmitting. - 2026-09-23 — iPhone HEIC photos now actually upload. HEIC/HEIF was
always on the allowed list, but every real camera HEIC failed with
503 MEDIA_CHECK_UNAVAILABLE— "please try again" on something that could never succeed. Fixed server-side; nothing to change in the app, and you can stop converting to JPEG first if you were. New failure code in the same change:415 MEDIA_UNDECODABLEmeans the file couldn't be opened at all (corrupt or truncated) — permanent, ask for a different photo, and don't say it was rejected./api/media/checkcan now answerundecodableper photo too. If your code treats "not 422" as retryable, add 415 or you'll loop forever. - 2026-09-21 — You can now check a photo right after you upload it,
before the user finishes the post:
POST /api/media/checkwith the urls you just PUT returnsok/blocked/unavailableper photo, so you can grey out the one bad thumbnail in the picker instead of losing a finished post to one error. It's optional and nothing breaks if you skip it — but if you do skip it, the create call's422/503body now also tells you exactly which items failed (blockedandunavailablearrays, each withindexand theurlyou sent). New404 MEDIA_NOT_FOUNDif you reference an object that isn't in storage — re-upload, don't retry. - 2026-09-18 — The photo safety check is enforcing from day one, not
observe-only as stated yesterday. Both failures are live on your first
shipped build:
422 MEDIA_BLOCKEDmeans the photo failed the check — ask for a different one, retrying won't help.503 MEDIA_CHECK_UNAVAILABLEmeans the check itself couldn't run — retryable, and you must not tell the user their photo was rejected. Handling both is required to ship, not optional.
1 — How uploads work (the whole model)
Media never flows through the API as bytes. The client uploads straight to object storage using a short-lived presigned URL, then references the uploaded object when it creates the post / message / etc.
1. POST <presign endpoint> → { uploadUrl, key/publicUrl } (declare type + size)
2. PUT <uploadUrl> → upload raw bytes straight to storage
3. <feature create/send> → reference the object (by key or url)
The server enforces uploads at three layers — know these so you handle the errors correctly:
- Presign — rejects a disallowed content-type (e.g. any video) with
415, asizeover the cap with413, and bakes your declaredsizeinto the signed URL asContent-Length. Your PUT body must be exactly that many bytes or storage returns403. - Byte verification — when you reference the object, the server downloads
the first chunk and sniffs the real bytes. A file whose true content
doesn't match what you declared (a
.png-named PDF, a renamed video) is rejected415. The declared type is never trusted — don't try to sneak a type past it; send accurate metadata. - Persistence — a final image-only / kind check per surface.
There is a server-side multipart fallback (POST /api/media/upload) for clients
that can't presign — see §6. Prefer presign.
2 — The rules (copy into the app)
Mirror this in the RN app and keep it in sync with this doc. It's the same table the backend enforces.
Allowed content types + caps
| kind | content types | max size |
|---|---|---|
image | image/jpeg, image/png, image/webp | 10 MB |
image | image/heic, image/heif (+ -sequence) — iOS photos | 15 MB |
gif | image/gif | 15 MB |
file | application/pdf, .docx, .xlsx, text/csv | 25 MB |
No video. No audio. Any video/* or audio/* is rejected at presign
(415). Don't offer a record-video / record-voice / attach-video path.
.docx = application/vnd.openxmlformats-officedocument.wordprocessingml.document
.xlsx = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Per-surface rules — what each screen may attach
| Surface | Presign endpoint | Allowed kinds |
|---|---|---|
| Posts | POST /api/media/presign | image only |
| Chat | POST /api/media/presign | image, gif, file |
| Activities / Achievements | POST /api/media/presign | image only |
| Events | POST /api/media/presign | image only |
| Onboarding (avatar/act/ach) | POST /api/onboarding/media-presign | image only |
| Profile avatar | POST /api/users/me/avatar/presign | image only |
gifin chat is usually a Klipy picker gif sent asremoteUrl(no upload). Owned gif uploads are also allowed. Files (pdf/docx/csv/xlsx) are chat-only. Everywhere else is images.
Drop-in TypeScript (app-side copy)
// media-limits.ts — MIRRORS the API's @ksn/validators. Keep in sync with
// docs/mobile-guides/media-upload. The server is authoritative.
const MB = 1024 * 1024;
export type UploadKind = "image" | "gif" | "file";
export interface UploadTypeSpec { kind: UploadKind; maxBytes: number; ext: string[]; }
export const SUPPORTED_UPLOAD_TYPES: Record<string, UploadTypeSpec> = {
"image/jpeg": { kind: "image", maxBytes: 10 * MB, ext: ["jpg", "jpeg"] },
"image/png": { kind: "image", maxBytes: 10 * MB, ext: ["png"] },
"image/webp": { kind: "image", maxBytes: 10 * MB, ext: ["webp"] },
"image/gif": { kind: "gif", maxBytes: 15 * MB, ext: ["gif"] },
"image/heic": { kind: "image", maxBytes: 15 * MB, ext: ["heic"] },
"image/heif": { kind: "image", maxBytes: 15 * MB, ext: ["heif"] },
"image/heic-sequence": { kind: "image", maxBytes: 15 * MB, ext: ["heic"] },
"image/heif-sequence": { kind: "image", maxBytes: 15 * MB, ext: ["heif"] },
"application/pdf": { kind: "file", maxBytes: 25 * MB, ext: ["pdf"] },
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
{ kind: "file", maxBytes: 25 * MB, ext: ["docx"] },
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
{ kind: "file", maxBytes: 25 * MB, ext: ["xlsx"] },
"text/csv": { kind: "file", maxBytes: 25 * MB, ext: ["csv"] },
};
// Which kinds each screen may attach.
export const SURFACE_KINDS = {
post: ["image"],
chat: ["image", "gif", "file"],
generic: ["image"], // activities, achievements, events, onboarding, avatar
} as const;
export const specFor = (ct: string): UploadTypeSpec | null =>
SUPPORTED_UPLOAD_TYPES[ct] ?? null;
/** Client-side pre-flight: block obvious rejects before you even presign. */
export function checkUploadable(contentType: string, size: number, surface: keyof typeof SURFACE_KINDS) {
const spec = specFor(contentType);
if (!spec) return { ok: false as const, reason: "type_not_supported" };
if (!SURFACE_KINDS[surface].includes(spec.kind)) return { ok: false as const, reason: "kind_not_allowed_here" };
if (size > spec.maxBytes) return { ok: false as const, reason: "too_large", maxBytes: spec.maxBytes };
return { ok: true as const, kind: spec.kind, maxBytes: spec.maxBytes };
}
Pre-flight is UX only — it saves a round-trip and gives a nicer message. The server still enforces everything.
3 — Standard upload: POST /api/media/presign
Used by posts and chat (and the activities/achievements/events attach flows). Handles 1–4 files per call.
3.1 — Presign
POST /api/media/presign
Authorization: Bearer <accessToken>
X-Client-Type: mobile
Content-Type: application/json
{
"files": [
{ "filename": "photo.jpg", "contentType": "image/jpeg", "size": 348211 }
]
}
| field | required | notes |
|---|---|---|
filename | ✅ | Its extension sets the stored object's extension — pass a real one. |
contentType | ✅ | Must be in the allowlist (§2). Video → 415. |
size | ✅ | Exact byte length you will PUT. Over the cap → 413. Pinned as Content-Length. |
- Max 4 files per call.
Response:
{
"success": true,
"data": [
{
"uploadUrl": "https://.../signed-put-url", // PUT bytes here (~5 min TTL)
"publicUrl": "https://.../media/<you>-...", // final URL (posts reference this)
"key": "media/<yourUserId>-1719-uuid.jpg", // storageKey (chat references this)
"contentType": "image/jpeg"
}
]
}
3.2 — PUT the bytes to storage
PUT <uploadUrl>
Content-Type: image/jpeg ← must equal the presign contentType
Content-Length: 348211 ← must equal the presign size (exact)
<raw bytes>
- No auth headers — the signed URL is the credential.
Content-Lengthmust equalsize. Storage rejects any other length with403. Most HTTP clients set it automatically from a known-size body — see the RN note in §7.- On success storage returns
200with an empty body.
3.3 — Reference it in the feature call
You don't call a separate attach endpoint for most flows — you pass the reference inside the create/send body:
- Posts →
media: [{ "type": "image", "url": "<publicUrl>", "width", "height" }](image only). See Posts guide. - Chat →
media: [{ "storageKey": "<key>", "kind": "image|gif|file", "mimeType", ... }](send thekey, not a url;mimeTyperequired for files). See Chat Media guide. - Activities / Achievements / Events →
media: [{ "type": "image", "url": "<publicUrl>" }]. See Activities & Achievements guide.
At this step the server byte-verifies each referenced object (§5).
3.4 — Pre-check the photos (optional, strongly recommended)
Right after the PUTs land and before the user finishes composing, ask which of the uploaded photos are usable:
POST /api/media/check
Authorization: Bearer <accessToken>
Content-Type: application/json
{ "urls": ["<publicUrl1>", "<publicUrl2>"] }
// 200 — always 200, even when a photo is rejected
{ "success": true, "data": [
{ "url": "<publicUrl1>", "status": "ok" },
{ "url": "<publicUrl2>", "status": "blocked" }
]}
One entry per url, in the order you sent them. Four statuses:
status | Means | Do |
|---|---|---|
ok | Safe to use. | Nothing. |
blocked | This photo fails the content policy. | Mark that thumbnail rejected, ask for a different one. Never retry it — the same bytes always fail. |
undecodable | The file couldn't be opened at all (corrupt / truncated). | Ask for a different photo. Never retry it, and never say it was rejected — nothing looked at the picture. |
unavailable | The check couldn't run for this photo. | Retry /check with backoff, or just proceed — the create call re-checks. Never tell the user the photo was inappropriate. |
Notes:
- Up to 4 urls per call, same cap as presign. Rate limit 60/hour per user.
- A
blockedanswer here is the same verdict the create/send call would give you — this endpoint is not a second, looser check. - A
blockedphoto is deleted server-side. Reusing its URL in a create/send call returns 422MEDIA_BLOCKED— upload a replacement. - Uploads that are never attached to a post/message are deleted after 24 hours. Upload at Post time, not on pick.
- It is advisory. The create/send call still checks every photo itself, so
skipping
/checkis safe; you just get the failure later and after the user has done more work. - It costs you nothing to call: the server caches the verdict against the image's bytes, so the create call doesn't re-do the work.
UX this is for: four photos picked, one bad. Without /check the user
writes the whole post, taps Post, and gets one error. With it, the bad
thumbnail is marked while they're still in the picker.
4 — Onboarding uploads
Mid-onboarding you don't have an accessToken yet — use the onboarding
endpoint, authorized with the onboardingToken.
POST /api/onboarding/media-presign
Authorization: Bearer <onboardingToken>
X-Client-Type: mobile
Content-Type: application/json
{
"kind": "avatar", // "avatar" | "activity" | "achievement"
"filename": "avatar.jpg",
"contentType": "image/jpeg",
"size": 210433
}
Image-only (jpeg/png/webp 10 MB; heic/heif 15 MB). Response:
{ success, data: { kind, uploadUrl, publicUrl, key, contentType } }. PUT the
bytes exactly as §3.2 (Content-Type +
Content-Length), then PATCH the session with the publicUrl. Full flow +
where each publicUrl lands in data: Onboarding guide.
Errors specific to this route: 404 (session not active), 410 EXPIRED.
5 — Avatar (logged-in)
POST /api/users/me/avatar/presign
Authorization: Bearer <accessToken>
{ "contentType": "image/jpeg", "size": 210433 }
→ { success, data: { uploadUrl, publicUrl, key } }. PUT the bytes (Content-Type
- Content-Length), then confirm:
PUT /api/users/me/avatar
{ "avatarUrl": "<publicUrl>" }
Confirm downloads the original, byte-verifies it's an image, generates the
square display + thumbnail (WebP), and sets profile.avatarUrl. Non-image → 415. A URL that isn't your own presigned avatar upload → 400 MEDIA_KEY_REJECTED.
6 — Multipart fallback: POST /api/media/upload
Only if you can't do presign → PUT. The API proxies the bytes (a hop the presign
flow avoids). multipart/form-data, up to 4 files.
- Same allowlist + caps + byte-verification as presign; video →
415, over cap →413. - Returns
{ success, data: [{ type, url, width, height, size, mimetype, altText }] }wheretypeis the server-verified kind (image|gif|file).
7 — Byte verification (what the server checks)
When you reference an uploaded object, the server sniffs its real bytes before creating any row:
- Type must match. Declared
image/pngbut the bytes are a PDF / video / executable →415. This is a security control — send accurate metadata, and don't rename files to fake a type. - Files: send the real
mimeType. CSV has no magic number, so forkind: "file"the server relies on your declaredmimeTypeto validate it —mimeTypeis required for file attachments. - HEIC/HEIF (iOS): a single photo can sniff as
image/heic,image/heif, or a-sequencevariant — all are accepted as images. Declare the actualcontentTypeyour picker reports. - Size is re-checked against the cap even after upload.
Decode limit ≠ size limit. Even a within-cap image can be rejected after upload if it exceeds the server's decode limits (8000×8000 / 32 MP) — e.g. a 9 MB 9500×9500 PNG. Down-scale huge images before upload.
8 — The Content-Length gotcha (read this)
The presigned URL is signed for exactly the size you declared. The PUT
must send a Content-Length equal to that size, or storage returns 403
(it reads as a signature/length mismatch).
- Declare the real byte length at presign — get it from the picker/file system, not an estimate.
- In RN,
fetchsetsContent-Lengthautomatically when the body is aBlob(or string) of known size. Upload a Blob, not a streaming source:
async function putToStorage(uploadUrl: string, fileUri: string, contentType: string) {
const blob = await (await fetch(fileUri)).blob(); // known size → Content-Length set
const res = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": contentType }, // do NOT add Authorization
body: blob,
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`); // 403 = length/type mismatch
}
- Don't send the file via
FormDatato a presigned PUT — that changes the body length and content-type and breaks the signature. - If your HTTP layer can't guarantee an exact
Content-Length, use the multipart fallback instead.
9 — End-to-end RN example (image on a post)
import { checkUploadable } from "./media-limits";
import { api } from "./api"; // your axios/fetch wrapper with auth + X-Client-Type
async function uploadPostImage(fileUri: string, contentType: string, size: number) {
// 1. Pre-flight (UX only)
const pre = checkUploadable(contentType, size, "post");
if (!pre.ok) throw new Error(pre.reason);
// 2. Presign
const { data } = await api.post("/api/media/presign", {
files: [{ filename: `photo.${pre.kind === "gif" ? "gif" : "jpg"}`, contentType, size }],
});
const { uploadUrl, publicUrl } = data.data[0];
// 3. PUT bytes (exact Content-Length via Blob)
await putToStorage(uploadUrl, fileUri, contentType);
// 4. Reference on create (posts are image-only)
return { type: "image", url: publicUrl }; // put into the post body's media[]
}
For chat, reference by key (not url) and send kind + mimeType:
const { key } = data.data[0];
// message media item:
{ storageKey: key, kind: "file", mimeType: "application/pdf", altText: "report.pdf" }
9b — The content check (and the two failures you must tell apart)
Every image is checked for content before it is attached to anything. This runs on all image surfaces: posts, chat, avatars, onboarding, activities, achievements, events, and the multipart fallback. It is not a separate call — it happens inside the feature call you already make, so the only thing that changes for you is two new failure codes.
These three are not interchangeable and must not share a message.
422 MEDIA_BLOCKED | 415 MEDIA_UNDECODABLE | 503 MEDIA_CHECK_UNAVAILABLE | |
|---|---|---|---|
| Means | We checked the photo. It fails the policy. | We couldn't open the file at all. | We could not run the check at all. |
| Retry | Never. The same bytes will fail again. | Never. The file will never open. | Yes, with backoff. Same upload, same key. |
| Say | "That photo can't be shared here — please pick a different one." | "We couldn't open that photo — please pick a different one." | "We couldn't check that photo right now. Please try again." |
| Don't say | — | Anything implying the photo was inappropriate — nothing looked at it. | Anything implying the photo was inappropriate. |
Getting this backwards is the failure mode that matters: telling a child their
photo was inappropriate because a server was briefly down is worse than the
outage. If you only handle one of the two, handle 503 as a retry and let
422 fall through to a generic error — never the reverse.
On 503 the uploaded object is deliberately left in storage, so a retry of the
same attach/send call with the same key works. You do not need to
re-presign or re-upload the bytes.
Which photo failed?
Both error bodies name the offending items — all of them, not just the first:
// 422
{
"success": false,
"error": "That photo can't be shared here. Please choose a different one.",
"code": "MEDIA_BLOCKED",
"blocked": [{ "index": 1, "url": "<the url you sent>" }],
"unavailable": [{ "index": 3, "url": "<the url you sent>" }]
}
indexis the position in themediaarray you sent, and is always present.urlis present wherever you sent urls (posts, activities, achievements, events,/api/media/attach); on chat and onboarding, which send storage keys, you getindexonly.- A batch containing several problems takes the strongest code — 422, then
415, then 503 — and still lists every array (
blocked,undecodable,unavailable), so one round trip tells you everything that is wrong. - A batch with no blocked item but an unopenable file is a 415; with only a
failed check, a 503 with
unavailableonly. - On a
PATCHthat edits media (activities, achievements),indexcounts the photos you are adding — the ones not already on the entry — not your wholemediaarray. Match onurl, which is always present on those surfaces.
Drop only the named items and keep the rest of the post — the user should not have to re-pick photos that were fine.
Live from day one — there is no grace period. The gate runs in enforcing mode from launch, so both codes are real on the first build that ships. A client that does not handle them shows raw errors to children on day one. There is no observe-only window to catch up in.
10 — Errors
| code | meaning | what to do |
|---|---|---|
| 400 | bad request (missing url/key, empty file, message with neither text nor media) | fix the request shape |
| 401 | missing / invalid token | refresh auth |
| 403 | PUT Content-Length ≠ presigned size (or wrong Content-Type); or you don't own the object | re-presign with the exact size; PUT a Blob |
| 413 | declared size — or the stored bytes — exceed the per-type cap | compress / downscale; block in pre-flight |
| 415 | unsupported type (e.g. video/audio), or the bytes don't match the declared type | only offer allowed types; send accurate metadata |
| 404 | MEDIA_NOT_FOUND — the key/url has no object behind it (PUT never happened or failed; or a blocked photo the server deleted) | re-presign and re-upload; don't retry the same reference |
| 422 | MEDIA_BLOCKED — the image was checked and fails the content policy | Not retryable. Ask for a different photo. Never auto-retry. |
| 415 | MEDIA_UNDECODABLE — the bytes couldn't be decoded into an image at all | Not retryable. Ask for a different photo; never word it as a rejection. |
| 503 | MEDIA_CHECK_UNAVAILABLE — the safety check couldn't run | Retryable. Back off and retry the same upload. Do not say the photo was rejected. |
| 410 | onboarding session expired (onboarding presign only) | restart the session |
| 429 | rate limited (e.g. chat send 60/min) | back off |
11 — Checklist
- Mirror §2 in the app; keep it in sync with this doc (separate repos — nothing is imported).
- Offer only allowed types per surface — no video, no audio; files (pdf/docx/csv/xlsx) in chat only; everything else image-only.
- Pre-flight type +
kind-per-surface + size before presigning. - Presign with the exact
size; PUT a Blob soContent-Lengthmatches; never add auth headers to the PUT; never useFormDatafor it. - Reference correctly per surface: posts/activities/events →
{ type:"image", url }; chat →{ storageKey, kind, mimeType(for files), ... }. - Send accurate
contentType/mimeType— the server byte-verifies and rejects spoofed types (415). - Down-scale very large images (32 MP decode limit) before upload.
- Handle
413/415/403with clear messages; treat returnedurls as opaque + refetchable (don't persist them). - Call
POST /api/media/checkafter the PUTs and mark rejected thumbnails in the picker, instead of letting the user discover it when they tap Post. - On
422/503, readblocked[]/unavailable[]and drop only those items — keep the photos that passed. - Handle
422 MEDIA_BLOCKED(ask for a different photo, never retry),415 MEDIA_UNDECODABLE(different photo, never retry, never blame it) and503 MEDIA_CHECK_UNAVAILABLE(retry with backoff, never blame the photo) as separate cases — see §9b.