Sending files
Send local audio to the API: one request up to 100 MB, or resumable chunks of up to 16 MiB for files up to 2 GiB, then transcribe by uploadId.
If the audio is not on a public URL, send the file to the API and transcribe it
by uploadId. There are two paths, and the first one covers almost everyone.
Simple path: the file in one request
POST /v1/uploads with the file as the raw body. No JSON, no multipart, no
Content-Type required. One request carries up to 100 MB, which covers most
audio; above that, use the chunked path:
UPLOAD_ID=$(curl -s -X POST https://api.transcrevo.com/v1/uploads \
-H "Authorization: Bearer $TRANSCREVO_API_KEY" \
--data-binary @meeting.mp3 | jq -r .data.id){
"data": {
"id": "0f3c2d11-8a4b-4c6e-b2d9-5e7f1a9c3b42",
"receivedBytes": 48291840,
"expectedBytes": null,
"sha256": null,
"createdAt": "2026-08-24T18:00:00.000Z"
}
}With the id in hand, create the transcript:
curl https://api.transcrevo.com/v1/transcripts \
-H "Authorization: Bearer $TRANSCREVO_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"uploadId\": \"$UPLOAD_ID\", \"speakers\": true }"From here the flow is the same as transcribing from a URL:
poll GET /v1/transcripts/{id} until it finishes.
Full example (Node.js)
import { openAsBlob } from "node:fs";
const API = "https://api.transcrevo.com";
const headers = { Authorization: `Bearer ${process.env.TRANSCREVO_API_KEY}` };
const { data: upload } = await (
await fetch(`${API}/v1/uploads`, { method: "POST", headers, body: await openAsBlob("meeting.mp3") })
).json();
const { data: transcript } = await (
await fetch(`${API}/v1/transcripts`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ uploadId: upload.id }),
})
).json();
console.log(transcript.id, transcript.status);Declare the file: expectedBytes and sha256
Both are optional, both go in the query string when the upload is created, and both exist for the same reason: to close the window where an incomplete send becomes a transcript.
BYTES=$(stat -c %s meeting.mp3)
SHA=$(sha256sum meeting.mp3 | cut -d" " -f1)
curl -X POST "https://api.transcrevo.com/v1/uploads?expectedBytes=$BYTES&sha256=$SHA" \
-H "Authorization: Bearer $TRANSCREVO_API_KEY" \
--data-binary @meeting.mp3| Parameter | What it changes |
|---|---|
expectedBytes | Size of the file. While what arrived does not match, the upload cannot be transcribed: 400 upload_mismatch. |
sha256 | Digest of the file, lowercase hex. Checked against the stored audio before processing. |
Without expectedBytes, a chunk lost in the middle of 2 GiB is accepted in
silence: the transcript comes back done, plausible, charged, covering only the
part that arrived, and there is no way to tell without counting bytes yourself.
With it, the same case is an explicit error at transcription time.
sha256 closes the other side (wrong or corrupted file) and is checked at
dispatch, where the bytes already pass through in the clear: if it does not
match, the transcript ends failed with error.code audio_mismatch, and is
not charged. It is also what identifies the content for
reuseIfIdentical, which keeps you from paying twice
for the same audio.
Resumable path: a big file in chunks
File over 100 MB, or a link that may drop? Send it in pieces (up to 2 GiB total) and resume where you stopped.
POST /v1/uploadswith no body creates the empty upload and returns theid.PUT /v1/uploads/{id}appends each chunk (raw body, in order, up to 16 MiB).POST /v1/transcriptswithuploadIdstarts the transcription.
UPLOAD_ID=$(curl -s -X POST https://api.transcrevo.com/v1/uploads \
-H "Authorization: Bearer $TRANSCREVO_API_KEY" | jq -r .data.id)
split -b 8m meeting.mp3 chunk_
for f in chunk_*; do
curl -X PUT "https://api.transcrevo.com/v1/uploads/$UPLOAD_ID" \
-H "Authorization: Bearer $TRANSCREVO_API_KEY" \
--data-binary @"$f"
doneEach response returns the total received so far:
{ "data": { "id": "0f3c2d11-…", "receivedBytes": 16777216, "expectedBytes": 109051904, "sha256": "9f86d0…", "createdAt": "…" } }If you declared expectedBytes, a repeated or out-of-order chunk, which would
push the total past the size of the file, is refused with 400 upload_mismatch
instead of becoming a splice.
Chunks must go in order, one at a time: the server appends each one to the
end of the file. Parallel writes to the same upload are refused with
409 upload_busy.
Resuming an interrupted upload
Connection dropped? Ask how many bytes the server already has and continue from the right place:
curl "https://api.transcrevo.com/v1/uploads/$UPLOAD_ID" \
-H "Authorization: Bearer $TRANSCREVO_API_KEY"{ "data": { "id": "0f3c2d11-…", "receivedBytes": 25165824, "expectedBytes": 109051904, "sha256": "9f86d0…", "createdAt": "…" } }Resume from byte receivedBytes of the original file.
Full example (Node.js)
The loop below is the whole path: declare the file, send it in pieces, and on every failure ask how much arrived and continue from there. This is where people in a hurry get it wrong, so it is worth copying as is.
import { createHash } from "node:crypto";
import { open, stat } from "node:fs/promises";
const API = "https://api.transcrevo.com";
const headers = { Authorization: `Bearer ${process.env.TRANSCREVO_API_KEY}` };
const CHUNK = 8 * 1024 * 1024;
const path = "meeting.mp3";
const { size } = await stat(path);
const file = await open(path);
const hash = createHash("sha256");
for await (const chunk of file.createReadStream({ autoClose: false })) hash.update(chunk);
const sha256 = hash.digest("hex");
const created = await fetch(`${API}/v1/uploads?expectedBytes=${size}&sha256=${sha256}`, { method: "POST", headers });
const { data: upload } = await created.json();
let sent = 0;
while (sent < size) {
const buffer = Buffer.alloc(Math.min(CHUNK, size - sent));
await file.read(buffer, 0, buffer.length, sent);
try {
const res = await fetch(`${API}/v1/uploads/${upload.id}`, { method: "PUT", headers, body: buffer });
if (!res.ok) throw new Error((await res.json()).error.code);
// The response is where to continue from; do not trust your own counter.
({ data: { receivedBytes: sent } } = await res.json());
} catch (error) {
// It dropped mid-chunk: ask what actually arrived and resume from there.
const state = await fetch(`${API}/v1/uploads/${upload.id}`, { headers });
if (!state.ok) throw error;
({ data: { receivedBytes: sent } } = await state.json());
}
}
await file.close();
const res = await fetch(`${API}/v1/transcripts`, {
method: "POST",
// The idempotency key is the uploadId itself: it identifies THIS body. Keying
// by your own media id would give 409 on every retry, because each retry
// uploads the file again and sends a different uploadId.
headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": upload.id },
body: JSON.stringify({ uploadId: upload.id, speakers: true, reuseIfIdentical: true }),
});
const { data: transcript } = await res.json();
console.log(transcript.id, transcript.status);Limits and errors
| Situation | Response |
|---|---|
| File (or upload) would pass 2 GiB | 413 upload_too_large |
| A single request over 100 MB | 413 from the edge, before the API |
| Chunk over 16 MiB | 413 chunk_too_large |
| Empty chunk | 400 chunk_empty |
| Two chunks in parallel | 409 upload_busy |
| Unknown upload (or another account) | 404 upload_not_found |
| Transcribing an upload with 0 bytes | 400 validation (upload_empty) |
Received differs from expectedBytes | 400 upload_mismatch |
Stored audio does not match sha256 | transcript failed (audio_mismatch), not charged |
Transcripts
Create and read transcripts over the API: URL or uploadId, language, speaker separation, glossary, webhook, and the response format with timestamps.
Pricing & limits
US$ 0.03 per hour of audio, US$ 0.05 with speaker separation, billed by the second. Prepaid credits, no subscription, and US$ 2 on a new account.