How pagination and search work
Lists in the Sally API are paged, and search has its own endpoint per resource. This page shows the parameters and the envelope every paged answer carries, a copyable loop that fetches all pages, and how the search endpoints take and match their terms.
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.
Quick navigation
1. Pagination
Paged list endpoints take two query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number, starting at 1. |
pageSize | integer | 25 | Entries per page, at most 100. |
This request asks for the second page with ten recordings:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings?page=2&pageSize=10" \
-H "Authorization: Bearer $SALLY_TOKEN"
const url = new URL(`https://api.sally.io/v1.0/directories/${directoryId}/recordings`);
url.searchParams.set('page', '2');
url.searchParams.set('pageSize', '10');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
const {page, total, hasMore, items} = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings",
params={"page": 2, "pageSize": 10},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
data = response.json()
The answer is always the same envelope around the entries:
{
"page": 2,
"pageSize": 10,
"total": 137,
"hasMore": true,
"items": [
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"name": "Weekly team meeting",
"durationInSeconds": 1843,
"languageCode": "de-DE",
"createdOn": "2026-09-24T09:30:00Z"
}
]
}
| Field | Type | Meaning |
|---|---|---|
page | integer | The page you got. |
pageSize | integer | The page size that was applied. |
total | integer | Number of matches across all pages. |
hasMore | boolean | true if another page follows. Use it as your loop condition. |
items | array | The entries of this page. Their fields are in the reference of each endpoint. |
A few small lists are not paged and return a plain JSON array. The reference shows which kind each endpoint returns, and Endpoints at a glance lists every paged one.
2. Fetch all pages
Ask for pages until hasMore is false. Use the largest page size you can handle, so you need fewer calls. When the loop hits the rate limit and gets a 429, it waits a little longer each time and asks for the same page again.
- cURL
- JavaScript
- Python
page=1
while : ; do
body=$(curl -s "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings?page=$page&pageSize=100" \
-H "Authorization: Bearer $SALLY_TOKEN")
echo "$body" | jq -c '.items[]'
[ "$(echo "$body" | jq '.hasMore')" = "true" ] || break
page=$((page + 1))
done
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function* allPages(path, pageSize = 100) {
let wait = 1000;
for (let page = 1; ; page++) {
const url = new URL(`https://api.sally.io${path}`);
url.searchParams.set('page', String(page));
url.searchParams.set('pageSize', String(pageSize));
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (response.status === 429) {
await sleep(wait);
wait = Math.min(wait * 2, 60_000);
page--; // same page again
continue;
}
wait = 1000;
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const body = await response.json();
yield* body.items;
if (!body.hasMore) return;
}
}
for await (const recording of allPages(`/v1.0/directories/${directoryId}/recordings`)) {
console.log(recording.recordingId, recording.name);
}
import os
import time
import requests
def all_pages(path, page_size=100):
page, wait = 1, 1
while True:
response = requests.get(
f"https://api.sally.io{path}",
params={"page": page, "pageSize": page_size},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
if response.status_code == 429:
time.sleep(wait)
wait = min(wait * 2, 60)
continue # same page again
wait = 1
response.raise_for_status()
body = response.json()
yield from body["items"]
if not body["hasMore"]:
return
page += 1
for recording in all_pages(f"/v1.0/directories/{directory_id}/recordings"):
print(recording["recordingId"], recording["name"])
Combine paging with field selection: fields=recordingId,name,createdOn keeps every page small. The envelope fields are always returned, so the loop keeps working.
3. Search endpoints
Every searchable resource has a POST …/search endpoint that takes its parameters in the request body. That is deliberate: a search term in a URL runs into encoding trouble with spaces, umlauts and percent signs, in the body it does not.
A search body always has exactly one free-text field called search, matched across all relevant text fields of the resource at once, plus the structural filters of that resource and page and pageSize. This request searches appointments:
- cURL
- JavaScript
- Python
curl -X POST "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/appointments/search" \
-H "Authorization: Bearer $SALLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"search": "Q4 planning",
"from": "2026-09-01T00:00:00Z",
"page": 1,
"pageSize": 25
}'
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/appointments/search`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SALLY_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
search: 'Q4 planning',
from: '2026-09-01T00:00:00Z',
page: 1,
pageSize: 25,
}),
},
);
const {total, items} = await response.json();
import os
import requests
response = requests.post(
f"https://api.sally.io/v1.0/directories/{directory_id}/appointments/search",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
json={
"search": "Q4 planning",
"from": "2026-09-01T00:00:00Z",
"page": 1,
"pageSize": 25,
},
)
data = response.json()
The body fields of the appointment search:
| Field | Type | Description |
|---|---|---|
search | string | Free text, matched in subject, description and attendee name or email. |
from | string | Only appointments that start at or after this UTC timestamp. |
to | string | Only appointments that start at or before this UTC timestamp. |
page | integer | Page number, default 1. |
pageSize | integer | Entries per page, default 25, at most 100. |
The response has the same envelope as the matching list endpoint, so the loop from Fetch all pages works here too: send the same body again with the next page.
The plain GET list endpoints stay pure list endpoints. The search endpoint is an addition, not a replacement.
4. How matching works
- Case-insensitive substring: the term matches anywhere in the value, not only at the start.
- Literal:
%,_and*are ordinary characters, not wildcards. - Trimmed: leading and trailing spaces are removed, and an empty term means no filter.
- Some endpoints require a minimum length, which the reference names on the endpoint.
search | Matches Q4 planning with Sales | Why |
|---|---|---|
"planning" | yes | Substring anywhere in the value. |
"SALES" | yes | Case does not matter. |
" q4 " | yes | Surrounding spaces are removed. |
"plan*" | no | * is a literal character, not a wildcard. |
"" | yes | An empty term means no filter. |
5. Endpoints at a glance
These lists come straight from the specification. Each entry leads to the reference, where you can send the request with your own token.
Paged endpoints
- GET
…/membershipsLists the memberships of a directory (paginated, optionally filtered)Try it - GET
…/memberships/usersLists the users of a directory (paginated, optionally filtered)Try it - POST
…/memberships/users/searchSearches the users of a directory (parameters in the body)Try it - GET
…/recordingsLists recordings of a directory (paginated, optionally filtered by date)Try it - GET
…/appointmentsSearches appointments (subject, attendee, time range), paginatedTry it - POST
…/appointments/searchSearches appointments (parameters in the body)Try it - POST
…/summaries/searchSearches recording summaries (parameters in the body)Try it - GET
…/foldersLists the recording folders the caller can access (paginated, optionally filtered)Try it - POST
…/folders/searchSearches recording folders (parameters in the body)Try it - GET
…/folders/{folderId}/recordingsLists the recordings in a folder (paginated)Try it - GET
…/tasksLists the tasks of a directory (paginated, optionally filtered)Try it - POST
…/tasks/searchSearches tasks (parameters in the body)Try it - GET
…/meetingtemplatesLists the meeting templates the caller can use in a directoryTry it
Search endpoints
- POST
…/memberships/users/searchSearches the users of a directory (parameters in the body)Try it - POST
…/appointments/searchSearches appointments (parameters in the body)Try it - POST
…/summaries/searchSearches recording summaries (parameters in the body)Try it - POST
…/folders/searchSearches recording folders (parameters in the body)Try it - POST
…/tasks/searchSearches tasks (parameters in the body)Try it