How to upload a recording for transcription
You can hand Sally a file that was recorded somewhere else and get a transcript and a summary back, exactly as for a recorded meeting. There are two ways in, depending on where the file lives: Sally fetches it from a URL, or you upload it yourself in three steps.
Every example works as it stands once SALLY_TOKEN and DIRECTORY_ID are set, see the Quickstart. Pick your language once, every example on the API pages follows.
Read the Quickstart and Rate limits & fair usage first.
Quick navigation
- The file is reachable by URL
- You have the file yourself
- Formats, size and quota
- Complete script: upload a file end to end
- Endpoints at a glance
1. The file is reachable by URL
One call. Sally fetches the file server-side and starts transcription and summarization, exactly like a finished upload:
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | Publicly reachable https URL of the audio or video file itself, not a landing page. |
name | string | null | no | Name of the recording. A generated name is used if you leave it out, never the URL. |
languageCode | string | null | no | IETF language tag of the audio, e.g. de-DE. Leave it out to auto-detect. |
speakerCount | number | null | no | Number of speakers, for diarization. |
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/from-url" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://files.example.com/weekly.mp4",
"name": "Weekly team meeting"
}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/from-url`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://files.example.com/weekly.mp4',
name: 'Weekly team meeting',
}),
},
);
const {recordingId} = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/from-url",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={
"url": "https://files.example.com/weekly.mp4",
"name": "Weekly team meeting",
},
)
recording_id = response.json()["recordingId"]
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d"
}
The call returns once the file has been fetched and the recording exists, transcription then runs in the background. Each of these rules is enforced, and a violation answers 400:
httpsonly, no other scheme.- No credentials in the URL, a
user:pass@hostform is rejected. - Public hosts only, addresses in private or reserved networks are rejected.
- No redirects. Pass the final address, a redirect is rejected rather than followed.
- A supported media type, by
Content-Typeor by file extension, see Formats, size and quota. - The size limit returned by the upload endpoint as
maxBytes.
Presigned links from S3, Azure Blob or Google Cloud Storage work as long as they are valid at the time of the call. That is why the URL is never used as the recording name: such links carry credentials in their query string.
2. You have the file yourself
The upload has three steps. The file bytes do not go through the Sally API, they go straight to storage:
- Request an upload from the Sally API.
- Upload the file with
PUTto theuploadUrlyou received. - Finalize the upload at the Sally API. Only now does the recording exist.
Step 1: Request an upload
You get an uploadId, an uploadUrl and the requiredHeaders to use with it. The transcription quota is already checked here, so a rejection costs you nothing.
| Field | Type | Required | Description |
|---|---|---|---|
fileName | string | yes | Original file name including the extension. |
mimeType | string | yes | MIME type of the file, e.g. video/mp4 or audio/mpeg. |
sizeBytes | number | yes | File size in bytes, greater than 0 and at most 5 GB. |
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/uploads" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fileName": "weekly.mp4",
"mimeType": "video/mp4",
"sizeBytes": 184320000
}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/uploads`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileName: 'weekly.mp4',
mimeType: 'video/mp4',
sizeBytes: 184320000,
}),
},
);
const {uploadId, uploadUrl, requiredHeaders} = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/uploads",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={
"fileName": "weekly.mp4",
"mimeType": "video/mp4",
"sizeBytes": 184320000,
},
)
upload = response.json()
{
"uploadId": "2054a871-e250-4b17-a44c-8dbda99714cd",
"uploadUrl": "https://example.blob.core.windows.net/uploads/2054a871-e250-4b17-a44c-8dbda99714cd?sv=...&sig=...",
"storageMode": "azure-blob",
"requiredHeaders": {
"x-ms-blob-type": "BlockBlob"
},
"maxBytes": 5368709120
}
| Field | Type | Description |
|---|---|---|
uploadId | string | Upload token, you pass it to the finalize step. |
uploadUrl | string | Absolute Azure SAS URL. You PUT the file bytes directly to it. |
storageMode | string | Storage mode, e.g. azure-blob. |
requiredHeaders | object | Headers that must be sent with the PUT. |
maxBytes | number | Maximum allowed upload size in bytes. |
Step 2: Upload the file
Send the file with PUT directly to uploadUrl, with every header from requiredHeaders. This request does not go through the Sally API, so it takes those headers instead of your Sally token.
- cURL
- JavaScript
- Python
# UPLOAD_URL is the uploadUrl from step 1, the header comes from requiredHeaders
curl -X PUT "$UPLOAD_URL" \
-H "x-ms-blob-type: BlockBlob" \
--upload-file weekly.mp4
import {openAsBlob} from 'node:fs';
// Streams the file from disk instead of loading it into memory
const file = await openAsBlob('weekly.mp4');
const put = await fetch(uploadUrl, {
method: 'PUT',
headers: requiredHeaders,
body: file,
});
if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
import requests
# Passing the open file streams it instead of loading it into memory
with open("weekly.mp4", "rb") as f:
put = requests.put(upload["uploadUrl"], headers=upload["requiredHeaders"], data=f)
put.raise_for_status()
Step 3: Finalize the upload
Only now does the recording exist and transcription starts. Sally verifies the stored file, creates the recording and returns its recordingId. The body is optional:
| Field | Type | Required | Description |
|---|---|---|---|
languageCode | string | null | no | IETF language tag of the audio, e.g. de-DE. Leave it out to auto-detect. |
speakerCount | number | null | no | Number of speakers, for diarization. |
appointmentId | string | null | no | Appointment to link the recording to. |
choice | copy | replace | null | no | Only needed when the linked appointment already has an active recording, ignored otherwise. copy creates a copy of the appointment for you and attaches the file there, the existing recording stays on the original appointment. replace attaches the file to the appointment and removes the existing recording once the new one is created. |
If the appointment already has a recording and you leave out choice, the call fails with 409 and the code RECORDING_EXISTS_CHOICE_REQUIRED (see The error codes). The uploaded file is kept, so you can repeat the same call with choice set to copy or replace without uploading again.
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/uploads/$UPLOAD_ID/finalize" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"languageCode": "de-DE",
"speakerCount": 2
}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/uploads/${uploadId}/finalize`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({languageCode: 'de-DE', speakerCount: 2}),
},
);
const {recordingId} = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/uploads/{upload_id}/finalize",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={"languageCode": "de-DE", "speakerCount": 2},
)
recording_id = response.json()["recordingId"]
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d"
}
Skipping step 3 is the classic mistake. The file is stored, but no recording is created, so nothing shows up in Sally and nothing is transcribed.
3. Formats, size and quota
Supported are video files (mp4, mkv, avi, mov, webm) and audio files (mp3, wav, flac, ogg, amr, m4a, opus, aac) up to 5 GB each. Transcription also falls under a fair-usage policy, which applies the same way to the upload and to the URL import. The last row is the one conflict that finalizing can run into:
| Status | code | Returned by | What to do |
|---|---|---|---|
| 429 | FUP_LIMIT_EXCEEDED | Requesting an upload | The quota is used up. Retrying soon only produces the same answer. |
| 429 | TOO_MANY_CONCURRENT_TRANSCRIPTIONS | Finalizing, URL import | Too many transcriptions of your company account run at the same time. Wait until running ones have finished. For a URL import nothing is fetched. |
| 429 | none | Any request | Plain rate limit. Slow down and retry later. |
| 409 | RECORDING_EXISTS_CHOICE_REQUIRED | Finalizing with appointmentId | The appointment already has a recording. Repeat the call with choice set to copy or replace, the uploaded file is kept. |
Errors use the common error body, so you tell the cases apart by code:
{
"statusCode": 429,
"message": "Transcription quota exhausted.",
"code": "FUP_LIMIT_EXCEEDED"
}
Handle these two codes separately from a plain rate limit. Waiting a moment helps with a rate limit and with concurrent transcriptions, but not with a used-up quota.
4. Complete script: upload a file end to end
This script uploads a local file, finalizes it and waits until the transcription run has finished. It reacts to the three 429 cases from the table above: it stops at FUP_LIMIT_EXCEEDED, waits and finalizes again at TOO_MANY_CONCURRENT_TRANSCRIPTIONS and retries with growing waits at a plain rate limit. Set FILE and MIME_TYPE to your file. The waiting times of 60 and 30 seconds are choices of the script, not values of the API.
- cURL
- JavaScript
- Python
#!/usr/bin/env bash
# Needs curl and jq.
set -euo pipefail
FILE="${FILE:-weekly.mp4}"
MIME_TYPE="${MIME_TYPE:-video/mp4}"
API="https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings"
AUTH="Authorization: Bearer $SALLY_TOKEN"
# Sends a POST, prints the body, and stops on FUP_LIMIT_EXCEEDED
post() {
local out status
out=$(mktemp)
while : ; do
status=$(curl -s -o "$out" -w '%{http_code}' -X POST "$1" \
-H "$AUTH" -H "Content-Type: application/json" -d "$2")
[ "$status" = "429" ] || break
case "$(jq -r '.code // empty' "$out")" in
FUP_LIMIT_EXCEEDED) echo "Transcription quota used up." >&2; exit 1 ;;
TOO_MANY_CONCURRENT_TRANSCRIPTIONS) sleep 60 ;;
*) sleep 5 ;;
esac
done
[ "${status:0:1}" = "2" ] || { echo "HTTP $status: $(cat "$out")" >&2; exit 1; }
cat "$out"
}
# 1. Request an upload
size=$(wc -c < "$FILE" | tr -d ' ')
upload=$(post "$API/uploads" \
"{\"fileName\": \"$(basename "$FILE")\", \"mimeType\": \"$MIME_TYPE\", \"sizeBytes\": $size}")
# 2. PUT the bytes with every header from requiredHeaders
headers=()
while IFS= read -r h; do headers+=(-H "$h"); done \
< <(echo "$upload" | jq -r '.requiredHeaders | to_entries[] | "\(.key): \(.value)"')
curl -sf -X PUT "$(echo "$upload" | jq -r '.uploadUrl')" "${headers[@]}" --upload-file "$FILE"
# 3. Finalize
recording_id=$(post "$API/uploads/$(echo "$upload" | jq -r '.uploadId')/finalize" '{}' \
| jq -r '.recordingId')
echo "Recording created: $recording_id"
# 4. Wait until the transcription run has finished
while : ; do
rec=$(curl -sf --retry 5 "$API/$recording_id?fields=isTranscriptionCompleted,transcriptionCompletionReason" -H "$AUTH")
[ "$(echo "$rec" | jq '.isTranscriptionCompleted')" = "true" ] && break
sleep 30
done
echo "Transcription finished: $(echo "$rec" | jq -r '.transcriptionCompletionReason')"
import {openAsBlob} from 'node:fs';
import {basename} from 'node:path';
const FILE = process.env.FILE ?? 'weekly.mp4';
const MIME_TYPE = process.env.MIME_TYPE ?? 'video/mp4';
const API = `https://api.sally.io/v1.0/directories/${process.env.DIRECTORY_ID}/recordings`;
const AUTH = {Authorization: `Bearer ${process.env.SALLY_TOKEN}`};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Calls the Sally API and handles the three 429 cases
async function api(path, {method = 'GET', body} = {}) {
for (let wait = 1000; ; wait = Math.min(wait * 2, 60_000)) {
const response = await fetch(`${API}${path}`, {
method,
headers: body ? {...AUTH, 'Content-Type': 'application/json'} : AUTH,
body: body ? JSON.stringify(body) : undefined,
});
if (response.ok) return response.json();
const error = await response.json().catch(() => ({}));
if (response.status !== 429) throw new Error(`${response.status} ${error.message ?? ''}`);
if (error.code === 'FUP_LIMIT_EXCEEDED') throw new Error('Transcription quota used up.');
await sleep(error.code === 'TOO_MANY_CONCURRENT_TRANSCRIPTIONS' ? 60_000 : wait);
}
}
const file = await openAsBlob(FILE);
// 1. Request an upload
const {uploadId, uploadUrl, requiredHeaders} = await api('/uploads', {
method: 'POST',
body: {fileName: basename(FILE), mimeType: MIME_TYPE, sizeBytes: file.size},
});
// 2. PUT the bytes with the headers from requiredHeaders
const put = await fetch(uploadUrl, {method: 'PUT', headers: requiredHeaders, body: file});
if (!put.ok) throw new Error(`Upload failed: ${put.status}`);
// 3. Finalize
const {recordingId} = await api(`/uploads/${uploadId}/finalize`, {method: 'POST', body: {}});
console.log(`Recording created: ${recordingId}`);
// 4. Wait until the transcription run has finished
let recording;
for (;;) {
recording = await api(
`/${recordingId}?fields=isTranscriptionCompleted,transcriptionCompletionReason`,
);
if (recording.isTranscriptionCompleted) break;
await sleep(30_000);
}
console.log(`Transcription finished: ${recording.transcriptionCompletionReason}`);
import os
import time
import requests
FILE = os.environ.get("FILE", "weekly.mp4")
MIME_TYPE = os.environ.get("MIME_TYPE", "video/mp4")
API = f"https://api.sally.io/v1.0/directories/{os.environ['DIRECTORY_ID']}/recordings"
AUTH = {"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"}
def api(method, path, body=None):
"""Calls the Sally API and handles the three 429 cases."""
wait = 1
while True:
response = requests.request(method, f"{API}{path}", headers=AUTH, json=body)
if response.ok:
return response.json()
if response.status_code != 429:
response.raise_for_status()
code = response.json().get("code")
if code == "FUP_LIMIT_EXCEEDED":
raise SystemExit("Transcription quota used up.")
if code == "TOO_MANY_CONCURRENT_TRANSCRIPTIONS":
time.sleep(60)
else:
time.sleep(wait)
wait = min(wait * 2, 60)
# 1. Request an upload
upload = api("POST", "/uploads", {
"fileName": os.path.basename(FILE),
"mimeType": MIME_TYPE,
"sizeBytes": os.path.getsize(FILE),
})
# 2. PUT the bytes with the headers from requiredHeaders
with open(FILE, "rb") as f:
put = requests.put(upload["uploadUrl"], headers=upload["requiredHeaders"], data=f)
put.raise_for_status()
# 3. Finalize
recording_id = api("POST", f"/uploads/{upload['uploadId']}/finalize", {})["recordingId"]
print(f"Recording created: {recording_id}")
# 4. Wait until the transcription run has finished
while True:
recording = api("GET", f"/{recording_id}?fields=isTranscriptionCompleted,transcriptionCompletionReason")
if recording["isTranscriptionCompleted"]:
break
time.sleep(30)
print(f"Transcription finished: {recording['transcriptionCompletionReason']}")
To import from a URL instead, replace steps 1 to 3 with the single from-url call from section 1. Its 429 handling is the same as for finalizing. Instead of polling, you can also register a webhook. Once the transcript is ready, Get a full transcript and Get meeting summaries show how to read the results.
5. Endpoints at a glance
Each entry leads to the reference, where you can send the request with your own token.
- POST
…/recordings/from-urlCreates a recording from a file URL (no upload needed)Try it - POST
…/recordings/uploadsStarts a recording upload (step 1/2) — returns a SAS PUT URLTry it - POST
…/recordings/uploads/{uploadId}/finalizeCompletes a recording upload (step 2/2) — creates the recordingTry it - GET
…/recordings/{recordingId}Returns a single recordingTry it