Skip to main content
Version: Latest

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 the onboardingToken instead (see §4)
  • Header: X-Client-Type: mobile on every API call

⚠️ Separate repos — nothing is shared as a package. The API's limits live in its @ksn/validators package; 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 returns 400 MEDIA_KEY_REJECTED unless the avatarUrl is the publicUrl you 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/presign upload and is content-checked (422 MEDIA_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/check now deletes a blocked photo: reusing its URL returns 422 MEDIA_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_UNDECODABLE means 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/check can now answer undecodable per 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/check with the urls you just PUT returns ok / blocked / unavailable per 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's 422/503 body now also tells you exactly which items failed (blocked and unavailable arrays, each with index and the url you sent). New 404 MEDIA_NOT_FOUND if 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_BLOCKED means the photo failed the check — ask for a different one, retrying won't help. 503 MEDIA_CHECK_UNAVAILABLE means 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:

  1. Presign — rejects a disallowed content-type (e.g. any video) with 415, a size over the cap with 413, and bakes your declared size into the signed URL as Content-Length. Your PUT body must be exactly that many bytes or storage returns 403.
  2. 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 rejected 415. The declared type is never trusted — don't try to sneak a type past it; send accurate metadata.
  3. 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​

kindcontent typesmax size
imageimage/jpeg, image/png, image/webp10 MB
imageimage/heic, image/heif (+ -sequence) — iOS photos15 MB
gifimage/gif15 MB
fileapplication/pdf, .docx, .xlsx, text/csv25 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​

SurfacePresign endpointAllowed kinds
PostsPOST /api/media/presignimage only
ChatPOST /api/media/presignimage, gif, file
Activities / AchievementsPOST /api/media/presignimage only
EventsPOST /api/media/presignimage only
Onboarding (avatar/act/ach)POST /api/onboarding/media-presignimage only
Profile avatarPOST /api/users/me/avatar/presignimage only

gif in chat is usually a Klipy picker gif sent as remoteUrl (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 }
]
}
fieldrequirednotes
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-Length must equal size. Storage rejects any other length with 403. Most HTTP clients set it automatically from a known-size body — see the RN note in §7.
  • On success storage returns 200 with 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 the key, not a url; mimeType required 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).

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:

statusMeansDo
okSafe to use.Nothing.
blockedThis photo fails the content policy.Mark that thumbnail rejected, ask for a different one. Never retry it — the same bytes always fail.
undecodableThe 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.
unavailableThe 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 blocked answer here is the same verdict the create/send call would give you — this endpoint is not a second, looser check.
  • A blocked photo is deleted server-side. Reusing its URL in a create/send call returns 422 MEDIA_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 /check is 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 }] } where type is 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/png but 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 for kind: "file" the server relies on your declared mimeType to validate it — mimeType is required for file attachments.
  • HEIC/HEIF (iOS): a single photo can sniff as image/heic, image/heif, or a -sequence variant — all are accepted as images. Declare the actual contentType your 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, fetch sets Content-Length automatically when the body is a Blob (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 FormData to 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_BLOCKED415 MEDIA_UNDECODABLE503 MEDIA_CHECK_UNAVAILABLE
MeansWe checked the photo. It fails the policy.We couldn't open the file at all.We could not run the check at all.
RetryNever. 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>" }]
}
  • index is the position in the media array you sent, and is always present. url is present wherever you sent urls (posts, activities, achievements, events, /api/media/attach); on chat and onboarding, which send storage keys, you get index only.
  • 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 unavailable only.
  • On a PATCH that edits media (activities, achievements), index counts the photos you are adding — the ones not already on the entry — not your whole media array. Match on url, 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​

codemeaningwhat to do
400bad request (missing url/key, empty file, message with neither text nor media)fix the request shape
401missing / invalid tokenrefresh auth
403PUT Content-Length ≠ presigned size (or wrong Content-Type); or you don't own the objectre-presign with the exact size; PUT a Blob
413declared size — or the stored bytes — exceed the per-type capcompress / downscale; block in pre-flight
415unsupported type (e.g. video/audio), or the bytes don't match the declared typeonly offer allowed types; send accurate metadata
404MEDIA_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
422MEDIA_BLOCKED — the image was checked and fails the content policyNot retryable. Ask for a different photo. Never auto-retry.
415MEDIA_UNDECODABLE — the bytes couldn't be decoded into an image at allNot retryable. Ask for a different photo; never word it as a rejection.
503MEDIA_CHECK_UNAVAILABLE — the safety check couldn't runRetryable. Back off and retry the same upload. Do not say the photo was rejected.
410onboarding session expired (onboarding presign only)restart the session
429rate 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 so Content-Length matches; never add auth headers to the PUT; never use FormData for 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/403 with clear messages; treat returned urls as opaque + refetchable (don't persist them).
  • Call POST /api/media/check after the PUTs and mark rejected thumbnails in the picker, instead of letting the user discover it when they tap Post.
  • On 422/503, read blocked[] / 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) and 503 MEDIA_CHECK_UNAVAILABLE (retry with backoff, never blame the photo) as separate cases — see §9b.
⤓ Download .md