Media Caption API v1
Local File Uploads
Upload a local audio or video file directly to Media Caption storage and transcribe it with ElevenLabs.
Complete upload and transcription example
This Node.js example reads the file in bounded chunks, uploads each part directly to S3, completes the upload, handles a measured-duration credit pause, and retrieves the finished transcript. It requires Node.js 20+ and a TypeScript runner such as tsx. Save it as upload.ts and use the command shown below.
import { open, stat } from "node:fs/promises";
import { basename } from "node:path";
import { createInterface } from "node:readline/promises";
const API_ORIGIN = "https://api.mediacaption.io";
const API_KEY = process.env.MEDIACAPTION_API_KEY;
const filePath = process.argv[2];
const estimatedDurationSec = Number.parseInt(process.argv[3] ?? "", 10);
const contentType = process.argv[4] ?? "video/mp4";
if (
!API_KEY ||
!filePath ||
!Number.isInteger(estimatedDurationSec) ||
estimatedDurationSec <= 0
) {
throw new Error(
"Usage: MEDIACAPTION_API_KEY=mc_live_xxx npx tsx upload.ts <file> <estimated-duration-seconds> [content-type]",
);
}
function apiUrl(path: string) {
return new URL(path, API_ORIGIN).toString();
}
async function apiRequest<T>(path: string, init: RequestInit = {}) {
const response = await fetch(apiUrl(path), {
...init,
headers: {
Authorization: "Bearer " + API_KEY,
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
const body = await response.json();
if (!response.ok) {
throw new Error(response.status + " " + JSON.stringify(body));
}
return body as T;
}
async function readPart(
file: Awaited<ReturnType<typeof open>>,
offset: number,
length: number,
) {
const buffer = Buffer.allocUnsafe(length);
let filled = 0;
while (filled < length) {
const { bytesRead } = await file.read(
buffer,
filled,
length - filled,
offset + filled,
);
if (bytesRead === 0) throw new Error("Unexpected end of file");
filled += bytesRead;
}
return buffer;
}
type UploadCreated = {
id: string;
minPartSizeBytes: number;
partUrl: string;
completeUrl: string;
statusUrl: string;
};
type UploadStatus = {
status:
| "initiated"
| "uploading"
| "uploaded"
| "processing"
| "awaiting_credits"
| "completed"
| "failed"
| "aborted";
error?: string | null;
transcriptionUrl?: string;
};
const fileInfo = await stat(filePath);
if (fileInfo.size > 3_000_000_000) {
throw new Error("The file exceeds the 3 GB limit");
}
// 1. Reserve credits and create the multipart upload.
const upload = await apiRequest<UploadCreated>("/v1/uploads", {
method: "POST",
body: JSON.stringify({
filename: basename(filePath),
contentType,
sizeBytes: fileInfo.size,
durationSec: estimatedDurationSec,
}),
});
const uploadedParts: Array<{ partNumber: number; etag: string }> = [];
let submittedForTranscription = false;
try {
// Every non-final part must be at least minPartSizeBytes. This example uses
// 16 MiB and requests each 15-minute signed URL immediately before using it.
const partSize = Math.max(upload.minPartSizeBytes, 16 * 1024 * 1024);
const partCount = Math.ceil(fileInfo.size / partSize);
const file = await open(filePath, "r");
try {
for (let index = 0; index < partCount; index += 1) {
const partNumber = index + 1;
const offset = index * partSize;
const length = Math.min(partSize, fileInfo.size - offset);
const part = await readPart(file, offset, length);
// 2. Get a signed URL for this part from Media Caption.
const signed = await apiRequest<{
partNumber: number;
uploadUrl: string;
}>(upload.partUrl, {
method: "POST",
body: JSON.stringify({ partNumber }),
});
// 3. Upload directly to S3. Do not send the Media Caption API key here.
const partResponse = await fetch(signed.uploadUrl, {
method: "PUT",
headers: { "Content-Length": String(length) },
body: part,
});
if (!partResponse.ok) {
throw new Error("Part " + partNumber + " failed: " + partResponse.status);
}
const etag = partResponse.headers.get("etag");
if (!etag) throw new Error("S3 did not return an ETag");
uploadedParts.push({ partNumber, etag });
}
} finally {
await file.close();
}
// 4. Complete the multipart upload and start ElevenLabs transcription.
await apiRequest(upload.completeUrl, {
method: "POST",
body: JSON.stringify({ parts: uploadedParts }),
});
submittedForTranscription = true;
} catch (error) {
// Cancellation aborts the multipart upload and refunds reserved credits.
if (!submittedForTranscription) {
await fetch(apiUrl(upload.statusUrl), {
method: "DELETE",
headers: { Authorization: "Bearer " + API_KEY },
});
}
throw error;
}
// 5. Poll until the transcript is ready. If the measured duration costs more
// than the estimate, top up the account and resume the same upload.
for (;;) {
const status = await apiRequest<UploadStatus>(upload.statusUrl);
if (status.status === "awaiting_credits") {
const prompt = createInterface({ input: process.stdin, output: process.stdout });
await prompt.question(
"Top up credits in the dashboard, then press Enter to resume...",
);
prompt.close();
await apiRequest(upload.statusUrl + "/resume", { method: "POST" });
} else if (status.status === "completed") {
if (!status.transcriptionUrl) {
throw new Error("Completed upload did not return a transcription URL");
}
const transcription = await apiRequest(status.transcriptionUrl);
console.log(JSON.stringify(transcription, null, 2));
break;
} else if (status.status === "failed" || status.status === "aborted") {
throw new Error(status.error ?? "Upload " + status.status);
}
await new Promise((resolve) => setTimeout(resolve, 2_000));
}MEDIACAPTION_API_KEY=mc_live_xxx npx tsx upload.ts ./meeting.mp4 600 video/mp41. Create the upload
/v1/uploads- Send
filename, anaudio/*orvideo/*content type, the exactsizeBytes, and an estimateddurationSec. - The API verifies the 3 GB limit and reserves estimated transcription credits before creating an S3 multipart upload.
- Save the returned
id,partUrl,completeUrl, andstatusUrl.
2. Upload the parts
/v1/uploads/{id}/parts- Split the file into numbered parts. Every part except the last must be at least the returned
minPartSizeBytes. - Request a fresh signed URL immediately before uploading each part; each URL expires after 15 minutes.
- Send the bytes to
uploadUrlwith an HTTPPUT. Do not attach your Media Caption API key to the S3 request. - Record the
ETagresponse header for every part. Part uploads may run sequentially or in bounded parallel batches.
3. Complete and start transcription
/v1/uploads/{id}/completeSubmit every partNumber and etag in ascending order. The API completes the S3 multipart upload, verifies the actual object size against the declared size and 3 GB limit, and queues preparation and ElevenLabs transcription.
4. Poll, top up, and retrieve
/v1/uploads/{id}- Poll until
statusiscompleted, then fetch the returnedtranscriptionUrl. - The server independently measures the real duration and reconciles it with the estimate. If the status becomes
awaiting_credits, top up the account and callPOST /v1/uploads/{id}/resume. The existing S3 object is reused; do not upload the file again. - Treat
failedandabortedas terminal states.
Cancel before processing
/v1/uploads/{id}Cancel an initiated or uploading session to abort its multipart upload and refund reserved credits. Processing, completed, and failed uploads can no longer be cancelled.
Storage and cleanup
Incomplete multipart uploads are cleaned up after one day. A successfully uploaded source may remain in Media Caption S3 for up to 30 days. The public transcript response is available for 3 days.