Skip to main content

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.

Before you start

Quick navigation

  1. The file is reachable by URL
  2. You have the file yourself
  3. Formats, size and quota
  4. Complete script: upload a file end to end
  5. 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:

FieldTypeRequiredDescription
urlstringyesPublicly reachable https URL of the audio or video file itself, not a landing page.
namestring | nullnoName of the recording. A generated name is used if you leave it out, never the URL.
languageCodestring | nullnoIETF language tag of the audio, e.g. de-DE. Leave it out to auto-detect.
speakerCountnumber | nullnoNumber of speakers, for diarization.
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"
}'
200 OK
{
"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:

  • https only, no other scheme.
  • No credentials in the URL, a user:pass@host form 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-Type or by file extension, see Formats, size and quota.
  • The size limit returned by the upload endpoint as maxBytes.
Recommendation

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:

  1. Request an upload from the Sally API.
  2. Upload the file with PUT to the uploadUrl you received.
  3. 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.

FieldTypeRequiredDescription
fileNamestringyesOriginal file name including the extension.
mimeTypestringyesMIME type of the file, e.g. video/mp4 or audio/mpeg.
sizeBytesnumberyesFile size in bytes, greater than 0 and at most 5 GB.
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
}'
200 OK
{
"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
}
FieldTypeDescription
uploadIdstringUpload token, you pass it to the finalize step.
uploadUrlstringAbsolute Azure SAS URL. You PUT the file bytes directly to it.
storageModestringStorage mode, e.g. azure-blob.
requiredHeadersobjectHeaders that must be sent with the PUT.
maxBytesnumberMaximum 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.

# 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

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:

FieldTypeRequiredDescription
languageCodestring | nullnoIETF language tag of the audio, e.g. de-DE. Leave it out to auto-detect.
speakerCountnumber | nullnoNumber of speakers, for diarization.
appointmentIdstring | nullnoAppointment to link the recording to.
choicecopy | replace | nullnoOnly 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 -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
}'
200 OK
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d"
}
Important

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:

StatuscodeReturned byWhat to do
429FUP_LIMIT_EXCEEDEDRequesting an uploadThe quota is used up. Retrying soon only produces the same answer.
429TOO_MANY_CONCURRENT_TRANSCRIPTIONSFinalizing, URL importToo many transcriptions of your company account run at the same time. Wait until running ones have finished. For a URL import nothing is fetched.
429noneAny requestPlain rate limit. Slow down and retry later.
409RECORDING_EXISTS_CHOICE_REQUIREDFinalizing with appointmentIdThe 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:

429 Too Many Requests
{
"statusCode": 429,
"message": "Transcription quota exhausted.",
"code": "FUP_LIMIT_EXCEEDED"
}
Recommendation

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.

#!/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')"

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.