How rate limits and fair usage work
The Sally API has two different limits, and they need two different reactions: rate limits protect the service from too many requests, the fair-usage policy governs how much you can transcribe.
This page shows how to read the limits of your company account, how to tell the three kinds of 429 apart and a retry loop with a growing pause that you can copy. The examples work as they stand once SALLY_TOKEN and DIRECTORY_ID are set, see the Quickstart.
Quick navigation
1. Rate limits
Requests are limited per token, and unauthenticated requests per client IP. Each company account has its own limits on top. When a limit is exceeded, the API answers 429.
| Limit | Applies to | Where you see the value |
|---|---|---|
| Per token | Every authenticated request | Not published |
| Per client IP | Requests without a token | Not published |
| Per company account, per minute | All requests for one company account | maxApiRequestsPerMinute |
| Per company account, per day | All requests for one company account | maxApiRequestsPerDay |
2. Read your limits
You can read the limits of a company account from the company account itself. With field selection the answer carries only what you need:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID?fields=name,maxApiRequestsPerMinute,maxApiRequestsPerDay" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(`https://api.sally.io/v1.0/directories/${directoryId}`);
url.searchParams.set('fields', 'name,maxApiRequestsPerMinute,maxApiRequestsPerDay');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
const {maxApiRequestsPerMinute, maxApiRequestsPerDay} = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}",
params={"fields": "name,maxApiRequestsPerMinute,maxApiRequestsPerDay"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
limits = response.json()
{
"name": "Example GmbH",
"maxApiRequestsPerMinute": 60,
"maxApiRequestsPerDay": 10000
}
| Field | Type | Meaning |
|---|---|---|
maxApiRequestsPerMinute | number | Maximum number of API requests per minute allowed for this company account. |
maxApiRequestsPerDay | number | Maximum number of API requests per day allowed for this company account. |
The values above are examples. Read the real ones for your company account, and read them again from time to time instead of hard-coding them. GET /v1.0/directories returns the same fields for every company account your token can address.
Reference: GET/v1.0/directories/{directoryId}
Spread your calls instead of sending them in bursts. With maxApiRequestsPerMinute at 60, one request per second stays below the limit, as long as nothing else uses the same company account at the same time.
3. Fair usage for transcription
Uploads for transcription are additionally governed by a fair-usage policy. It applies the same way to the two-step upload and to the import from a URL. The API reports it with its own codes in the error body:
code | Meaning | When it comes |
|---|---|---|
FUP_LIMIT_EXCEEDED | The transcription quota is used up. | Already when you request an upload, before you send any file. |
TOO_MANY_CONCURRENT_TRANSCRIPTIONS | Too many transcriptions of your company account are running at the same time. | For example when you finalize an upload. |
{
"statusCode": 429,
"message": "<human-readable text>",
"code": "FUP_LIMIT_EXCEEDED"
}
The endpoints that start a transcription:
- 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 - POST
…/recordings/from-urlCreates a recording from a file URL (no upload needed)Try it
4. How to react
Handle the cases separately. A plain 429 means slow down and retry later. TOO_MANY_CONCURRENT_TRANSCRIPTIONS means wait until running transcriptions have finished. FUP_LIMIT_EXCEEDED means the quota is used up, and retrying soon will only produce the same answer. The error body is described in Errors.
| 429 with | Meaning | Reaction |
|---|---|---|
no code | Rate limit exceeded | Wait, then retry with a growing pause. |
TOO_MANY_CONCURRENT_TRANSCRIPTIONS | Too many transcriptions running | Wait until running transcriptions have finished, then retry. |
FUP_LIMIT_EXCEEDED | Quota used up | Do not retry soon. Stop uploads and tell a person. |
5. Retry with backoff
A 429 does not tell you how long to wait, so wait a little longer after each attempt: one second, then two, four and so on, capped at one minute, with a small random share so that several clients do not retry in lockstep. The same loop fits a 500. FUP_LIMIT_EXCEEDED is passed through at once.
- cURL
- JavaScript
- Python
wait=1
for attempt in 1 2 3 4 5 6; do
response=$(curl -s -w '\n%{http_code}' \
"https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings" \
-H "Authorization: Bearer $SALLY_TOKEN")
status=$(echo "$response" | tail -n 1)
body=$(echo "$response" | sed '$d')
if [ "$status" = "429" ] && [ "$(echo "$body" | jq -r '.code')" = "FUP_LIMIT_EXCEEDED" ]; then
echo "Quota used up, not retrying" >&2; break
fi
if [ "$status" != "429" ] && [ "$status" -lt 500 ]; then
echo "$body"; break
fi
sleep "$wait"
wait=$(( wait * 2 > 60 ? 60 : wait * 2 ))
done
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function withRetry(request, maxAttempts = 6) {
let wait = 1000;
for (let attempt = 1; ; attempt++) {
const response = await request();
const retryable = response.status === 429 || response.status >= 500;
if (!retryable || attempt === maxAttempts) return response;
if (response.status === 429) {
const body = await response.clone().json().catch(() => ({}));
if (body.code === 'FUP_LIMIT_EXCEEDED') return response; // quota used up, do not retry
}
await sleep(wait + Math.random() * 250);
wait = Math.min(wait * 2, 60_000);
}
}
const response = await withRetry(() =>
fetch(`https://api.sally.io/v1.0/directories/${directoryId}/recordings`, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
}),
);
import os
import random
import time
import requests
def with_retry(request, max_attempts=6):
wait = 1
for attempt in range(1, max_attempts + 1):
response = request()
retryable = response.status_code == 429 or response.status_code >= 500
if not retryable or attempt == max_attempts:
return response
if response.status_code == 429:
try:
code = response.json().get("code")
except ValueError:
code = None
if code == "FUP_LIMIT_EXCEEDED":
return response # quota used up, do not retry
time.sleep(wait + random.random() / 4)
wait = min(wait * 2, 60)
response = with_retry(lambda: requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
))
For TOO_MANY_CONCURRENT_TRANSCRIPTIONS a pause of a few seconds is often too short. Wait until your running transcriptions report isTranscriptionCompleted, or use a webhook, before you finalize the next upload.