MeetyyAPI
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
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/posts with video_upload_id.
  • Community video post: POST /api/v1/mentee/communities/{community}/posts with video_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 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.