Node script for a 9:16 TikTok video: check the model, then submit
A Node 20 fetch script that confirms a Sume model lists 9:16 and your duration, submits one 12-second 720p job, and saves an MP4 that fits TikTok's API limits.

To make a 9:16 TikTok video from Node, ask GET /v1/videos/models whether your chosen model lists 9:16 and your duration, then POST /v1/videos, poll the job, and save the file. The script below does that with Node 20's built-in fetch and no packages, and it fails early with a clear message instead of burning a paid job on an unsupported request.
The targets come from TikTok's Media Transfer Guide (read 2026-10-10) for the Content Posting API: MP4 is recommended, with WebM and MOV also accepted; H.264 is the recommended codec; frame rate must be between 23 and 60 FPS; each side must be between 360 and 4096 pixels; the file can be up to 4 GB. On length it says all TikTok creators can post 3-minute videos, while some can post 5 or 10 minutes, and the API handles videos up to 10 minutes.
The script
Save it as make.mjs (the .mjs extension lets Node run the top-level await) and run node make.mjs with SUME_API_KEY set in the environment. It targets Seedance 2.5, which the Sume docs list at 4 to 30 seconds and 480p/720p/1080p.
import { writeFile } from "node:fs/promises";
const API = "https://api.sume.com";
const headers = {
Authorization: `Bearer ${process.env.SUME_API_KEY}`,
"Content-Type": "application/json",
};
async function call(path, init = {}) {
const res = await fetch(API + path, { headers, ...init });
if (!res.ok) throw new Error(`${path} -> ${res.status} ${await res.text()}`);
return res;
}
const rel = (u) => { const x = new URL(u); return x.pathname + x.search; };
const { data } = await (await call("/v1/videos/models")).json();
const m = data.find((x) => x.id === "seedance-2.5");
if (!m?.supported_aspect_ratios.includes("9:16") || !m.supported_durations.includes(12))
throw new Error("model lacks 9:16 or 12 s");
const body = { model: m.id, aspect_ratio: "9:16", resolution: "720p",
duration: 12, prompt: "Hands unwrap a ceramic mug on a wooden desk, natural light" };
let job = await (await call("/v1/videos", { method: "POST",
headers: { ...headers, "Idempotency-Key": "tiktok-mug-001" },
body: JSON.stringify(body) })).json();
while (!["completed", "failed", "cancelled"].includes(job.status)) {
await new Promise((r) => setTimeout(r, 15000));
job = await (await call(rel(job.polling_url))).json();
}
if (job.status !== "completed") throw new Error(job.error ?? job.status);
const file = await call(rel(job.unsigned_urls[0]));
await writeFile("tiktok-mug.mp4", Buffer.from(await file.arrayBuffer()));
console.log("saved, cost", job.usage?.cost);What the preflight buys you
Limits differ by model. The Video Generation page tells you to read the models endpoint before submitting, because supported resolutions, aspect ratios and durations are per model. The script reads exactly those fields (supported_aspect_ratios, supported_durations), so a change in the catalog stops the run before any money is reserved.
The preflight also keeps the spend predictable. Per the same page, Sume reserves the provider list price times 1.25 when you submit, and the finished job reports usage.cost as the billable amount, which the script prints. Run a 4-second job first if you are testing the plumbing; the model's range starts at 4 seconds, so that is the shortest valid request. Change the 12 in both the check and the body together.
The submit uses an Idempotency-Key, which the docs say makes retries safe: a replay returns the original job. That matters in a loop that might restart after a network drop.
| TikTok limit | Script request | Fits? |
|---|---|---|
| MP4 recommended | Sume returns the file at the content URL | Check the container with a probe |
| 23 to 60 FPS | Not set by the request | Probe it; the Sume docs give no frame rate |
| 360 to 4096 px per side | 720p at 9:16 | Yes, nominally 720 by 1280 |
| Up to 4 GB | One 12-second clip | Far below the cap |
| 3 minutes for all creators | 12 seconds | Yes |
Where the guarantees stop
Two rows in that table are not proven by the request. The Sume pages describe the model's resolution tier and duration range, not the frame rate or the exact pixel size of the generated file, and TikTok enforces both on upload. Probe the saved MP4 with a tool you trust, or with Sume's Video inspect once the file is imported, before it goes to the Content Posting API.
Also remember that the 12-second request is a building block. If you want a longer TikTok, the same preflight pattern works for each clip: loop over a shot list, check each, submit with distinct idempotency keys, and join the results afterwards.
Common failures
A 400 on the submit usually means the body carries a field Sume rejects on this route. The docs state that size, seed and a non-empty provider.options return an error on v1, so keep the body to model, prompt, aspect_ratio, resolution and duration. A failed job carries an error field, which the script throws. A job that stays pending is not an error: the docs say generation can take several minutes depending on the model, resolution and load, so keep polling at a moderate interval.
Sources
Related posts
More in Developers
- Revoked a Sume API key and it still works? Up to 15 seconds
After you revoke a Sume API key, the API can keep accepting it for up to 15 seconds by default, because the key lookup is cached. What that means for a leak.
- Sume public_reason: generation_rejected vs temporary error
How Sume picks generation_rejected, temporary_generation_error or generation_failed on a failed job from the provider HTTP status and the retryable flag.
- Sume "Generation could not start": rejected request or outage
Generation could not start is the fallback when a provider rejects a submit. A 4xx gives generation_rejected_request; anything else gives submit_failed.
- Sume job error details.input_field: the parameter that failed
When a provider rejects one request field, Sume publishes its name as details.input_field beside provider_error_message. Map it back to your body and fix it.
Written by Sume