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
- Test a Sume poll loop with node:test mock.timers, no real sleeping
Use node:test mock.timers to prove your poll loop waits next_poll_after_seconds, falls back to 2 s, and never polls early. A runnable test, no network.
- One length gate for six ad platforms: caps table and Sume plan
Reels 15 min, Stories 60 min, TikTok 10 min, LinkedIn 30 min, Pinterest 5 min, Snap 180 s. Check one Sume Timeline length against all six with the plan call.
- openapi-fetch with Sume's OpenAPI JSON: a typed client in 20 lines
Generate types from Sume's reference JSON with openapi-typescript, then call it with openapi-fetch and an x-api-key middleware. A type-checked sample.
- OpenRouter: poll video every 30 s. Sume: obey next_poll_after_seconds
OpenRouter recommends a 30 second poll on video. Sume's jobs API returns next_poll_after_seconds, else backoff. A Python poller that does both.
Written by Sume