Cut.ProDocs
Guides

Transcribing audio and video

From speech to SRT, with no clipping and no rendering

Transcription stands apart from the rest of the API: no submission, no edit, no render. You send an audio or video file and get the text back with timings, as JSON or as a ready-made subtitle file.

Plain audio is accepted. A podcast in .mp3 does not have to become a video first.

Send the file

POST /transcriptions/upload returns a media_id and a signed URL. file_size is required: it is what decides whether the file fits your plan and the storage you have left.

curl -X POST https://api.cut.pro/api/v1/transcriptions/upload \
  -H "X-Api-Key: $CUTPRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_name": "episode-42.mp3",
    "file_size": 48210332,
    "content_type": "audio/mpeg"
  }'
Response
{
  "media_id": "7412908365112823",
  "kind": "audio",
  "upload_url": "https://storage.cut.pro/...",
  "expires_in": 3600
}

Then PUT the bytes to upload_url, with the same Content-Type you declared. Do not send your key on that PUT: the URL is already signed.

curl -X PUT "UPLOAD_URL" \
  -H "Content-Type: audio/mpeg" \
  --data-binary @episode-42.mp3

Start the job

POST /transcriptions charges per minute of audio, plus a surcharge when you ask who is speaking.

curl -X POST https://api.cut.pro/api/v1/transcriptions \
  -H "X-Api-Key: $CUTPRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "media_id": "7412908365112823",
    "file_name": "episode-42.mp3",
    "file_size": 48210332,
    "speaker_labels": true,
    "source_language": "en"
  }'
202 response
{
  "id": "7412908372001144",
  "status": "processing",
  "credits_charged": 62
}

On short audio, automatic language detection routinely mistakes Portuguese for Spanish. If you already know the language, send source_language.

A 200 instead of a 202 means nothing had to run: either that same audio was already transcribed (the text is shared per media, so a repeat is a cache hit and credits_charged comes back 0), or nobody is talking, and then status is no_speech and there is no charge.

Follow it

curl https://api.cut.pro/api/v1/transcriptions/7412908372001144 \
  -H "X-Api-Key: $CUTPRO_API_KEY"

status goes from processing to ready, no_speech or failed. Poll every 5 to 10 seconds.

Take the text

Two outputs, from the same job.

Timed JSON, to index, search or draw yourself:

curl https://api.cut.pro/api/v1/transcriptions/7412908372001144/transcript \
  -H "X-Api-Key: $CUTPRO_API_KEY"
Response
{
  "language": "en",
  "speaker_labels": true,
  "cues": [
    { "start": 12.4, "end": 15.9, "text": "We started out wrong, and it took two years to notice.", "speaker": "A" },
    { "start": 16.1, "end": 18.7, "text": "Wrong how?", "speaker": "B" }
  ]
}

A ready-made subtitle file, in srt (default), vtt or txt. The body is the file itself, not JSON:

curl "https://api.cut.pro/api/v1/transcriptions/7412908372001144/download?format=srt" \
  -H "X-Api-Key: $CUTPRO_API_KEY" \
  -o episode-42.srt

While the job is unfinished, both routes answer 409 TRANSCRIPTION_NOT_READY. That is not a wrong id: just keep polling.

Who is speaking

With speaker_labels: true, every cue carries a label (A, B, ...) and the subtitle file writes the name before the line. Pass speakers=false on the download if you want the same text without the labels.

The speaker column cannot be added after the fact. If an audio was transcribed without speaker_labels and you changed your mind, call POST /transcriptions again with the same media_id and speaker_labels: true: it reprocesses and charges again.

The whole flow

import { readFile, stat } from "node:fs/promises";

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

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 size = (await stat(FILE)).size;
const upload = await call("/transcriptions/upload", {
	method: "POST",
	body: JSON.stringify({ file_name: FILE, file_size: size, content_type: TYPE }),
});

await fetch(upload.upload_url, { method: "PUT", headers: { "Content-Type": TYPE }, body: await readFile(FILE) });

let job = await call("/transcriptions", {
	method: "POST",
	body: JSON.stringify({ media_id: upload.media_id, file_name: FILE, file_size: size, source_language: "en" }),
});

while (job.status === "processing") {
	await new Promise((resolve) => setTimeout(resolve, 10_000));
	job = await call(`/transcriptions/${job.id}`);
}

if (job.status !== "ready") throw new Error(job.status);

const { cues } = await call(`/transcriptions/${job.id}/transcript`);
console.log(cues.map((cue) => cue.text).join(" "));

Deleting

DELETE /transcriptions/{transcriptionId} takes the job out of the listings. The uploaded media stays in your library, so the same media_id can be transcribed again, and that repeat is a cache hit, not a second charge.

On this page