How to get the full transcript of a recording
The transcript is what Sally builds the summary from: who said what, and when. This guide shows how to check that a transcript exists, what the transcript response contains, how to keep the answer small, how to download it as a finished file or as subtitles, and ends with a script that turns a recording into a readable text file.
Every example works as it stands once SALLY_TOKEN, DIRECTORY_ID and RECORDING_ID are set, see the Quickstart. Pick your language once, every example on the API pages follows.
Read the Quickstart and Meetings, recordings & summaries first.
Quick navigation
- Check the status first
- Fetch the transcript
- Keep the answer small
- Download as a file
- Complete script: export a transcript as text
- Endpoints at a glance
1. Check the status first
A finished transcription run is not the same as an existing transcript. Look at the recording first. Field selection keeps that answer down to the three status fields:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID?fields=isTranscriptionCompleted,isTranscriptionSucceeded,transcriptionCompletionReason" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}`,
);
url.searchParams.set(
'fields',
'isTranscriptionCompleted,isTranscriptionSucceeded,transcriptionCompletionReason',
);
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
const status = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}",
params={"fields": "isTranscriptionCompleted,isTranscriptionSucceeded,transcriptionCompletionReason"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
status = response.json()
{
"isTranscriptionCompleted": true,
"isTranscriptionSucceeded": true,
"transcriptionCompletionReason": "succeeded"
}
| Field | Type | Meaning |
|---|---|---|
isTranscriptionCompleted | boolean | The run is over, whatever the outcome. |
isTranscriptionSucceeded | boolean | There is a usable transcript. This is the field to check. |
transcriptionCompletionReason | string | Why the run ended, see the next table. |
transcriptionCompletionReason | Meaning |
|---|---|
succeeded | A usable transcript exists. |
noSpokenAudio | Nobody spoke in the recording. |
fileEmpty | The file was empty. |
languageNotDetected | The language could not be detected. |
quotaExceeded | The transcription quota was used up. |
error | The run failed. The underlying error text is deliberately not exposed. |
unknown | The run has not finished yet. |
A meeting in which nobody spoke produces a finished run without a transcript. That is not an error and does not need a retry, noSpokenAudio says so plainly.
2. Fetch the transcript
Once isTranscriptionSucceeded is true, fetch the transcript:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/transcription" \
-H "Authorization: Bearer $SALLY_TOKEN"
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}/transcription`,
{headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`}},
);
const {speakers, segments} = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}/transcription",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
transcript = response.json()
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"speakers": [
{
"speakerId": "b16c2c6f-d9f5-4709-a27e-41a319410893",
"speakerNumber": 1,
"name": "Anna Berger",
"emailAddress": "anna.berger@example.com"
},
{
"speakerId": "4f0e1c52-7a3b-4d8e-9c61-2b5d8e7f3a10",
"speakerNumber": 2,
"name": null,
"emailAddress": null
}
],
"segments": [
{
"speakerId": "b16c2c6f-d9f5-4709-a27e-41a319410893",
"startTime": 12.4,
"endTime": 17.9,
"text": "We agreed on the rollout plan for October."
},
{
"speakerId": "4f0e1c52-7a3b-4d8e-9c61-2b5d8e7f3a10",
"startTime": 18.2,
"endTime": 21.0,
"text": "Then I will update the timeline today."
}
]
}
The answer holds two lists that belong together:
speakershas one entry per voice withspeakerId,speakerNumberand, where Sally could resolve it,nameandemailAddress.segmentsholds the time-coded pieces of speech withstartTimeandendTimein seconds, thetextand aspeakerIdfrom thespeakerslist. You do not need a second call to resolve the speaker of a segment.
| Field | Type | Description |
|---|---|---|
recordingId | string | Id of the recording. |
speakers[].speakerId | string | Id of the speaker, referenced by each segment. |
speakers[].speakerNumber | number | Speaker number. |
speakers[].name | string | null | Name of the speaker. |
speakers[].emailAddress | string | null | Email of the speaker. |
segments[].speakerId | string | Speaker of the segment, resolves to the speakers list. |
segments[].startTime | number | Start time in seconds. |
segments[].endTime | number | End time in seconds. |
segments[].text | string | null | Transcribed text of the segment. |
3. Keep the answer small
Transcripts are the largest responses in this API, and almost all of it is segments. If you only want to know who took part, ask for ?fields=speakers. The segments are then not sent at all. Field selection explains the rules.
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/transcription?fields=speakers" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}/transcription`,
);
url.searchParams.set('fields', 'speakers');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
const {speakers} = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}/transcription",
params={"fields": "speakers"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
speakers = response.json()["speakers"]
{
"speakers": [
{
"speakerId": "b16c2c6f-d9f5-4709-a27e-41a319410893",
"speakerNumber": 1,
"name": "Anna Berger",
"emailAddress": "anna.berger@example.com"
}
]
}
Selectable fields on this endpoint are recordingId, speakers and segments. A nested list is returned whole or not at all, speakers.name is not supported.
4. Download as a file
If you need the transcript as a document rather than as data, GET …/recordings/{recordingId}/transcription/document returns exactly the file a user downloads in the Sally web app: with branding, logos, header and footer, speakers, timestamps and the same file name. The file is built on request and comes back directly in the response, there is no job to poll. The formats vtt and srt are subtitle files without a header block, ready for a video player.
| Parameter | Type | Required | Description |
|---|---|---|---|
format | string | yes | pdf, docx, xlsx, txt, md, vtt or srt. |
languageCode | string | no | de-DE, en-US, es-ES, fr-FR, it-IT, nl-NL or pl-PL. Without it, the language of the token owner applies, as in the web app. |
- cURL
- JavaScript
- Python
# -OJ saves the file under the name from Content-Disposition
curl -fOJ "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/transcription/document?format=docx&languageCode=en-US" \
-H "Authorization: Bearer $SALLY_TOKEN"
import {writeFile} from 'node:fs/promises';
const url = new URL(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}/transcription/document`,
);
url.searchParams.set('format', 'docx');
url.searchParams.set('languageCode', 'en-US');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
// Same file name as in the web app, UTF-8 version from Content-Disposition
const disposition = response.headers.get('content-disposition') ?? '';
const match = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
const fileName = match ? decodeURIComponent(match[1]) : 'transcript.docx';
await writeFile(fileName, Buffer.from(await response.arrayBuffer()));
import os
import re
from urllib.parse import unquote
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}/transcription/document",
params={"format": "docx", "languageCode": "en-US"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
response.raise_for_status()
# Same file name as in the web app, UTF-8 version from Content-Disposition
disposition = response.headers.get("Content-Disposition", "")
match = re.search(r"filename\*=UTF-8''([^;]+)", disposition, re.IGNORECASE)
file_name = unquote(match.group(1)) if match else "transcript.docx"
with open(file_name, "wb") as f:
f.write(response.content)
languageCode only changes the labels and the date format inside the document, not the transcribed text. The font always follows the language of the recording, so Chinese characters stay readable with en-US labels. This endpoint does not support ?fields=.
The answer is 200 with the file itself, not JSON. The header Content-Disposition carries the file name the web app uses for the same download, ASCII in filename and UTF-8 in filename*. The Content-Type follows the format:
format | Content-Type |
|---|---|
pdf | application/pdf |
docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
txt | text/plain; charset=utf-8 |
md | text/markdown; charset=utf-8 |
vtt | text/vtt; charset=utf-8 |
srt | application/x-subrip; charset=utf-8 |
Besides the usual errors, this endpoint answers with:
| Status | code | Meaning |
|---|---|---|
400 | UNKNOWN_DOCUMENT_FORMAT or UNKNOWN_LANGUAGE_CODE | format is missing or unknown, or languageCode is unknown. The body lists the allowed values. |
403 | TRANSCRIPT_DOWNLOAD_PREVENTED | The company account has disabled this download for its members. |
404 | The recording does not exist, belongs to another company account, or you cannot access it. | |
409 | TRANSCRIPT_NOT_AVAILABLE | The recording has no transcript yet. Check the status first. |
Errors shows how to handle these codes in one place.
5. Complete script: export a transcript as text
This script waits until the transcription run of a recording has finished, stops cleanly if there is no transcript and otherwise writes a readable text file with one line per segment: time stamp, speaker, text. Speakers without a resolved name appear as Speaker 1, Speaker 2 and so on. The polling interval of 30 seconds is a choice of the script, not a value of the API.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Needs curl and jq. curl --retry repeats a request after a 429 with growing waits.
set -euo pipefail
BASE="https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID"
CURL=(curl -sf --retry 5 -H "Authorization: Bearer $SALLY_TOKEN")
# 1. Wait until the run has finished
while : ; do
status=$("${CURL[@]}" "$BASE?fields=isTranscriptionCompleted,isTranscriptionSucceeded,transcriptionCompletionReason")
[ "$(echo "$status" | jq '.isTranscriptionCompleted')" = "true" ] && break
sleep 30
done
# 2. Stop if there is no transcript
if [ "$(echo "$status" | jq '.isTranscriptionSucceeded')" != "true" ]; then
echo "No transcript: $(echo "$status" | jq -r '.transcriptionCompletionReason')"
exit 0
fi
# 3. Fetch it and write one line per segment
"${CURL[@]}" "$BASE/transcription" | jq -r '
(.speakers | map({key: .speakerId, value: (.name // "Speaker \(.speakerNumber)")}) | from_entries) as $names
| .segments[]
| (.startTime | floor) as $s
| "[\($s / 60 | floor):\($s % 60 | tostring | ("0" + .)[-2:])] \($names[.speakerId]): \(.text // "")"
' > "transcript-$RECORDING_ID.txt"
import {writeFile} from 'node:fs/promises';
const BASE = `https://api.sally.io/v1.0/directories/${process.env.DIRECTORY_ID}/recordings/${process.env.RECORDING_ID}`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function api(url) {
for (let wait = 1000; ; wait = Math.min(wait * 2, 60_000)) {
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (response.status === 429) {
await sleep(wait);
continue;
}
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response.json();
}
}
const clock = (seconds) => {
const s = Math.floor(seconds);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
// 1. Wait until the run has finished
let status;
for (;;) {
status = await api(
`${BASE}?fields=isTranscriptionCompleted,isTranscriptionSucceeded,transcriptionCompletionReason`,
);
if (status.isTranscriptionCompleted) break;
await sleep(30_000);
}
// 2. Stop if there is no transcript
if (!status.isTranscriptionSucceeded) {
console.log(`No transcript: ${status.transcriptionCompletionReason}`);
process.exit(0);
}
// 3. Fetch it and write one line per segment
const {speakers, segments} = await api(`${BASE}/transcription`);
const names = new Map(
speakers.map((s) => [s.speakerId, s.name ?? `Speaker ${s.speakerNumber}`]),
);
const lines = segments.map(
(seg) => `[${clock(seg.startTime)}] ${names.get(seg.speakerId)}: ${seg.text ?? ''}`,
);
await writeFile(`transcript-${process.env.RECORDING_ID}.txt`, lines.join('\n') + '\n');
import os
import sys
import time
import requests
BASE = (
f"https://api.sally.io/v1.0/directories/{os.environ['DIRECTORY_ID']}"
f"/recordings/{os.environ['RECORDING_ID']}"
)
def api(url, params=None):
wait = 1
while True:
response = requests.get(
url,
params=params,
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
if response.status_code == 429:
time.sleep(wait)
wait = min(wait * 2, 60)
continue
response.raise_for_status()
return response.json()
def clock(seconds):
s = int(seconds)
return f"{s // 60}:{s % 60:02d}"
# 1. Wait until the run has finished
while True:
status = api(BASE, {
"fields": "isTranscriptionCompleted,isTranscriptionSucceeded,transcriptionCompletionReason",
})
if status["isTranscriptionCompleted"]:
break
time.sleep(30)
# 2. Stop if there is no transcript
if not status["isTranscriptionSucceeded"]:
print(f"No transcript: {status['transcriptionCompletionReason']}")
sys.exit(0)
# 3. Fetch it and write one line per segment
transcript = api(f"{BASE}/transcription")
names = {
s["speakerId"]: s["name"] or f"Speaker {s['speakerNumber']}"
for s in transcript["speakers"]
}
with open(f"transcript-{os.environ['RECORDING_ID']}.txt", "w", encoding="utf-8") as f:
for seg in transcript["segments"]:
f.write(f"[{clock(seg['startTime'])}] {names[seg['speakerId']]}: {seg['text'] or ''}\n")
The result looks like this:
[0:12] Anna Berger: We agreed on the rollout plan for October.
[0:18] Speaker 2: Then I will update the timeline today.
6. Endpoints at a glance
Each entry leads to the reference, where you can send the request with your own token.
- GET
…/recordings/{recordingId}Returns a single recordingTry it - GET
…/recordings/{recordingId}/transcriptionReturns the transcript of a recording (time-coded segments + speakers)Try it - GET
…/recordings/{recordingId}/transcription/documentReturns the transcript of a recording as a finished document fileTry it