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.


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
filenameIts extension sets the stored object's extension — pass a real one.
contentTypeMust be in the allowlist (§2). Video → 415.
sizeExact 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:

  • Postsmedia: [{ "type": "image", "url": "<publicUrl>", "width", "height" }] (image only). See Posts guide.
  • Chatmedia: [{ "storageKey": "<key>", "kind": "image|gif|file", "mimeType", ... }] (send the key, not a url; mimeType required for files). See Chat Media guide.
  • Activities / Achievements / Eventsmedia: [{ "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 }] } 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" }

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
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).
⤓ Download .md