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/jsonforinit/complete;multipart/form-datafor 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_videobacksPOST /feed/reelsβ see Reels API. Only mentors can open one (403otherwise). 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_imagewhatever 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: onecourse_material_videofor the recording, onecourse_material_imagefor the poster.
course_lesson_videobacks an uploaded video on avideolesson β see Course lesson video upload. It is separate fromcourse_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/postswithvideo_upload_id. - Community video post:
POST /api/v1/mentee/communities/{community}/postswithvideo_upload_id. - Course lesson video:
POST|PUT /api/v1/mentor/courses/{course}/modules/{module}/lessons[/{lesson}]withvideo_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
thumbnailfield on the post-create request β not on the upload endpoints. If it is omitted,video.thumbnailcomes backnulland the player falls back to the video's own first frame. - Reuse. New features can accept large files by adding a
purposeentry inconfig/upload.phpand callingChunkedUploadService::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 localFileviaURL.createObjectURL, so extraction always works. iOS Safari needsvideo.muted = trueand oftenplaysInlinebefore it will decode a frame.