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/mp4

1. Create the upload

POST/v1/uploads
  • Send filename, an audio/* or video/* content type, the exact sizeBytes, and an estimated durationSec.
  • The API verifies the 3 GB limit and reserves estimated transcription credits before creating an S3 multipart upload.
  • Save the returned id, partUrl, completeUrl, and statusUrl.

2. Upload the parts

POST/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 uploadUrl with an HTTP PUT. Do not attach your Media Caption API key to the S3 request.
  • Record the ETag response header for every part. Part uploads may run sequentially or in bounded parallel batches.

3. Complete and start transcription

POST/v1/uploads/{id}/complete

Submit 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

GET/v1/uploads/{id}
  • Poll until status is completed, then fetch the returned transcriptionUrl.
  • The server independently measures the real duration and reconciles it with the estimate. If the status becomes awaiting_credits, top up the account and call POST /v1/uploads/{id}/resume. The existing S3 object is reused; do not upload the file again.
  • Treat failed and aborted as terminal states.

Cancel before processing

DELETE/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.

Next: Jobs