API
Documentation / API Reference / Chunked Upload

Chunked Upload API

A reusable, resumable large-file upload surface. A client slices a big file (e.g. a video) into small chunks and drives an init β†’ chunk β†’ complete lifecycle. Once the file is assembled server-side, the returned upload_id is spent when creating the resource that needs it (a feed post, a community post, …).

  • Base URL: /api/v1
  • Auth: every endpoint requires a Sanctum bearer token β€” Authorization: Bearer {token}.
  • Content type: application/json for init/complete; multipart/form-data for chunk uploads.

Why chunked? Each request carries only a small slice, so uploads dodge web-server body-size caps and request timeouts, and a dropped connection resumes from the missing chunks instead of restarting the whole file.


Response envelope

Success { "status": "success", "message": "…", "data": { } } Error { "status": "error", "message": "…", "data": { } }

Status codes

Code Meaning
200 OK (chunk stored, status read, upload completed)
201 Upload session created (init)
401 Missing/invalid token
403 The session belongs to another user, or the purpose is limited to roles the caller does not hold (reel_video: mentors only)
404 Session not found
422 Validation error / invalid state (bad purpose, oversized/disallowed file, incomplete on complete)
500 Server error

Purposes

Every upload is opened for a purpose, which fixes its size ceiling and allowed mime types (server-side, in config/upload.php). Current purposes:

Purpose Max size Allowed mime types
feed_video 25 MB (configurable via VIDEO_MAX_SIZE) video/mp4, video/quicktime, video/webm
community_video 25 MB (configurable via VIDEO_MAX_SIZE) video/mp4, video/quicktime, video/webm
reel_video 100 MB (REEL_VIDEO_MAX_SIZE), 120 s (REEL_VIDEO_MAX_DURATION) video/mp4, video/quicktime, video/webm
course_lesson_video 2 GB (COURSE_LESSON_VIDEO_MAX_SIZE) video/mp4, video/quicktime, video/webm, video/x-matroska
course_material_video 2 GB (COURSE_MATERIAL_VIDEO_MAX_SIZE) video/mp4, video/quicktime, video/webm, video/x-matroska
course_material_image 15 MB (COURSE_MATERIAL_IMAGE_MAX_SIZE) image/jpeg, image/png, image/webp, image/gif, image/avif
course_material_file 512 MB (COURSE_MATERIAL_FILE_MAX_SIZE) PDF, ZIP, Office documents, text/plain, text/csv, audio/mpeg, audio/wav

reel_video backs POST /feed/reels β€” see Reels API. Only mentors can open one (403 otherwise). Its length cap is enforced when the reel is published, by reading the file with ffprobe, not at upload.

The three course_material_* purposes back Course API Β§36 β€” Materials. Course recordings get a much larger ceiling than feed clips because they are full session captures, which is also why materials have no multipart path at all: a 2 GB recording cannot go through a plain form post.

Poster frames use course_material_image whatever the material's own type is β€” a poster is an image even when the thing it previews is a 2 GB recording, and validating it as a video would be nonsense. So a video material with a still spends two sessions: one course_material_video for the recording, one course_material_image for the poster.

course_lesson_video backs an uploaded video on a video lesson β€” see Course lesson video upload. It is separate from course_material_video: a material session cannot be spent on a lesson, or the other way round.

A session opened for one purpose can only be spent on that purpose.


The upload session object

{
  "id": "9b1c…-uuid",
  "purpose": "feed_video",
  "original_filename": "clip.mp4",
  "mime_type": "video/mp4",
  "total_size": 15728640,
  "total_chunks": 3,
  "received_chunks": 1,
  "missing_indexes": [1, 2],
  "is_complete": false,
  "status": 1,
  "status_label": "Pending",
  "expires_at": "2026-07-19T13:00:00+00:00",
  "created_at": "2026-07-19T07:00:00+00:00"
}

status: 1 Pending Β· 2 Completed Β· 3 Failed Β· 4 Expired. An unfinished session expires after UPLOAD_SESSION_TTL_HOURS (default 6h) and is purged automatically.


Endpoints

1. Initialize a session

POST /api/v1/uploads/chunked

{
  "purpose": "feed_video",
  "original_filename": "clip.mp4",
  "mime_type": "video/mp4",
  "total_size": 15728640,
  "total_chunks": 3
}
Field Rules
purpose required, one of the configured purposes
original_filename required, string
mime_type required, must be allowed for the purpose
total_size required, integer bytes, ≀ the purpose cap
total_chunks required, integer β‰₯ 1

201 β†’ { "data": { "upload": { …session… } } }. Keep data.upload.id.

2. Upload a chunk

POST /api/v1/uploads/chunked/{id}/chunks β€” multipart/form-data

Field Rules
index required, integer, 0-based, 0 … total_chunks-1
chunk required, file, ≀ UPLOAD_CHUNK_MAX_SIZE (default 10 MB)

200 β†’ the updated session object. Chunks may be sent in any order and are idempotent β€” re-sending an index overwrites it, so retrying a failed chunk is safe.

3. Get status (resume)

GET /api/v1/uploads/chunked/{id}

200 β†’ the session object. After a dropped connection, read missing_indexes and re-send only those chunks.

4. Complete

POST /api/v1/uploads/chunked/{id}/complete

Server verifies every chunk is present, assembles them in order, and checks the assembled size matches total_size.

  • 200 β†’ session with is_complete: true, status_label: "Completed".
  • 422 β†’ still missing chunks, or the assembled size did not match.

5. Use the upload

Pass the completed upload_id to the resource endpoint. The file is moved onto that resource and the upload session is consumed (single use):

  • Feed video post: POST /api/v1/feed/posts with video_upload_id.
  • Community video post: POST /api/v1/mentee/communities/{community}/posts with video_upload_id.
  • Course lesson video: POST|PUT /api/v1/mentor/courses/{course}/modules/{module}/lessons[/{lesson}] with video_upload_id. Consumed by a queued job, not in the request β€” see course-lesson-video-upload-api.md.

See feed-api.md and community-api.md.


Reference client (browser)

const CHUNK_SIZE = 5 * 1024 * 1024; // 5 MB

async function uploadVideo(file, purpose, token) {
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
  const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };

  // 1. init
  const init = await fetch('/api/v1/uploads/chunked', {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      purpose,
      original_filename: file.name,
      mime_type: file.type,
      total_size: file.size,
      total_chunks: totalChunks,
    }),
  }).then((r) => r.json());

  const uploadId = init.data.upload.id;

  // 2. send each chunk (sequential, with retry)
  const send = async (index) => {
    const start = index * CHUNK_SIZE;
    const blob = file.slice(start, start + CHUNK_SIZE);
    const form = new FormData();
    form.append('index', index);
    form.append('chunk', blob, `${index}`);

    for (let attempt = 0; attempt < 3; attempt++) {
      const res = await fetch(`/api/v1/uploads/chunked/${uploadId}/chunks`, {
        method: 'POST', headers, body: form,
      });
      if (res.ok) return;
    }
    throw new Error(`Chunk ${index} failed`);
  };

  for (let i = 0; i < totalChunks; i++) {
    await send(i);
    // onProgress((i + 1) / totalChunks)
  }

  // 3. resume β€” re-send anything still missing
  const status = await fetch(`/api/v1/uploads/chunked/${uploadId}`, { headers })
    .then((r) => r.json());
  for (const index of status.data.upload.missing_indexes) {
    await send(index);
  }

  // 4. complete
  await fetch(`/api/v1/uploads/chunked/${uploadId}/complete`, { method: 'POST', headers });

  return uploadId; // hand this to the post-create call as video_upload_id
}

Notes

  • Video thumbnails are the client's job. The server does not decode video or derive a frame (no FFMPEG involved anywhere). Generate the thumbnail in the browser and send it as the thumbnail field on the post-create request β€” not on the upload endpoints. If it is omitted, video.thumbnail comes back null and the player falls back to the video's own first frame.
  • Reuse. New features can accept large files by adding a purpose entry in config/upload.php and calling ChunkedUploadService::takeCompletedFile() in their own service β€” no new upload endpoints needed.

Generating a video thumbnail (browser)

Run this after picking the file, then send the returned blob as thumbnail alongside video_upload_id when creating the post.

async function makeThumbnail(file, seekTo = 1) {
  const video = document.createElement('video');
  video.src = URL.createObjectURL(file);
  video.muted = true;

  await new Promise((res, rej) => {
    video.onloadedmetadata = res;
    video.onerror = rej;
  });

  // Clamp the seek point for very short clips.
  video.currentTime = Math.min(seekTo, (video.duration || 1) / 2);
  await new Promise((res) => { video.onseeked = res; });

  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);

  URL.revokeObjectURL(video.src);

  return new Promise((res) => canvas.toBlob(res, 'image/jpeg', 0.8));
}

// Usage β€” one multipart request creates the post:
const thumb = await makeThumbnail(file);
const form = new FormData();
form.append('body', caption);
form.append('video_upload_id', uploadId);
form.append('thumbnail', thumb, 'thumb.jpg');

await fetch('/api/v1/feed/posts', { method: 'POST', headers, body: form });

Note. canvas.toBlob() on a cross-origin video would taint the canvas, but here the source is a local File via URL.createObjectURL, so extraction always works. iOS Safari needs video.muted = true and often playsInline before it will decode a frame.