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 |
| 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 |
500 MB (configurable via VIDEO_MAX_SIZE) |
video/mp4, video/quicktime, video/webm |
community_video |
500 MB (configurable via VIDEO_MAX_SIZE) |
video/mp4, video/quicktime, video/webm |
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.
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.