Cut.ProDocs
Guides

Recipes

The three flows that cover almost everything, ready to copy and run

The Quickstart shows each request on its own. These are the whole flows, the way they run in production. Copy one, swap the URL and the connection_id, run it.

1. From a YouTube link to a published post

The complete path with nobody in the loop: analyze, clip, pick the best cut, apply your template, render and publish to TikTok and Instagram.

The two steps that cost something: POST /clips spends credits, and publishing uses up the connected account's daily quota. Everything else is free.

const API = "https://api.cut.pro/api/v1";
const HEADERS = { "X-Api-Key": process.env.CUTPRO_API_KEY, "Content-Type": "application/json" };

async function call(path, init) {
	const response = await fetch(`${API}${path}`, { ...init, headers: HEADERS });
	const body = await response.json();
	if (!response.ok) throw new Error(`${response.status} ${body.code}`);
	return body;
}

const wait = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

/** Repeat `fn` until the state leaves the "still working" list. */
async function until(fn, working, everySeconds) {
	let state = await fn();
	while (working.includes(state.status)) {
		await wait(everySeconds);
		state = await fn();
	}
	return state;
}

const SOURCE = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
const TEMPLATE_ID = "7399000000000001";

// Each target carries the metadata of ITS OWN network: the inner keys are the
// platform's, not ours. Full list at /en/api-reference/postagem.
const targetsFor = (clip) => [
	{ connection_id: "7399001244553012", metadata: { tiktok: { title: clip.title, privacyLevel: "PUBLIC_TO_EVERYONE" } } },
	{ connection_id: "7399001244553099", metadata: { instagram: { caption: clip.title } } },
];

// 1. What it costs, before spending anything.
const video = await call("/clips/info", { method: "POST", body: JSON.stringify({ url: SOURCE }) });
console.log(`${video.title}: ${video.credits_cost} credits`);

// 2. Clip it. This is where the credits go.
const submission = await call("/clips", {
	method: "POST",
	body: JSON.stringify({ video_id: video.id, template_id: TEMPLATE_ID }),
});

const base = `/clips/${video.id}/submissions/${submission.id}`;
const done = await until(() => call(base), ["queued", "downloading", "transcribing", "video_analysis", "analyzing", "finalizing"], 15);
if (done.status === "failed") throw new Error(done.error_code);

// 3. The best cut by the AI score.
const { clips } = await call(`${base}/clips?sort=rating&order=desc&limit=1`);
const best = clips[0];

// 4. Render. `from_cache` means the MP4 already existed.
const render = await call(`${base}/clips/${best.id}/render`, { method: "POST" });
const finished = render.from_cache ? render : await until(() => call(`/renders/${render.id}`), ["queued", "active"], 10);
if (finished.status !== "completed") throw new Error(finished.status);

// 5. Publish the SAME edit to both accounts.
const post = await call("/posts", {
	method: "POST",
	body: JSON.stringify({ videos: [{ edit_id: render.edit_id, targets: targetsFor(best) }] }),
});

// 6. Follow it until every account answers.
const published = await until(() => call(`/posts/${post.id}`), ["pending", "processing"], 10);
for (const item of published.items) {
	console.log(item.platform, item.status, item.published_url ?? item.error_code);
}

A post can end up partial: one account published, another failed. Do not treat that as success. Walk the items and use POST /posts/{id}/items/{itemId}/retry on the ones that failed, without touching the ones already live.

2. A queue of several videos

Clipping a whole channel is the same flow, N times. What changes is that you do not fire everything at once: each submission in flight takes a slot in the queue, and polling all of them together is what gets close to the rate limit.

const SOURCES = ["https://youtu.be/aaa", "https://youtu.be/bbb", "https://youtu.be/ccc"];
const AT_A_TIME = 3;

async function clipOne(url) {
	const video = await call("/clips/info", { method: "POST", body: JSON.stringify({ url }) });
	const submission = await call("/clips", { method: "POST", body: JSON.stringify({ video_id: video.id }) });
	const base = `/clips/${video.id}/submissions/${submission.id}`;
	const done = await until(() => call(base), ["queued", "downloading", "transcribing", "video_analysis", "analyzing", "finalizing"], 15);
	if (done.status === "failed") return { url, error: done.error_code };
	const { clips } = await call(`${base}/clips`);
	return { url, clips };
}

// A conveyor with N workers pulling from the same list: never more than N in flight.
const queue = [...SOURCES];
const results = await Promise.all(
	Array.from({ length: AT_A_TIME }, async () => {
		const mine = [];
		while (queue.length > 0) mine.push(await clipOne(queue.shift()));
		return mine;
	}),
);

console.log(results.flat());

Before firing the batch, GET /balance tells you whether the balance covers it, and GET /renders/limits tells you how many simultaneous renders your plan allows. Going over the second one gets you a 429 on the render, not on the clipping.

When a submission sits at downloading

It is not stuck. If the source does not fully exist yet, the waiting_reason field explains it and the job resumes on its own:

waiting_reasonWhat is happening
SOURCE_PREMIERE_SCHEDULEDA scheduled premiere that has not aired. waiting_until carries the announced time
SOURCE_LIVE_IN_PROGRESSThe stream is still on air
SOURCE_VOD_PROCESSINGThe broadcast ended, the platform is still publishing the recording

Show that to your user instead of a generic "processing". If they would rather not wait, DELETE /clips/{videoId}/submissions/{submissionId} refunds the credits while waiting_reason is set.

3. A fresh MP4 from an old clip

The rendered file expires according to your plan (GET /renders/limits carries the render_expiry_hours). The edit never expires. Keep the edit_id and you can produce a new file whenever you like, with no re-clipping and no second charge.

curl -X POST https://api.cut.pro/api/v1/renders \
  -H "X-Api-Key: $CUTPRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "edit_id": "7412908490112775" }'
202 response
{
  "id": "7412908490998001",
  "edit_id": "7412908490112775",
  "status": "queued",
  "output_resolution": "1080p",
  "has_watermark": false,
  "from_cache": false,
  "download_url": null
}

If an identical file is still in storage, the answer is 200 with from_cache: true and the download_url already filled in: nothing was rendered again.

This is why it pays to keep the edit_id of every post in your database. A post item whose render_id turned null lost the file, not the edit: POST /renders with its edit_id brings the video back.

On this page