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.
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).
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.
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" }
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 |
| 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).