How appointments, recordings and summaries relate
If you read only one concept page, read this one. Appointments, recordings and summaries look interchangeable, but they are scoped differently, and that difference is where most integrations go wrong.
This page shows how each of the three is scoped, which key identifies it, what the responses look like and a copyable import loop that stores every summary exactly once. Every example works as it stands once SALLY_TOKEN and DIRECTORY_ID are set, see the Quickstart.
Quick navigation
- An appointment exists per person
- A recording exists once
- A summary exists per language
- What that means in practice
- Import summaries without duplicates
- All entities at a glance
1. An appointment exists per person
Every attendee of the same real-world meeting has their own appointment record. A meeting with six people can therefore appear up to six times inside one company account, with the same subject and the same time.
An appointment carries no recordingId. This is one entry from GET …/appointments, shortened to the fields that matter here:
{
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"subject": "Quarterly planning",
"startTime": "2026-09-24T09:30:00Z",
"endTime": "2026-09-24T10:00:00Z",
"icalId": "e32cc9bd-3639-442b-a946-3e99bc12da69",
"attendees": [
{
"name": "Anna Berger",
"email": "anna.berger@example.com",
"invitationStatus": "organizer",
"attendanceStatus": "attended",
"isRequired": true
}
]
}
Never treat subject plus time as an identity. Counting appointments does not count meetings, and grouping by subject merges things that are not the same. The icalId is no identity either: it is not unique within a company account, because there is one copy per synced calendar. If you need the meeting, go through the recording.
2. A recording exists once
There is exactly one recording per recorded meeting within a company account, no matter how many people attended. That makes the recording the stable anchor: it is what you store, deduplicate and join on.
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID" \
-H "Authorization: Bearer $SALLY_TOKEN"
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}`,
{headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`}},
);
const recording = await response.json();
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
recording = response.json()
{
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"name": "Weekly team meeting",
"durationInSeconds": 1843,
"isManualUpload": false,
"isTranscriptionSucceeded": true,
"isTranscriptionCompleted": true,
"transcriptionCompletionReason": "succeeded",
"languageCode": "de-DE",
"createdOn": "2026-09-24T09:30:00Z",
"tags": [
{
"tagId": "18063a7e-a82b-4972-a850-f866b8b4a046",
"name": "Customer",
"colorCode": "indigo"
}
]
}
3. A summary exists per language
A summary belongs to exactly one recording, and a recording can have several summaries, one per language. A summary is unique per recording and languageCode. To pick a specific one, use both.
All summaries of a recording come from one call:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/summaries" \
-H "Authorization: Bearer $SALLY_TOKEN"
const response = await fetch(
`https://api.sally.io/v1.0/directories/${directoryId}/recordings/${recordingId}/summaries`,
{headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`}},
);
const summaries = await response.json();
const german = summaries.find((s) => s.languageCode === 'de-DE');
import os
import requests
response = requests.get(
f"https://api.sally.io/v1.0/directories/{directory_id}/recordings/{recording_id}/summaries",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
summaries = response.json()
german = next((s for s in summaries if s["languageCode"] == "de-DE"), None)
The answer is a plain array with one entry per language. Both entries share the recordingId:
[
{
"recordingSummaryId": "731180f9-257a-46cf-a7e3-8ca508fb9c35",
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"languageCode": "de-DE",
"summary": "Wir haben den Rollout-Plan für Oktober beschlossen.",
"isSummarizationCompleted": true,
"isSummarizationSucceeded": true,
"summarizationCompletionReason": "succeeded",
"createdOn": "2026-09-24T09:30:00Z",
"sectionItems": null
},
{
"recordingSummaryId": "0f5d2a51-6c1e-4b8e-9d7a-2e4c3b1a9f60",
"recordingId": "53e2e041-4b34-41c7-a285-634d80b3675d",
"appointmentId": "d4398f97-cd92-4d21-a6ec-4aab0a85441b",
"languageCode": "en-US",
"summary": "We agreed on the rollout plan for October.",
"isSummarizationCompleted": true,
"isSummarizationSucceeded": true,
"summarizationCompletionReason": "succeeded",
"createdOn": "2026-09-24T09:30:00Z",
"sectionItems": null
}
]
| Field | Meaning |
|---|---|
recordingSummaryId | Id of this one summary, for GET …/summaries/{recordingSummaryId}. |
recordingId | The recording the summary was generated from. Your join key. |
languageCode | Language of the summary as an IETF tag, such as de-DE or en-US. |
appointmentId | Your own appointment linked to the recording, or null if the recording is not linked to one. |
sectionItems | null here. Add ?includeDetails=true to get the structured section items. |
4. What that means in practice
| Entity | Exists | Unique key |
|---|---|---|
| Appointment | Once per attendee | appointmentId |
| Recording | Once per recorded meeting in a company account | recordingId |
| Summary | Once per recording and language | recordingId + languageCode, or recordingSummaryId |
- Store the
recordingIdas your foreign key, not the appointment. - When you show a meeting to a user, use the appointment of that user, because it carries their view of the meeting.
- When you import summaries, iterate over recordings and pick the language you want, instead of iterating over appointments and importing the same meeting several times.
| Approach | Six colleagues, summaries in German and English | Result |
|---|---|---|
| Keyed on the appointment | 6 appointments | Six entries for one meeting |
| Keyed on the recording | 1 recording | One entry per meeting |
| Keyed on recording + language | 2 summaries | One entry per meeting and language |
Six colleagues attend the same weekly meeting. The company account holds six appointments, one recording and, if summaries exist in German and English, two summaries. An importer keyed on the appointment would create six entries for one meeting.
5. Import summaries without duplicates
This loop walks the recordings, fetches the summaries of each one and stores the German summary under the key recordingId + languageCode. Running it twice updates the same entries instead of adding new ones. How the paging works is explained in Pagination & search.
- cURL
- JavaScript
- Python
# German summary of one recording, keyed on recordingId + languageCode
curl -s "https://api.sally.io/v1.0/directories/$DIRECTORY_ID/recordings/$RECORDING_ID/summaries" \
-H "Authorization: Bearer $SALLY_TOKEN" \
| jq '.[] | select(.languageCode == "de-DE") | {key: "\(.recordingId):\(.languageCode)", summary}'
const base = `https://api.sally.io/v1.0/directories/${directoryId}`;
const headers = {Authorization: `Bearer ${process.env.SALLY_TOKEN}`};
const store = new Map(); // your database
for (let page = 1; ; page++) {
const list = await fetch(
`${base}/recordings?page=${page}&pageSize=100&fields=recordingId`,
{headers},
).then((r) => r.json());
for (const {recordingId} of list.items) {
const summaries = await fetch(`${base}/recordings/${recordingId}/summaries`, {headers})
.then((r) => r.json());
const german = summaries.find((s) => s.languageCode === 'de-DE');
if (german) store.set(`${recordingId}:${german.languageCode}`, german); // upsert
}
if (!list.hasMore) break;
}
import os
import requests
base = f"https://api.sally.io/v1.0/directories/{directory_id}"
headers = {"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"}
store = {} # your database
page = 1
while True:
listing = requests.get(
f"{base}/recordings",
params={"page": page, "pageSize": 100, "fields": "recordingId"},
headers=headers,
).json()
for item in listing["items"]:
recording_id = item["recordingId"]
summaries = requests.get(f"{base}/recordings/{recording_id}/summaries", headers=headers).json()
german = next((s for s in summaries if s["languageCode"] == "de-DE"), None)
if german:
store[(recording_id, german["languageCode"])] = german # upsert
if not listing["hasMore"]:
break
page += 1
Check isSummarizationSucceeded before you store a summary. A run over a recording without a transcript finishes too, and then summarizationCompletionReason is noTranscript, which is an expected outcome, not a failure.
6. All entities at a glance
Most resources live under a company account: /v1.0/directories/{directoryId}/….
| Entity | What it is |
|---|---|
| Directory | A Sally company account and the top-level tenant. Every other resource belongs to exactly one directory. |
| User / Membership | A person, and the link between a user and a company account with a role and a license. |
| Role / License | Lookups that a membership refers to. |
| Team | A named group of users inside a company account. |
| Appointment | A calendar meeting with its attendees, stored once per user. |
| Recording | One audio or video recording of a meeting, plus its transcript. |
| Summary | The AI summary of a recording, one per language, made of section items. |
| Meeting template | Defines the sections a summary is generated into (read-only). |
| Folder / Tag | Organize recordings in a tree and label appointments and recordings. |
| Task | An action item, often derived from a meeting. |
| Vocabulary | Terms that improve transcription. |
| Webhook | An outgoing subscription that sends events to your URL. |
| File | A binary asset such as a profile picture, fetched by id. |
The endpoints for the three entities on this page:
- GET
…/appointmentsSearches appointments (subject, attendee, time range), paginatedTry it - GET
…/recordingsLists recordings of a directory (paginated, optionally filtered by date)Try it - GET
…/recordings/{recordingId}Returns a single recordingTry it - GET
…/recordings/{recordingId}/summariesReturns the summaries of a recordingTry it - GET
…/summaries/{recordingSummaryId}Returns a single recording summary by idTry it