Developers
txscribe API
Create transcriptions from links or uploaded files, read transcripts and summaries, and get a signed webhook when they finish.
The API is the app underneath: the same transcription, the same checks and the same limits. Everything you create through it appears in your library, and it uses your account's minutes exactly as an upload in the app does — there is no separate API allowance or price.
Base URL https://txscribe.ai/api/v1. The full description is OpenAPI 3.1 JSON.
Authentication
Make a key under Plan and billing → Developer API. It starts txs_live_ and is shown once: we keep only a hash of it, so a lost key cannot be shown again — revoke it and make another. An account can hold 5 keys.
Send it on every request as Authorization: Bearer txs_live_…. The API does not accept a signed-in browser session, and a key does not sign anyone in to the app.
Quick start
curl https://txscribe.ai/api/v1/transcriptions \
-H "Authorization: Bearer $TXSCRIBE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"url": "https://www.youtube.com/watch?v=VIDEO_ID", "language": "auto"}'# 1. Create the transcription. The answer carries a one-time upload URL.
curl https://txscribe.ai/api/v1/transcriptions \
-H "Authorization: Bearer $TXSCRIBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"fileName": "interview.m4a", "contentType": "audio/mp4", "size": 18234567}'
# 2. PUT the whole file to upload.url (no Authorization header).
curl -X PUT --upload-file interview.m4a -H "Content-Type: audio/mp4" "$UPLOAD_URL"
# 3. Start transcribing.
curl -X POST https://txscribe.ai/api/v1/transcriptions/$ID/start \
-H "Authorization: Bearer $TXSCRIBE_API_KEY"
# 4. When status is "done" (or your webhook says so), fetch the transcript.
curl "https://txscribe.ai/api/v1/transcriptions/$ID/transcript?format=srt" \
-H "Authorization: Bearer $TXSCRIBE_API_KEY" -o interview.srtconst API = "https://txscribe.ai/api/v1";
const headers = { Authorization: `Bearer ${process.env.TXSCRIBE_API_KEY}`, "Content-Type": "application/json" };
async function call(path, init = {}) {
const response = await fetch(API + path, { ...init, headers: { ...headers, ...init.headers } });
if (!response.ok) {
const { error } = await response.json();
throw new Error(`${error.code}: ${error.message} (request ${error.requestId})`);
}
return response;
}
const { transcription } = await (
await call("/transcriptions", {
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ url: "https://www.youtube.com/watch?v=VIDEO_ID" }),
})
).json();
// Prefer a webhook; polling works too.
let current = transcription;
while (!["done", "error"].includes(current.status)) {
await new Promise((resolve) => setTimeout(resolve, 15000));
current = await (await call(`/transcriptions/${transcription.id}`)).json();
}
if (current.status === "done") {
console.log(await (await call(`/transcriptions/${current.id}/transcript?format=txt`)).text());
}import os, time, uuid, requests
API = "https://txscribe.ai/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TXSCRIBE_API_KEY']}"
created = session.post(
f"{API}/transcriptions",
json={"url": "https://www.youtube.com/watch?v=VIDEO_ID"},
headers={"Idempotency-Key": str(uuid.uuid4())},
)
created.raise_for_status()
transcription = created.json()["transcription"]
while transcription["status"] not in ("done", "error"):
time.sleep(15)
transcription = session.get(f"{API}/transcriptions/{transcription['id']}").json()
if transcription["status"] == "done":
print(session.get(f"{API}/transcriptions/{transcription['id']}/transcript", params={"format": "txt"}).text)Endpoints
POST /transcriptions
Create one from a url, or for a file you will upload (fileName, size).
GET /transcriptions
Your transcriptions, newest first. limit, cursor, status.
GET /transcriptions/{id}
Status and metadata.
POST /transcriptions/{id}/start
Start after the upload has landed, or try again after an error.
GET /transcriptions/{id}/transcript
?format=json, txt, docx, pdf, srt, vtt, fcpxml, premiere. JSON has segments, words and speakers with their times; the others are the files the app's Export menu makes.
GET /transcriptions/{id}/summary
The summary and its status.
POST /transcriptions/{id}/summary
Ask for a summary. Optional language and scene.
DELETE /transcriptions/{id}
Delete it, its media and everything made from it.
Uploading a file is three calls. Create the transcription with its fileName, contentType and size; the answer has an upload.url. PUT the whole file there, with no Authorization header — the URL is the permission, for that one file. Then POST /start. A link needs only the first call.
Options are the app's: language (auto by default, or a language code), diarization (speaker labels, on by default), and for YouTube links captions to use the video's own captions when they fit. Your account's vocabulary list is applied to every transcription, as in the app.
Errors, limits and retries
Every error has the same shape: {"error": {"code", "message", "requestId"}}, sometimes with details. Branch on code; the message is for people. Every answer carries X-Request-Id — quote it to support@txscribe.ai.
402 quota_exceeded means the account is out of minutes for this period, exactly as in the app. Media longer than your plan allows ends as file_too_long: at once for a link whose length is known, and as the transcription's error.code once an uploaded file has been measured.
Each key may make 120 requests a minute. Past that the answer is 429 rate_limited with Retry-After; X-RateLimit-Remaining says how close you are. The app's own limits apply too — on how many summaries an hour, and how many links can be checked in a few minutes.
POSTs take an Idempotency-Key header. Retrying with the same key and body within 24 hours returns the first success instead of creating a second transcription; the replay is marked Idempotent-Replayed: true. Refusals are not remembered, so a retry after you fix the cause runs again.
Webhooks
Register up to 5 https endpoints under Plan and billing → Developer API, each for any of transcription.completed, transcription.failed and summary.completed. Each event is a JSON POST with the transcription (and, for a summary, the summary) as the API returns them. “Send test event” sends webhook.test.
Answer with any 2xx within 10 seconds, and do the work afterwards. Anything else — a timeout, a 5xx, a redirect, which is never followed — is tried again 5 times, after 0.5 min, 2 min, 10 min, 1 h, 6 h. The same event can arrive twice; its id stays the same, so deduplicate on it. The last 50 deliveries and what your endpoint answered are listed in your settings.
Webhooks are only sent to public internet addresses. An address that resolves to a private, loopback, link-local or cloud metadata address is refused when you add it, and checked again at every send.
Every delivery is signed with the endpoint's secret (shown once, when you add it): Txscribe-Signature: t=1790000000,v1=…, where v1 is the hex HMAC-SHA256 of {t}.{raw body}. Check it against the body exactly as it arrived, before parsing it, and refuse a t more than 5 minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
// header: the Txscribe-Signature header. rawBody: the request body exactly as received, as a string.
export function verifyTxscribeSignature(secret, header, rawBody, toleranceSeconds = 300) {
let timestamp = NaN;
const signatures = [];
for (const part of header.split(",")) {
const [name, value] = part.trim().split("=", 2);
if (name === "t") timestamp = Number(value);
if (name === "v1" && value) signatures.push(value);
}
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import hashlib, hmac, time
# header: the Txscribe-Signature header. raw_body: the request body exactly as received, as bytes.
def verify_txscribe_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
timestamp, signatures = None, []
for part in header.split(","):
name, _, value = part.strip().partition("=")
if name == "t":
timestamp = value
elif name == "v1":
signatures.append(value)
if timestamp is None or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)