How to make your first call to the Sally API
This quickstart takes you from nothing to the summary of a real meeting in four steps. Every example works as it stands once your own token is in place, and every step shows the response you get back, so you know what to pick from it for the next call.
You need a Sally account with access to at least one company account and one meeting that Sally has already summarized. No app registration and no approval step are required. Pick your language once, every example on the API pages follows.
Quick navigation
- Create a token
- Find your company account
- List your recordings
- Read the summary
- Before you build on it
1. Create a token
In Sally, open Settings, select Integrations under Configuration and switch to the Personal Access Token tab. Create a token and copy it right away.
The token starts with sally_user_api_. It belongs to you and acts with your permissions: it reaches exactly the company accounts you are a member of and sees exactly what you would see in the app. Store it in an environment variable, all examples read it from there:
export SALLY_TOKEN="sally_user_api_..."
Every request sends it as a bearer token in the Authorization header. Authentication explains the details.
2. Find your company account
Nearly every path needs a directoryId, the id of the Sally company account the data belongs to. This call lists the company accounts your token can address:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/me/directories/memberships" \
-H "Authorization: Bearer $SALLY_TOKEN"
const response = await fetch('https://api.sally.io/v1.0/me/directories/memberships', {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const memberships = await response.json();
for (const {directoryId, directoryName} of memberships) {
console.log(directoryId, directoryName);
}
import os
import requests
response = requests.get(
"https://api.sally.io/v1.0/me/directories/memberships",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
response.raise_for_status()
for membership in response.json():
print(membership["directoryId"], membership["directoryName"])
The answer is a plain JSON array with one entry per company account:
[
{
"directoryId": "5daeda15-8fc7-4693-a1e1-13119cceb461",
"directoryName": "Example GmbH",
"roleId": "164531fc-9be4-4de4-a183-89cc85d6a98c",
"licenseId": "f5f084e9-b993-425f-ad36-bfd5dc3f0e65",
"isBlocked": false
}
]
| Field | Type | Description |
|---|---|---|
directoryId | string | Id of the company account. This is what the paths need. |
directoryName | string | Display name of the company account. |
roleId | string | Your role in this company account, resolved via GET /v1.0/roles. |
licenseId | string | Your license in this company account, resolved via GET /v1.0/licenses. |
isBlocked | boolean | Whether you are blocked in this company account. |
If you get a 401 instead, the token is missing or invalid. Check that SALLY_TOKEN is set in the shell you run the example in.
Copy the directoryId you want to work with:
export DIRECTORY_ID="5daeda15-8fc7-4693-a1e1-13119cceb461"
Reference: GET/v1.0/me/directories/memberships
3. List your recordings
There is one recording per recorded meeting, newest first. Ask only for the fields you need and the answer stays small:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings?pageSize=5&fields=recordingId,name,createdOn" \
-H "Authorization: Bearer $SALLY_TOKEN"
const directoryId = process.env.DIRECTORY_ID;
const url = new URL(`https://api.sally.io/v1.0/directories/${directoryId}/recordings`);
url.searchParams.set('pageSize', '5');
url.searchParams.set('fields', 'recordingId,name,createdOn');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const {items} = await response.json();
for (const {recordingId, name, createdOn} of items) {
console.log(recordingId, createdOn, name);
}
import os
import requests
directory_id = os.environ["DIRECTORY_ID"]
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings",
params={"pageSize": 5, "fields": "recordingId,name,createdOn"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
response.raise_for_status()
for recording in response.json()["items"]:
print(recording["recordingId"], recording["createdOn"], recording["name"])
The list is paged, so the recordings sit in items inside the paging envelope. Because of fields, each entry only carries the three fields you named:
{
"page": 1,
"pageSize": 5,
"total": 137,
"hasMore": true,
"items": [
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"name": "Weekly team meeting",
"createdOn": "2026-09-24T09:30:00Z"
},
{
"recordingId": "8f0c2b7a-3d51-4e0f-9a6c-2b1e7d4c9f30",
"name": "Quarterly planning",
"createdOn": "2026-09-23T13:00:00Z"
}
]
}
The query parameters of this endpoint:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number, starting at 1. |
pageSize | integer | 25 | Entries per page, at most 100. |
createdAfter | string | Only recordings created at or after this UTC timestamp (YYYY-MM-DDTHH:MM:SSZ). | |
createdBefore | string | Only recordings created at or before this UTC timestamp. | |
fields | string | all fields | Comma-separated list of the fields to return, see Field selection. |
Pagination & search shows how to fetch all pages. Copy the recordingId of a meeting that already has a summary:
export RECORDING_ID="53e2e041-4b34-41c7-a285-634d80b3675d"
Reference: GET/v1.0/directories/{directoryId}/recordings
4. Read the summary
Add includeDetails=true to get the section items of your meeting template as well. Without it, sectionItems is null and you only get the summary text.
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/summaries?includeDetails=true" \
-H "Authorization: Bearer $SALLY_TOKEN"
const directoryId = process.env.DIRECTORY_ID;
const recordingId = process.env.RECORDING_ID;
const url = new URL(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}/summaries`,
);
url.searchParams.set('includeDetails', 'true');
const response = await fetch(url, {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const summaries = await response.json();
for (const summary of summaries) {
if (!summary.isSummarizationSucceeded) continue;
console.log(`[${summary.languageCode}] ${summary.summary}`);
for (const item of summary.sectionItems ?? []) {
console.log(` ${item.sectionType}: ${item.title}`);
}
}
import os
import requests
directory_id = os.environ["DIRECTORY_ID"]
recording_id = os.environ["RECORDING_ID"]
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}/summaries",
params={"includeDetails": "true"},
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
response.raise_for_status()
for summary in response.json():
if not summary["isSummarizationSucceeded"]:
continue
print(f"[{summary['languageCode']}] {summary['summary']}")
for item in summary["sectionItems"] or []:
print(f" {item['sectionType']}: {item['title']}")
The answer is a plain JSON array with one summary per language:
[
{
"recordingSummaryId": "731180f9-257a-46cf-a7e3-8ca508fb9c35",
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"languageCode": "de-DE",
"summary": "The team agreed on the rollout plan for October and assigned the launch checklist.",
"isSummarizationCompleted": true,
"isSummarizationSucceeded": true,
"summarizationCompletionReason": "succeeded",
"createdOn": "2026-09-24T09:30:00Z",
"sectionItems": [
{
"sectionItemId": "39ec96ee-9578-4082-a103-aa168fcd18f6",
"meetingTemplateSectionId": "0ba1ea56-516d-485a-a738-e65e51578bbe",
"title": "Decisions",
"sectionType": "decisions",
"outputFormat": "html",
"sortOrder": 1,
"content": "<p>We agreed on the rollout plan for October.</p>",
"subject": null,
"description": null,
"responsibleUserName": null,
"responsibleUserEmail": null,
"dueDate": null,
"tableVariant": null,
"tableData": null,
"followUpMeetingStartTime": null,
"followUpMeetingEndTime": null,
"followUpMeetingParticipants": null,
"emailRecipients": null
},
{
"sectionItemId": "c2a7e4b1-6f38-4d0a-8e95-1b7c3f2d6a84",
"meetingTemplateSectionId": null,
"title": "Tasks",
"sectionType": "tasks",
"outputFormat": "html",
"sortOrder": 2,
"content": null,
"subject": "Prepare the launch checklist",
"description": "Collect the open points from all teams before the rollout.",
"responsibleUserName": "Anna Berger",
"responsibleUserEmail": "anna.berger@example.com",
"dueDate": "2026-10-01T00:00:00Z",
"tableVariant": null,
"tableData": null,
"followUpMeetingStartTime": null,
"followUpMeetingEndTime": null,
"followUpMeetingParticipants": null,
"emailRecipients": null
}
]
}
]
The fields you need first:
| Field | Type | Description |
|---|---|---|
recordingSummaryId | string | Id of the summary. |
languageCode | string | null | Language of the summary, for example de-DE or en-US. |
summary | string | null | The summary text. |
isSummarizationCompleted | boolean | true once the summarization run has finished, whether or not it produced anything. |
isSummarizationSucceeded | boolean | true when there is something to read: summary text or at least one section item. |
summarizationCompletionReason | string | Why the run ended: succeeded, noTranscript, emptyResult, error or unknown while it is still running. noTranscript is an expected outcome, not a failure. |
sectionItems | array | null | The structured results per section of your meeting template. null without includeDetails=true. |
Each section item has a sectionType (summary, tasks, topics, decisions, customList, freeText, table, appointment, email or unknown) and an outputFormat (html, markdown or unknown) that tells you how to render content. Task-like items carry subject, description, responsibleUserName, responsibleUserEmail and dueDate instead. The types table, appointment and email keep their result in dedicated fields and also put a readable Markdown version into content, so reading content alone still shows every section.
Reference: GET/v1.0/directories/{directoryId}/recordings/{recordingId}/summaries
5. Before you build on it
A recording can have several summaries, one per language. Always identify a summary by the recording together with its languageCode, never by the recording alone. Meetings, recordings & summaries explains why, and why the same meeting can show up several times as an appointment.
Version 1.0 is still under active development and can change. Ignore fields you do not know and tolerate new enum values, so your code keeps working when something is added. Versioning has the details.
The three endpoints of this quickstart at a glance:
- GET
/v1.0/me/directories/membershipsLists the memberships of the authenticated user across all directoriesTry it - GET
…/recordingsLists recordings of a directory (paginated, optionally filtered by date)Try it - GET
…/recordings/{recordingId}/summariesReturns the summaries of a recordingTry it
From here, pick the guide for what you are building: