How authentication works in the Sally API
Every request to the Sally API, except the health check GET /health, carries a bearer token in the Authorization header. This page explains which tokens exist, how to send one and check that it works, what a token is allowed to see and how to find out who a token belongs to.
Authorization: Bearer <token>
Quick navigation
1. Two kinds of token
The API accepts two kinds of token. Both are sent the same way, so the rest of your code does not need to know which one it holds.
| Token | Where it comes from | Right choice for |
|---|---|---|
Personal access token, prefix sally_user_api_ | You create it in Sally under Settings > Integrations > Personal Access Token. It is bound to your user and does not expire unless you set an expiry date. | An integration you run yourself. The Quickstart uses it. |
| OAuth 2.1 access token | Issued by Sally. | Something that other companies install and sign in to with their own Sally account. |
2. Send the token
Put the token in the Authorization header with the Bearer scheme. GET /v1.0/me is the simplest way to check that a token works, because it needs no ids:
- cURL
- JavaScript
- Python
curl "https://api.sally.io/v1.0/me" \
-H "Authorization: Bearer $SALLY_TOKEN"
const response = await fetch('https://api.sally.io/v1.0/me', {
headers: {Authorization: `Bearer ${process.env.SALLY_TOKEN}`},
});
if (response.status === 401) throw new Error('Token missing or invalid');
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
const me = await response.json();
console.log(`Signed in as ${me.firstName} ${me.lastName} <${me.email}>`);
import os
import requests
response = requests.get(
"https://api.sally.io/v1.0/me",
headers={"Authorization": f"Bearer {os.environ['SALLY_TOKEN']}"},
)
if response.status_code == 401:
raise SystemExit("Token missing or invalid")
response.raise_for_status()
me = response.json()
print(f"Signed in as {me['firstName']} {me['lastName']} <{me['email']}>")
With a valid token you get the user behind it:
{
"userId": "11f967df-7dd1-4719-a9aa-46530940c443",
"email": "anna.berger@example.com",
"firstName": "Anna",
"lastName": "Berger",
"timeZone": "Europe/Berlin"
}
| Field | Type | Description |
|---|---|---|
userId | string | Unique id of the user. |
email | string | Email address of the user. |
firstName | string | First name. |
lastName | string | Last name. |
timeZone | string | null | IANA time zone of the user, for example Europe/Berlin. null if the user has no time zone set. |
Without a token, or with an invalid one, the API answers 401 with the error body that every error uses:
{
"message": "Ungültiger oder abgelaufener API-Key.",
"error": "Unauthorized",
"statusCode": 401
}
The message is meant for people, so branch on statusCode. Errors describes the body and the optional code field.
3. What a token may access
A token acts as the user it belongs to. It reaches exactly the company accounts that user is a member of, with that user's role and visibility. There is no separate permission model to learn: if the person cannot see a recording in the app, the token cannot see it either.
You notice this in the status codes:
| Status | Meaning | What to do |
|---|---|---|
| 401 | The bearer token is missing or invalid. | Check the header and the token. |
| 403 | The resource exists and you can see it, but the operation needs a higher role, for example admin or owner. | Use a token of a user with that role. |
| 404 | The resource does not exist or you are not allowed to see it. | Check the id and whether the user has access in the app. |
Something you are not allowed to see answers 404, not 403, so a response never reveals that the data exists. Do not treat a 404 as proof that something was deleted. Errors lists all status codes.
4. Find out who you are
Two calls tell you who the token belongs to and which company accounts it can address:
- GET
/v1.0/meReturns the basic info of the authenticated userTry it - GET
/v1.0/me/directories/membershipsLists the memberships of the authenticated user across all directoriesTry it
GET /v1.0/me is shown above. The memberships call returns every company account (directoryId) the token can address, with your role and license in it:
- 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();
const usable = memberships.filter((membership) => !membership.isBlocked);
console.log(usable.map(({directoryId, directoryName}) => `${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()
usable = [m for m in response.json() if not m["isBlocked"]]
for membership in usable:
print(membership["directoryId"], membership["directoryName"])
[
{
"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, used in paths like /v1.0/directories/{directoryId}/.... |
directoryName | string | Display name of the company account. |
roleId | string | Your role in this company account. Resolve it via GET /v1.0/roles. |
licenseId | string | Your license in this company account. Resolve it via GET /v1.0/licenses. |
isBlocked | boolean | Whether you are blocked in this company account. |
5. Keep tokens safe
Keep the token out of your source code and read it from the environment or a secret store at runtime, as every example here does with SALLY_TOKEN:
export SALLY_TOKEN="sally_user_api_..."
Treat a token like a password. Send it only in the Authorization header, never in a URL. Give it an expiry date if the integration is temporary, and revoke a token in Sally as soon as nobody needs it any more.