\_\_\_uat GET
parameter. With a valid token, the user is signed in automatically without login screens.
/saas/v2/user/create](/en/exode-api/school/user/create) (or
[user/upsert](/en/exode-api/school/user/upsert) if the user may have been created earlier).
2. Pass one of the logins (email, phone or domain); to link the account to Telegram, you can pass tgId. You can set the profile right away.
3. Save user.id from the response — you will need it to issue the token.
/saas/v2/user/session/auth-token](/en/exode-api/school/user/session/auth-token)
with the body `{ "userId": 123 }`, where `123` is the user.id from the previous step.
2. Take payload.session.token from the response — this is the value for \_\_\_uat.
\_\_\_uat parameter.
2. Pass the link in the startapp payload when opening the WebApp from the bot, or send it to the user as a regular link.
3. When the Mini App opens, the user is signed in automatically.
```bash theme={null}
https://my-school.exode.biz?___uat=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.student-session-token
```
\_\_\_uat in the
button's web\_app.url field (of an inline or reply keyboard). Telegram passes the startapp parameter of direct links
([https://t.me/your\_school\_bot/app?startapp=](https://t.me/your_school_bot/app?startapp=)...) to the Mini App as
start\_param, not as the page address, so the token cannot be passed through it.
[https://my-school.exode.biz#\_\_\_uat=\<token](https://my-school.exode.biz#___uat=\<token)> — the fragment
is not sent to the server and does not end up in web server logs. After sign-in, the platform removes the token from the address.
\_\_\_uat and has not expired.
* User not found when issuing the token: use the user.id from the user creation step.
* Telegram does not open the WebApp: check that the button is created as web\_app and the school URL starts with https\://.
`. Only the permissions that SaaS API methods actually
check are listed below. The other checkboxes on the key's page do not affect the API.
## Where to enable permissions
**Manage → School → For developers → API keys** (`/manage/school/api-keys`).
In the row of the key you need, select **"Edit"**.
The checkboxes are grouped into sections: "School Management", "Course Management" and so on. Click
**"Save"**.
If a method lists several permissions, **any one** of them is enough.
Exports check permissions not at launch but while the file is being built: without the required permission,
`query-export/generate` still responds with `201`, but the export ends with `status: Failed`.
## Permissions by task
Choose what your integration should do and enable the corresponding checkboxes.
| Integration task | What to enable in the account |
| - | - |
| Create, update, find and delete users | "School User Management" |
| Read and write user states, issue a sign-in token | "School User Management" |
| Read groups, add members to groups and remove them | "School User Management" |
| Read courses and student progress | "School User Management" **or** "Course Curator" |
| Read product accesses | "School User Management" **or** "Course Student Management" |
| Read certificates | "Course Management" **or** "Course Student Management" |
| Work with form layouts and custom fields | "Forms management" |
| Read invoices | "School Sales" |
| Read the org structure: departments, positions, employees, absences | "Staff browsing" |
| Change the org structure (sync from HR/1C) | "Staff Management" |
| Export reports | The permission for the exported data — see the [table by report type](/en/exode-api/school/query-export/generate#permission-requirements) |
## Permission reference
In the error text, the API refers to a permission by its technical code, for example
`Forbidden seller resource - permissions FormManage`. Use the code in the right column to find which checkbox to enable.
### School Management
| Checkbox in the account | Methods | API code |
| - | - | - |
| School User Management | [Users](/en/exode-api/school/user/create): create, update, upsert, find, list, delete, [states](/en/exode-api/school/user/state), [sign-in token](/en/exode-api/school/user/session/auth-token). [Groups](/en/exode-api/school/group/list) and [group members](/en/exode-api/school/group-member/list). [Courses](/en/exode-api/school/course/list) and [progress](/en/exode-api/school/course/progresses). [Product accesses](/en/exode-api/school/product-access/list). [Exports](/en/exode-api/school/query-export/generate) of users, students, group members and accesses | `SchoolManageUsers` |
### Course Management
| Checkbox in the account | Methods | API code |
| - | - | - |
| Course Curator | [Courses](/en/exode-api/school/course/list), [get a course](/en/exode-api/school/course/get) and [progress](/en/exode-api/school/course/progresses). Exports of group members and practice attempts | `CourseCurator` |
| Course Student Management | [Product accesses](/en/exode-api/school/product-access/list), [certificates](/en/exode-api/school/certificate/list). Exports of students and accesses | `CourseStudentManage` |
| Course Management | [Create a course](/en/exode-api/school/course/create) (including modules, lessons and blocks), [update](/en/exode-api/school/course/update) and [get a course](/en/exode-api/school/course/get). [Certificates](/en/exode-api/school/certificate/list). Exports of group members and practice attempts | `CourseManage` |
### Forms management
| Checkbox in the account | Methods | API code |
| - | - | - |
| Forms management | [Form layouts](/en/exode-api/school/form-layout/list): list, create, update, delete. [Custom fields](/en/exode-api/school/custom-field/get): reading and writing values | `FormManage` |
### Organization Management
| Checkbox in the account | Methods | API code |
| - | - | - |
| School Sales | [Invoices](/en/exode-api/school/invoice/list), invoice export | `SellerSales` |
### Staff Management
This section exists only in corporate schools.
| Checkbox in the account | Methods | API code |
| - | - | - |
| Staff browsing | Read: [departments](/en/exode-api/school/staff/department), [positions](/en/exode-api/school/staff/position), [employments](/en/exode-api/school/staff/employment), [absences](/en/exode-api/school/staff/absence) | `StaffView` |
| Staff Management | Create, update and delete departments, positions, employments, [department managers](/en/exode-api/school/staff/department-manager) and absences | `StaffManage` |
## Default permissions
A new key immediately receives all the permissions that SaaS API methods need for its school type:
* **in any school** — "School User Management", "Forms management", "Course Curator", "Course Student
Management", "Course Management" and the other permissions in the "School Management" and "Course Management" sections;
* **in a commercial school** — additionally "School Sales" and "School Refunds";
* **in a corporate school** — additionally "Staff Management" and "Staff browsing".
So a fresh key works with all methods without any setup. Change the checkboxes when you need to **restrict**
the integration: for example, leave a key for employee sync with only the permissions of the "Staff Management" section.
If, after such a setup, a method returns `401` `Forbidden seller resource - permissions `, find the checkbox it needs in the tables above.
## Differences in corporate schools
In corporate schools, some checkboxes are labeled differently: "Company User Management" instead of
"School User Management". The permission code stays the same. The "School Sales" and "School Refunds" checkboxes are not
shown in corporate schools.
## System permissions
The key's service user automatically receives the system API client flag (`UserIsApiClient`) — without
it, SaaS methods do not work. It is not shown in the account, and you do not need to configure it. If a request with
a valid token returns an access error even though all the required checkboxes are enabled, contact
[support](https://t.me/exode_support_biz).
***
*Updated: 2026-09-28 05:04 UTC*
# Quickstart
Source: https://docs.exode.biz/en/exode-api/quickstart
Your first Exode API request in a few minutes
In five steps, you'll make your first request: get a token, set up the headers, find or create
a user, and parse the response.
The school owner can create an API key on their own: go to **Manage → School → For developers → API keys**
(`/manage/school/api-keys`) — this is where a service user (API client) is created and a token is issued.
If you prefer, contact [support](https://t.me/exode_support_biz) — we'll help with the setup.
You'll need three values for requests:
* `Authorization` — the API token (used as `Bearer `; shown in full only at the moment of
creation or rotation — save it right away);
* `Seller-Id` — the numeric ID of the seller (the account the school belongs to);
* `School-Id` — the numeric ID of the school.
Both IDs are on the same **API keys** page, in the **Integration data → Identifiers** card — clicking it
copies a string like `Seller-Id: 123; School-Id: 456`.
Save the values to environment variables — the examples below use them:
```bash theme={null}
export EXODE_TOKEN='...' # API token
export SELLER_ID=123 # Seller-Id
export SCHOOL_ID=456 # School-Id
```
Store the token in environment variables, not in code or in the repository.
All requests are made with three required headers:
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
Check access with a safe read method — [`user/find`](/en/exode-api/school/user/find):
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/find?extId=crm_12345' \
--header "Authorization: Bearer $EXODE_TOKEN" \
--header "Seller-Id: $SELLER_ID" \
--header "School-Id: $SCHOOL_ID"
```
```javascript Node.js theme={null}
const axios = require('axios');
const client = axios.create({
baseURL: 'https://api.exode.biz/saas/v2',
headers: {
Authorization: `Bearer ${process.env.EXODE_TOKEN}`,
'Seller-Id': process.env.SELLER_ID,
'School-Id': process.env.SCHOOL_ID,
},
});
const { data } = await client.get('/user/find', { params: { extId: 'crm_12345' } });
console.log(data.payload.user);
```
```python Python theme={null}
import os, requests
session = requests.Session()
session.headers.update({
'Authorization': f"Bearer {os.environ['EXODE_TOKEN']}",
'Seller-Id': os.environ['SELLER_ID'],
'School-Id': os.environ['SCHOOL_ID'],
})
r = session.get('https://api.exode.biz/saas/v2/user/find', params={'extId': 'crm_12345'})
print(r.json()['payload']['user'])
```
`extId` is your own user identifier (for example, an ID from your CRM): a string of up to 50 characters without `/` or
spaces, unique within the school. If the user is not found, the method returns `200` and `payload.user: null` —
this is not an error.
On method pages, the cURL examples write the headers as `{{ sellerId }}` and `{{ schoolId }}` — these are
Postman collection variables. In a terminal, replace them with the numeric IDs or `$SELLER_ID` and `$SCHOOL_ID`.
If the user doesn't exist yet, create them with the [`user/create`](/en/exode-api/school/user/create) method.
The login and password are sent to the user automatically if there is a delivery channel: `email`, `phone` (with
an SMS provider connected) or `tgId` — see the method page for details. To skip the delivery, pass
`skipSendCredentials: true`.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/create' \
--header "Authorization: Bearer $EXODE_TOKEN" \
--header "Seller-Id: $SELLER_ID" \
--header "School-Id: $SCHOOL_ID" \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user@example.com",
"extId": "crm_12345",
"profile": { "firstName": "John", "lastName": "Doe" }
}'
```
Every response is wrapped in `{ success, code, payload }`. Check `success`/`code`, and on error —
the `cause` field:
```json Success theme={null}
{ "success": true, "code": 200, "payload": { "user": { "id": 123, "extId": "crm_12345" } } }
```
```json Error theme={null}
{ "success": false, "code": 400, "cause": "EmailIsBusy", "message": "Email is busy", "error": "Email is busy" }
```
Here, `EmailIsBusy` means the email is already taken by another user of the school. In this case, you usually look up
the existing user through `user/find?login=` or use
[`user/upsert`](/en/exode-api/school/user/upsert), which creates or updates a user in a single call.
For details on headers, the response format, errors, rate limits and pagination, see
["Working with the API"](/en/exode-api/setup).
## What's next
How the seller, school, courses, products and accesses are related.
Headers, response and error format, rate limits, pagination.
Receive platform events in your services.
Complete entity structures based on zod schemas.
***
*Updated: 2026-10-05 12:43 UTC*
# Certificate list
Source: https://docs.exode.biz/en/exode-api/school/certificate/list
Get a list of issued certificates filtered by course, group, user and issue date
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/certificate/list/raw
```
Returns certificates issued for the school's courses. All filter parameters are optional — without them
the method returns all of the school's certificates page by page, from newest to oldest (by descending `certificateId`).
Array parameters are passed by repeating the parameter: `courseIds=1&courseIds=2`.
## Request parameters
### Pagination
Number of records to skip. Defaults to `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. From `1` to `1000`. Defaults to `100`.
### Filtering
Filter by certificate IDs.
Filter by IDs of the courses the certificate was issued for.
Filter by recipient user IDs.
Filter by group IDs. A group is linked to the recipient, not to the certificate: the results include
certificates of users who are members of at least one of the specified groups.
Issue date range.
Start of the range (ISO 8601), exclusive.
End of the range (ISO 8601), inclusive.
Filter by the certificate's archived state: `true` — archived only, `false` — non-archived only. If omitted,
both are returned.
## Response fields
Paginated list of certificates (compact projection). The full structure is in the
[`certificate`](/en/exode-api/objects/entities/certificate) reference.
Array of certificates.
Certificate ID.
Public identifier of the certificate.
Public link to the certificate. It opens without authorization, and you can also print it to PDF from there.
Course ID.
Course name.
Issue date (ISO 8601).
Expiration date (ISO 8601), or `null` if the certificate does not expire.
The recipient with their profile — a [`user`](/en/exode-api/objects/entities/user) object.
Users deleted via [`user/delete-many`](/en/exode-api/school/user/delete-many)
are returned with `status: "Deleted"`; `null` if the user record is unavailable.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Parameters of the next page (`skip`, `take`, `page`).
Parameters of the previous page (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/certificate/list/raw?take=20&courseIds=10' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listCertificates = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/certificate/list/raw', {
params: { take: 20, courseIds: 10 },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listCertificates();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/certificate/list/raw'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'take': 20, 'courseIds': 10 }, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/certificate/list/raw?take=20&courseIds=10");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Certificate list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"certificateId": 10001,
"uuid": "AbCdEfGhJkLm",
"link": "https://verify.exode.biz/certificate/AbCdEfGhJkLm",
"courseId": 10,
"courseName": "Programming Fundamentals",
"issuedAt": "2026-01-01T00:00:00.000Z",
"expireAt": null,
"user": {
"id": 1683,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1683",
"email": "user@example.com",
"phone": null,
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"profile": {
"id": 1665,
"userId": 1683,
"official": false,
"firstName": "Firstname",
"lastName": "Lastname",
"fullName": "Firstname Lastname",
"sex": "Ufo",
"role": "Student"
}
}
}
],
"next": {
"skip": 20,
"take": 20,
"page": 2
},
"prev": {
"skip": 0,
"take": 20,
"page": 1
}
}
}
```
## Permission requirements
Requires token authentication and one of the permissions: [**"Course Management"**](/en/exode-api/permissions) (`CourseManage`)
or **"Course Student Management"** (`CourseStudentManage`).
***
*Updated: 2026-09-25 14:33 UTC*
# Create a course
Source: https://docs.exode.biz/en/exode-api/school/course/create
Create a school course — together with its modules, lessons and content blocks
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
POST /saas/v2/course/create
```
Requires authentication and the [**"Course Management"**](/en/exode-api/permissions) permission (`CourseManage`). The
method is available to schools only.
The method creates a course with the same parameters as the "Create course" button in the admin panel. If you pass
`modules`, the whole course tree is created in the same request: modules, lessons inside them and the content blocks of
each lesson. Without `modules` an empty course is created — lessons can be added later in the admin panel.
Created automatically together with the course:
* the course **product** — published right away; its ID is returned in the `productId` field of the response;
* a **default group** ("Group 1") — you can [enroll users](/en/exode-api/school/course/enroll) into it.
[List groups](/en/exode-api/school/group/list) with the `courseIds` filter returns the group ID.
The API key's user becomes an author of the course and is assigned as its editor, so the created course is available
to this key in [`update`](/en/exode-api/school/course/update) and [`get`](/en/exode-api/school/course/get) even when
the course uses the "Assigned" access mode.
**Creation is not atomic.** The course, modules, lessons and blocks are written one after another, without a shared
transaction. Body validation errors are returned before anything is written, so nothing gets created. But if the
request breaks off in the middle of writing (a dropped connection, a timeout, a server failure), the part created so
far stays in the school and the course is left incomplete. Repeating the request creates a **new** course rather than
completing the previous one — delete the incomplete course or finish it in the admin panel.
Unknown fields in the request body are silently dropped — no error is returned. Check field names against this page.
## Request parameters
### Course
Course type: `TextCourse`, `VideoCourse`, `Webinar`, `Assessment`, `PersonalLesson`, `Bundle`.
Course name. 1 to 130 characters; leading and trailing spaces are trimmed.
Course description. Up to 500 characters; may be an empty string `""`.
Course tags, each at least 2 characters long. Pass `[]` if there are none.
IDs of school users listed as course authors. The API key's user is added automatically. Pass `[]` if there are no
other authors.
Course URL alias: Latin letters, digits and `_`. Cannot consist of digits only and must be unique.
Course images.
Main image URL.
Course card image URL.
Link to the course promo video.
SEO tags.
IDs of the course subject categories.
ID of the course content category.
Course settings. All fields are optional.
Learning path: `ByOrder` — in lesson order, `ByUser` — in any order.
Lesson unlocking: `Free` — all at once, `Sequence` — the next one after the previous is completed.
Which school team members see the course as editors: `All` — everyone with "Course Management", `Assigned` — only those assigned to the course. Defaults to `All`; `Assigned` for corporate schools.
The same for curators: `All` or `Assigned`. Defaults to the `editorAccessMode` default.
Hide module numbering.
Hide the participant count.
Enable gamification (stars) in the course.
Default stars for completing a lesson, `0` or more.
Default stars for a correct task answer, `0` or more.
Screen recording protection.
Disable text copying.
Hide course feedback.
Hide lesson ratings.
How ratings are collected: `ByLesson` — after each lesson, `ByModule` — after a module.
Curator chat: `Hidden`, `Platform` (Exode chat) or `External` (external link from `curatorChatUrl`).
External curator chat link (with `curatorChatMode: External`). Up to 255 characters.
Custom link (URL) the course card leads to.
Completion certificate: `enabled`, `templateId` (required with `enabled: true`; the template must be available
to the school), `expireInMonths`, `brandColor` (HEX), `signatureImageUrl`, `stampImageUrl`, `curatorName`,
`curatorRole` (up to 255 characters).
Course product parameters. If omitted, the product is created with the default settings.
Product type: `Course`. Required if the `product` object is passed.
Currency: `Free`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`.
Whether to show the course in the school catalog. Defaults to `false` for corporate schools.
Enrollment methods (non-empty array): `ByAssignment`, `ByInviteLink`, `ByApplication`, `BySelfEnrollment`.
Sales start (ISO 8601), not later than `saleFinishAt`.
Sales end (ISO 8601), not earlier than `saleStartAt`.
For `type: Bundle` only — courses included in the bundle (up to 50). The courses and their groups must belong to the
same school.
ID of a course included in the bundle.
Group of that course to enroll bundle participants into. A group may appear in at most one row.
Groups of the bundle itself the row applies to.
The `buildStatus` and `aiContext` fields are internal — they are filled in by the AI course wizard. Do not pass them:
with `buildStatus: AiGenerating` the course is hidden from everyone except its author.
### Modules and lessons
Course modules — up to 50. The order of modules, lessons and blocks in the course follows the order in the arrays.
Module name. Up to 120 characters.
Module description. Up to 500 characters; may be `""`.
Status: `Draft`, `OnCheck` or `Published`. Defaults to `Draft`.
Access: `Participant` — course participants only (default), `Demo` — open as a demo.
Module preview URL.
Module lessons — up to 100.
Lesson name. Up to 120 characters.
Lesson description. Up to 500 characters; may be `""`.
Status: `Draft`, `OnCheck` or `Published`. Defaults to `Draft`.
Lesson type: `Regular` (default) or `Webinar`.
Access: `Participant` (default) or `Demo`.
Lesson preview URL.
Create an empty practice for the lesson — tasks are added in the admin panel. Defaults to `false`.
Lesson settings: `starsForCompletion` (stars for completion, `0` or more),
`videoWatchPercentThreshold` (percentage of the video to watch, `1` to `100`),
`scorm` (`completionThreshold` from `1` to `100`, `requireNotFailed`).
Lesson content blocks — up to 100. See [Content blocks](#content-blocks).
Modules and lessons are created as **`Draft`** — students do not see them. To make the materials available right
away, pass `status: "Published"` for modules and lessons. Do not pass `parentLessonId`: modules and lessons are linked
by array nesting.
### Content blocks
Each lesson block is an object with the fields:
Block type. For lessons: `EditorJsBlock`, `TaskQuestion`, `Checklist`, `Button`, `Checkpoint`, `ChatMessage`, `Video`,
`Iframe`, `NotionPage`. Other values (`Text`, `Audio`, `Scorm`, `Survey` and promo block types) are not recommended
for creation via the API.
Heading shown above the block in the lesson.
Block content. Its shape depends on `type` — see the examples below.
**The API does not validate `content`**: the block is saved as passed. A block with the wrong `content` shape is
created but renders empty or broken in the lesson. Check the shape against the examples. Generate every `uuid` inside
`content` on your side (UUID v4), unique within the block. Blocks with files (uploaded video, audio, a SCORM package)
cannot be created via the API — the file is uploaded in the admin panel.
The main block for theory: headings, paragraphs, lists, quotes, tables and a delimiter. `content` is an
[EditorJS](https://editorjs.io/) document. The text may use ``, ``, ``, `` tags.
```json theme={null}
{
"type": "EditorJsBlock",
"content": {
"time": 0,
"version": "2.29.1",
"blocks": [
{ "id": "h-1", "type": "header", "data": { "text": "Why workplace safety matters", "level": 2 } },
{ "id": "p-1", "type": "paragraph", "data": { "text": "The rules protect you and your colleagues." } },
{ "id": "l-1", "type": "list", "data": { "style": "unordered", "items": ["Helmet", "Gloves"] } },
{ "id": "q-1", "type": "quote", "data": { "text": "Safety first.", "caption": "Handbook" } },
{ "id": "d-1", "type": "delimiter", "data": {} },
{ "id": "t-1", "type": "table", "data": { "withHeadings": true, "content": [["Equipment", "When"], ["Helmet", "Always"]] } }
]
}
}
```
Inner block `id`s are arbitrary unique strings. A header `level` is `2` or `3`; a list `style` is `unordered` or
`ordered`.
A question with answer options, checked right in the lesson. `answerType`: `Single` — one correct option,
`Multiple` — several.
```json theme={null}
{
"type": "TaskQuestion",
"title": "Check yourself",
"content": {
"uuid": "7f1c2a4e-3b5d-4c6e-8f9a-0b1c2d3e4f50",
"task": {
"title": "Who conducts the induction briefing?",
"answerType": "Single",
"question": {
"variants": [
{ "uuid": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "text": "A safety officer", "correct": true },
{ "uuid": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "text": "The employee", "correct": false }
]
}
},
"messages": {
"correct": "Correct — a safety officer conducts it.",
"incorrect": "The briefing is conducted by a safety officer."
}
}
}
```
`messages` is optional. When set, the blocks below the question stay hidden until the student answers.
```json theme={null}
{
"type": "Checklist",
"content": {
"items": [
{ "uuid": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "text": "Read the instructions" },
{ "uuid": "d2e3f4a5-b6c7-4d8e-9f0a-1b2c3d4e5f6a", "text": "Sign the logbook" }
]
}
}
```
```json theme={null}
{
"type": "Button",
"content": {
"buttons": [
{ "uuid": "e3f4a5b6-c7d8-4e9f-0a1b-2c3d4e5f6a7b", "text": "Open the policy", "link": "https://example.com/rules", "target": "_blank" }
]
}
}
```
`target` is `_blank` (new tab) or `_self`.
A divider button: everything below stays hidden until the student presses it.
```json theme={null}
{
"type": "Checkpoint",
"content": { "uuid": "f4a5b6c7-d8e9-4f0a-1b2c-3d4e5f6a7b8c", "text": "Got it, continue" }
}
```
```json theme={null}
{
"type": "ChatMessage",
"content": {
"senderName": "Anna, mentor",
"messages": [
{ "uuid": "a5b6c7d8-e9f0-4a1b-2c3d-4e5f6a7b8c9d", "text": "Hi! Today we cover workplace safety." }
]
}
}
```
A video from a video host (YouTube, Vimeo, Kinescope) — by link, without uploading a file.
```json theme={null}
{
"type": "Video",
"title": "Introductory lecture",
"content": { "type": "ThirdParty", "location": "https://www.youtube.com/watch?v=VIDEO_ID" }
}
```
```json theme={null}
{
"type": "Iframe",
"content": { "src": "https://example.com/embed" }
}
```
A public Notion page, displayed inside the lesson.
```json theme={null}
{
"type": "NotionPage",
"content": { "notionUrl": "https://example.notion.site/Page-Title-0123456789abcdef0123456789abcdef" }
}
```
## Response fields
The created course — a [`course`](/en/exode-api/objects/entities/course) object. The module and lesson tree is not
included in the response.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/course/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"type": "TextCourse",
"name": "Workplace safety for new employees",
"description": "An introductory safety course",
"tags": ["safety"],
"authors": [],
"modules": [
{
"name": "Module 1. Basics",
"description": "",
"status": "Published",
"lessons": [
{
"name": "Lesson 1. Why workplace safety matters",
"description": "",
"status": "Published",
"blocks": [
{
"type": "EditorJsBlock",
"content": {
"time": 0,
"version": "2.29.1",
"blocks": [
{ "id": "h-1", "type": "header", "data": { "text": "Why workplace safety matters", "level": 2 } },
{ "id": "p-1", "type": "paragraph", "data": { "text": "The rules protect you and your colleagues." } }
]
}
}
]
}
]
}
]
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const createCourse = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/course/create', {
type: 'TextCourse',
name: 'Workplace safety for new employees',
description: 'An introductory safety course',
tags: ['safety'],
authors: [],
modules: [
{
name: 'Module 1. Basics',
description: '',
status: 'Published',
lessons: [
{
name: 'Lesson 1. Why workplace safety matters',
description: '',
status: 'Published',
blocks: [
{
type: 'EditorJsBlock',
content: {
time: 0,
version: '2.29.1',
blocks: [
{ id: 'h-1', type: 'header', data: { text: 'Why workplace safety matters', level: 2 } },
{ id: 'p-1', type: 'paragraph', data: { text: 'The rules protect you and your colleagues.' } }
]
}
}
]
}
]
}
]
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Course created:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
createCourse();
```
```php PHP theme={null}
'TextCourse',
'name' => 'Workplace safety for new employees',
'description' => 'An introductory safety course',
'tags' => ['safety'],
'authors' => [],
'modules' => [
[
'name' => 'Module 1. Basics',
'description' => '',
'status' => 'Published',
'lessons' => [
[
'name' => 'Lesson 1. Why workplace safety matters',
'description' => '',
'status' => 'Published',
'blocks' => [
[
'type' => 'EditorJsBlock',
'content' => [
'time' => 0,
'version' => '2.29.1',
'blocks' => [
['id' => 'h-1', 'type' => 'header', 'data' => ['text' => 'Why workplace safety matters', 'level' => 2]],
['id' => 'p-1', 'type' => 'paragraph', 'data' => ['text' => 'The rules protect you and your colleagues.']]
]
]
]
]
]
]
]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 201) {
$result = json_decode($response, true);
echo "Course created: " . $result['payload']['id'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/course/create'
data = {
'type': 'TextCourse',
'name': 'Workplace safety for new employees',
'description': 'An introductory safety course',
'tags': ['safety'],
'authors': [],
'modules': [
{
'name': 'Module 1. Basics',
'description': '',
'status': 'Published',
'lessons': [
{
'name': 'Lesson 1. Why workplace safety matters',
'description': '',
'status': 'Published',
'blocks': [
{
'type': 'EditorJsBlock',
'content': {
'time': 0,
'version': '2.29.1',
'blocks': [
{ 'id': 'h-1', 'type': 'header', 'data': { 'text': 'Why workplace safety matters', 'level': 2 } },
{ 'id': 'p-1', 'type': 'paragraph', 'data': { 'text': 'The rules protect you and your colleagues.' } }
]
}
}
]
}
]
}
]
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.post(url, json=data, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
ВнутреннийБлок = Новый Структура;
ВнутреннийБлок.Вставить("id", "p-1");
ВнутреннийБлок.Вставить("type", "paragraph");
ВнутреннийБлок.Вставить("data", Новый Структура("text", "The rules protect you and your colleagues."));
СодержимоеБлока = Новый Структура;
СодержимоеБлока.Вставить("time", 0);
СодержимоеБлока.Вставить("version", "2.29.1");
СодержимоеБлока.Вставить("blocks", Новый Массив);
СодержимоеБлока.blocks.Добавить(ВнутреннийБлок);
Блок = Новый Структура;
Блок.Вставить("type", "EditorJsBlock");
Блок.Вставить("content", СодержимоеБлока);
Урок = Новый Структура;
Урок.Вставить("name", "Lesson 1. Why workplace safety matters");
Урок.Вставить("description", "");
Урок.Вставить("status", "Published");
Урок.Вставить("blocks", Новый Массив);
Урок.blocks.Добавить(Блок);
Модуль = Новый Структура;
Модуль.Вставить("name", "Module 1. Basics");
Модуль.Вставить("description", "");
Модуль.Вставить("status", "Published");
Модуль.Вставить("lessons", Новый Массив);
Модуль.lessons.Добавить(Урок);
Данные = Новый Структура;
Данные.Вставить("type", "TextCourse");
Данные.Вставить("name", "Workplace safety for new employees");
Данные.Вставить("description", "An introductory safety course");
Данные.Вставить("tags", Новый Массив);
Данные.tags.Добавить("safety");
Данные.Вставить("authors", Новый Массив);
Данные.Вставить("modules", Новый Массив);
Данные.modules.Добавить(Модуль);
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 60, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/course/create");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 201 Тогда
Сообщить("Course created");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 412,
"createdAt": "2026-09-28T09:12:03.518Z",
"updatedAt": "2026-09-28T09:12:03.941Z",
"archivedAt": null,
"type": "TextCourse",
"productId": 1290,
"buildStatus": "Ready",
"name": "Workplace safety for new employees",
"description": "An introductory safety course",
"alias": null,
"tags": [
"safety"
],
"seoTags": [],
"image": {
"main": ""
},
"promoVideo": null,
"settings": {
"editorAccessMode": "All",
"curatorAccessMode": "All"
},
"order": 0,
"isBundle": false
}
}
```
```json Error - Validation theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"modules.0.lessons.0.name must be shorter than or equal to 120 characters"
],
"error": "Bad Request"
}
```
```json Error - Plan Limit Reached theme={null}
{
"code": 402,
"success": false,
"cause": "SaasLimitReached",
"message": "School plan limit reached: maxActiveProducts (10/10)",
"error": "School plan limit reached: maxActiveProducts (10/10)",
"data": {
"max": 10,
"current": 10,
"feature": "maxActiveProducts"
}
}
```
```json Error - Alias Already Busy theme={null}
{
"code": 400,
"success": false,
"cause": "AliasAlreadyBusy",
"message": "Этот адрес уже занят",
"error": "Этот адрес уже занят"
}
```
```json Error - Certificate Template Required theme={null}
{
"code": 400,
"success": false,
"cause": "CertificateTemplateRequired",
"message": "Certificate template is required",
"error": "Certificate template is required"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - permissions CourseManage",
"error": "Forbidden seller resource - permissions CourseManage"
}
```
## Errors
| HTTP | `cause` | When it happens |
| - | - | - |
| 400 | `validation` | The body failed validation: type, length, enum, limits (50 modules, 100 lessons per module, 100 blocks per lesson). The field path is in `message` |
| 400 | `InvalidAlias` | `alias` contains invalid characters or consists of digits only |
| 400 | `AliasAlreadyBusy` | `alias` is already taken by another course |
| 400 | `CertificateTemplateRequired` | The certificate is enabled but `templateId` is missing |
| 400 | `CertificateTemplateNotAvailable` | The certificate template is not available to the school |
| 400 | `BundleGroupAlreadyUsed` | The same group appears in several `bundleCourses` rows |
| 400 | `BundleCourseSellerMismatch` | A course or group from `bundleCourses` belongs to another school |
| 401 | `Forbidden` | The key lacks the `CourseManage` permission, or the method is called for a non-school seller (`Allowed only for school`) |
| 402 | `SaasLimitReached` | The plan limit on active products is reached. `data` holds `feature`, `current`, `max` |
The `InvalidAlias` and `AliasAlreadyBusy` messages are currently returned in Russian — rely on `cause`.
## Related sections
* [Update a course](/en/exode-api/school/course/update) — change course fields
* [Get a course](/en/exode-api/school/course/get) — the full course object by ID
* [Course enrollment](/en/exode-api/school/course/enroll) — grant access through a course group
* [The `course` object](/en/exode-api/objects/entities/course) — response fields
## Permission requirements
Requires token authentication and the [**"Course Management"**](/en/exode-api/permissions) permission
(`CourseManage`). The method is available to schools only.
***
*Updated: 2026-09-28 05:04 UTC*
# Course enrollment
Source: https://docs.exode.biz/en/exode-api/school/course/enroll
How to grant a user access to a course by enrolling them in a group
You enroll a user in a course by adding them to a group linked to that course.
When a member is added to a group, the related member and access records are created automatically,
and the user gets access to the course.
The endpoint for enrolling users in a course group — `POST /saas/v2/group/:groupId/member/create-many`.
## How to enroll a user in a course
Get the list of groups filtered by course — the
[Group list](/en/exode-api/school/group/list) endpoint with the `courseIds` parameter.
In the response, each group has `courseId` and `courseName`, which let you confirm
that the group belongs to the right course. The course's group IDs are also returned by the
[Course list](/en/exode-api/school/course/list) in the `groupIds` field.
If the course has several groups (for example, cohorts), choose the one you need by its `name` — the group
determines the member's schedule, curator and chats.
Enroll users in the group you found — the
[Adding members](/en/exode-api/school/group-member/create-many) endpoint:
```
POST /saas/v2/group/:groupId/member/create-many
```
The request body contains a `userIds` array (numeric user IDs in Exode) — up to 250 users
per request. If a user is already a member of the group, they won't be added again, so you can
safely retry the request. If the user doesn't exist in the school yet, create them first with
[`user/create`](/en/exode-api/school/user/create) or [`user/upsert`](/en/exode-api/school/user/upsert).
Once added to the group, the user gets access to the course the group is linked to, and your
endpoint receives the `ProductEnrolledViaLms` webhook (if you are subscribed to it, see
[Webhooks](/en/exode-api/webhooks/about)).
## Related sections
* [Group list](/en/exode-api/school/group/list) — find a group by `courseIds`
* [Adding members](/en/exode-api/school/group-member/create-many) — enroll users in a group
* [Removing members](/en/exode-api/school/group-member/delete-many) — revoke course access
* [Access list](/en/exode-api/school/product-access/list) — check granted access
***
*Updated: 2026-09-25 14:33 UTC*
# Get a course
Source: https://docs.exode.biz/en/exode-api/school/course/get
The full school course object by its ID
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/course/:courseId/get
```
Requires authentication and one of the permissions: [**"Course Management"**](/en/exode-api/permissions)
(`CourseManage`) or **"Course Curator"** (`CourseCurator`). The method is available to schools only.
The method returns the full course object — unlike the [Course list](/en/exode-api/school/course/list), which returns a
compact projection. Modules, lessons and blocks are not included in the response.
**The "Assigned" access mode applies to the API too.** The `CourseManage` permission grants access to the course only
if `settings.editorAccessMode` = `All`, and `CourseCurator` only if `settings.curatorAccessMode` = `All`. For a course
in the `Assigned` mode the key has access only if its user is assigned to the course as an editor or a curator.
Otherwise `Forbidden` is returned with the message
`Forbidden seller resource - product permissions CourseManage CourseCurator`.
## Path parameters
Course ID.
## Response fields
The course — a [`course`](/en/exode-api/objects/entities/course) object.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/course/412/get' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const getCourse = async (courseId) => {
const { data } = await axios.get(`https://api.exode.biz/saas/v2/course/${courseId}/get`, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
getCourse(412);
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/course/412/get'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/course/412/get");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Course received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 412,
"createdAt": "2026-09-28T09:12:03.518Z",
"updatedAt": "2026-09-28T10:40:17.202Z",
"archivedAt": null,
"type": "TextCourse",
"productId": 1290,
"buildStatus": "Ready",
"name": "Workplace safety: introductory course",
"description": "An introductory safety course",
"alias": null,
"tags": [
"safety"
],
"seoTags": [],
"image": {
"main": ""
},
"promoVideo": null,
"settings": {
"editorAccessMode": "All",
"curatorAccessMode": "All",
"lessonProgressMode": "Sequence"
},
"order": 0,
"isBundle": false
}
}
```
```json Error - Course Not Found / Other School theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
## Related sections
* [Course list](/en/exode-api/school/course/list) — find courses and their group IDs
* [Update a course](/en/exode-api/school/course/update) — change course fields
* [Course progress](/en/exode-api/school/course/progresses) — participants' progress by lesson
## Permission requirements
Requires token authentication and one of the permissions: [**"Course Management"**](/en/exode-api/permissions)
(`CourseManage`) or **"Course Curator"** (`CourseCurator`). The method is available to schools only.
***
*Updated: 2026-09-28 05:04 UTC*
# Course list
Source: https://docs.exode.biz/en/exode-api/school/course/list
Get the list of school courses with filtering and pagination
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/course/list/raw
```
**Course visibility restrictions do not apply to the API.** A course has access modes
for the school team ("All" / "Assigned") that hide a restricted course from managers
in the interface. The API returns data for all school courses regardless of these modes:
access is determined only by the service user's token and its permissions.
All filter parameters are optional. Array parameters are passed by repeating the parameter in the query string:
`courseIds=1&courseIds=2`.
## Request parameters
### Pagination
Number of records to skip. Defaults to `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. From `1` to `1000`. Defaults to `100`.
### Filtering
Filter by course IDs.
Filter by course string aliases.
Filter by course types. Possible values: `Bundle`, `Webinar`, `TextCourse`, `Assessment`, `VideoCourse`,
`PersonalLesson`.
Filter by course tags. Up to 50 characters per tag.
Search by course name. Maximum 50 characters.
Filter by subject category IDs.
Filter by content category IDs.
Filter by archive status: `true` returns only archived courses, `false` only non-archived ones. If omitted,
both are returned.
The `participation`, `manage` and `administrate` parameters are part of the common course filter schema, but in this method
they are **not applied**: the response does not depend on them. The method always returns all of the school's courses (subject to the other
filters).
Nested filter by accesses to the course product. The full set of fields matches the filter of the
["Product access list"](/en/exode-api/school/product-access/list) method (for example, `active`, `userIds`,
`billingStatuses`, `expireAtDateRange`).
Nested product filter.
Product IDs.
Statuses: `Draft`, `OnCheck`, `Declined`, `ReadyToPublish`, `Published`.
Currencies: `Free`, `Exes`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`.
Whether it is shown in the catalog.
Whether the product is on sale.
Include archived ones.
Search by product (up to 50 characters).
## Response fields
A paginated list of courses (compact projection). The full course object is returned by
[Get a course](/en/exode-api/school/course/get); its structure is in the
[`course`](/en/exode-api/objects/entities/course) reference.
Array of courses.
Course ID.
Course product ID. Used to link with the
[accesses](/en/exode-api/school/product-access/list) and [invoices](/en/exode-api/school/invoice/list) methods.
Course name.
Course type: `Bundle`, `Webinar`, `TextCourse`, `Assessment`, `VideoCourse`, `PersonalLesson`.
Course group IDs. To enroll a user in the course, add them to one of these groups;
see [Course enrollment](/en/exode-api/school/course/enroll).
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Next page parameters (`skip`, `take`, `page`).
Previous page parameters (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/course/list/raw?take=20&types=VideoCourse' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listCourses = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/course/list/raw', {
params: { take: 20, types: ['VideoCourse'] },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listCourses();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/course/list/raw'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'take': 20, 'types': ['VideoCourse'] }, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/course/list/raw?take=20&types=VideoCourse");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Course list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"courseId": 10,
"productId": 838,
"name": "Exam preparation",
"type": "VideoCourse",
"groupIds": [
501,
502
]
}
],
"next": {
"skip": 20,
"take": 20,
"page": 2
},
"prev": {
"skip": 0,
"take": 20,
"page": 1
}
}
}
```
## Permission requirements
Requires token authentication and one of the permissions: [**"Course Curator"**](/en/exode-api/permissions) (`CourseCurator`) or **"School User Management"** (`SchoolManageUsers`).
***
*Updated: 2026-09-28 05:04 UTC*
# Course progress
Source: https://docs.exode.biz/en/exode-api/school/course/progresses
Get participants' progress on course lessons with pagination
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/course/:courseId/progresses
```
**Course visibility restrictions do not apply to the API.** A course has access modes
for the school team ("All" / "Assigned") that hide a restricted course from managers
in the interface. The API returns data for all school courses regardless of these modes:
access is determined only by the service user's token and its permissions.
Returns lesson progress records of the selected course for all participants. Each record corresponds to
a "user + lesson" pair. Records are created as the course is taken, so there may be no record for lessons
the participant has not reached yet. The method has no filters by user or lesson:
filter by `userId` / `lessonId` on your side.
To receive progress changes without periodically polling this method, subscribe to the
`CourseProgressChanged` and `CourseCompleted` webhooks — see [Webhooks](/en/exode-api/webhooks/about).
## Path parameters
ID of the course to get progress for — the `courseId` field from the
[course list](/en/exode-api/school/course/list).
## Request parameters
### Pagination
Number of records to skip. Defaults to `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. From `1` to `1000`. Defaults to `100`.
## Response fields
A paginated list of progress records. The full record structure is `courseProgress` in the
[`course`](/en/exode-api/objects/entities/course) reference.
An array of progress records.
Progress record ID.
Course ID.
User ID.
Lesson ID.
Lesson progress status:
* `NotStarted` — access granted, but the lesson has not been opened yet;
* `OnTheory` — the participant is studying the lesson theory;
* `OnPractice` — the participant has opened the practical part;
* `OnReview` — the homework is awaiting review by a curator;
* `OnCorrection` — the curator has returned the assignment for revision;
* `Completed` — the lesson is fully completed.
Scheduled start date (ISO 8601).
Scheduled end date (ISO 8601).
Deadline for the practical part (ISO 8601).
Whether the lesson is completed.
Whether the lesson is under review.
Completion date (ISO 8601).
Date submitted for review (ISO 8601).
Status change history: an array of `{ timestamp, status }` objects.
Record creation date (ISO 8601).
Record update date (ISO 8601).
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Next page parameters (`skip`, `take`, `page`).
Previous page parameters (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/course/10/progresses?take=50' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const getProgresses = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/course/10/progresses', {
params: { take: 50 },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
getProgresses();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/course/10/progresses'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'take': 50 }, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/course/10/progresses?take=50");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Progress retrieved");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 9001,
"courseId": 10,
"userId": 123,
"lessonId": 55,
"status": "Completed",
"isCompleted": true,
"completedAt": "2025-01-15T10:30:00Z",
"createdAt": "2025-01-10T09:00:00Z",
"updatedAt": "2025-01-15T10:30:00Z"
}
],
"next": {
"skip": 20,
"take": 20,
"page": 2
},
"prev": {
"skip": 0,
"take": 20,
"page": 1
}
}
}
```
## Permission requirements
Requires token authentication and one of the permissions: [**"Course Curator"**](/en/exode-api/permissions) (`CourseCurator`) or **"School User Management"** (`SchoolManageUsers`).
***
*Updated: 2026-09-25 14:33 UTC*
# Update a course
Source: https://docs.exode.biz/en/exode-api/school/course/update
Update the fields of an existing school course
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
PUT /saas/v2/course/:courseId/update
```
Requires authentication and the [**"Course Management"**](/en/exode-api/permissions) permission (`CourseManage`). The
method is available to schools only.
The method changes the fields of the course itself — the same ones as on [creation](/en/exode-api/school/course/create).
Modules, lessons and blocks are not changed by this method: the `modules` field in the body is ignored.
**The "Assigned" access mode applies to the API too.** If the course has `settings.editorAccessMode` = `Assigned`,
only a key whose user is assigned to the course (or the school owner) can update it. Otherwise `Forbidden` is returned
with the message `Forbidden seller resource - product permissions CourseManage`. Courses created via
[`course/create`](/en/exode-api/school/course/create) are available to the key that created them.
## Path parameters
Course ID.
## Request parameters
##### All fields are optional — pass only the ones you change
Course name. 1 to 130 characters.
Course description. Up to 500 characters.
Course type: `TextCourse`, `VideoCourse`, `Webinar`, `Assessment`, `PersonalLesson`, `Bundle`.
Course tags (each at least 2 characters). Replace the current list.
IDs of author users. **Replace** the whole current list of authors — pass the full list, including the ones to keep.
Subject category IDs. Replace the current list.
Content category ID.
Course URL alias: Latin letters, digits and `_`, unique.
Course images: `main`, `card` (URLs).
Link to the promo video.
SEO tags.
Course settings — the same fields as on [creation](/en/exode-api/school/course/create#course). The passed fields are
merged into the current settings: fields you omit stay as they were. `certificate` fields are merged too.
For `Bundle` courses only — the bundle composition (same format as on creation).
Archiving date (ISO 8601) — moves the course to the archive.
Product parameters (`product`) are not changed by this method — do not pass the `product` field. Price, currency and
catalog visibility are configured in the admin panel. Do not pass the internal `buildStatus` and `aiContext` fields
either.
## Response fields
The updated course — a [`course`](/en/exode-api/objects/entities/course) object.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/course/412/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Workplace safety: introductory course",
"settings": {
"lessonProgressMode": "Sequence"
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const updateCourse = async (courseId) => {
try {
const response = await axios.put(`https://api.exode.biz/saas/v2/course/${courseId}/update`, {
name: 'Workplace safety: introductory course',
settings: {
lessonProgressMode: 'Sequence'
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Course updated:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
updateCourse(412);
```
```php PHP theme={null}
'Workplace safety: introductory course',
'settings' => [
'lessonProgressMode' => 'Sequence'
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
echo curl_exec($ch);
curl_close($ch);
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/course/412/update'
data = {
'name': 'Workplace safety: introductory course',
'settings': {
'lessonProgressMode': 'Sequence'
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.put(url, json=data, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Настройки = Новый Структура;
Настройки.Вставить("lessonProgressMode", "Sequence");
Данные = Новый Структура;
Данные.Вставить("name", "Workplace safety: introductory course");
Данные.Вставить("settings", Настройки);
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/course/412/update");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("PUT", Запрос);
Если Ответ.КодСостояния = 200 Тогда
Сообщить("Course updated");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 412,
"createdAt": "2026-09-28T09:12:03.518Z",
"updatedAt": "2026-09-28T10:40:17.202Z",
"archivedAt": null,
"type": "TextCourse",
"productId": 1290,
"buildStatus": "Ready",
"name": "Workplace safety: introductory course",
"description": "An introductory safety course",
"alias": null,
"tags": [
"safety"
],
"seoTags": [],
"image": {
"main": ""
},
"promoVideo": null,
"settings": {
"editorAccessMode": "All",
"curatorAccessMode": "All",
"lessonProgressMode": "Sequence"
},
"order": 0,
"isBundle": false
}
}
```
```json Error - Course Not Found / Other School theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
```json Error - Not Assigned To Course theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - product 1290 permissions CourseManage",
"error": "Forbidden seller resource - product 1290 permissions CourseManage"
}
```
```json Error - Alias Already Busy theme={null}
{
"code": 400,
"success": false,
"cause": "AliasAlreadyBusy",
"message": "Этот адрес уже занят",
"error": "Этот адрес уже занят"
}
```
## Errors
| HTTP | `cause` | When it happens |
| - | - | - |
| 400 | `validation` | The body failed validation (type, length, enum) |
| 400 | `InvalidAlias` / `AliasAlreadyBusy` | Invalid or taken `alias` |
| 400 | `CertificateTemplateRequired` / `CertificateTemplateNotAvailable` | The certificate is enabled without a template, or the template is not available to the school |
| 400 | `BundleGroupAlreadyUsed` / `BundleCourseSellerMismatch` | Invalid `bundleCourses` composition |
| 401 | `Forbidden` | The course is not found or belongs to another school (`seller not entity owner`); the key lacks `CourseManage`; the course uses the "Assigned" mode and the key is not assigned to it (`product permissions CourseManage`) |
## Related sections
* [Create a course](/en/exode-api/school/course/create) — create a course with modules, lessons and blocks
* [Get a course](/en/exode-api/school/course/get) — the current state of the course
* [The `course` object](/en/exode-api/objects/entities/course) — response fields
## Permission requirements
Requires token authentication and the [**"Course Management"**](/en/exode-api/permissions) permission
(`CourseManage`). The method is available to schools only.
***
*Updated: 2026-09-28 05:04 UTC*
# Get field values
Source: https://docs.exode.biz/en/exode-api/school/custom-field/get
Get custom field values of school users with filtering and pagination
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/form/custom-field/value/get
```
Requires authentication and the [**"Forms management"**](/en/exode-api/permissions) permission (`FormManage`).
## Request parameters
All filter parameters are optional. If you pass no filters, all available custom field values within the school are returned.
Array parameters are passed by repeating the parameter in the query string: `userIds=1&userIds=2&userIds=3`.
### Pagination
Number of records to skip. Defaults to `0`.
Number of records per page. From `1` to `1000`. Defaults to `100`.
Page number (starting at `1`). An alternative to `skip` — the offset is calculated automatically as `(page - 1) * take`.
### Sorting
Sort by record ID. Possible values: `ASC`, `DESC`.
Sort by creation date. Possible values: `ASC`, `DESC`.
Sort by update date. Possible values: `ASC`, `DESC`.
Sort by the field's order number. Possible values: `ASC`, `DESC`.
### Filtering
An array of user IDs. Returns field values only for the specified users.
An array of field IDs. Returns values only for the specified fields.
An array of field slugs. An alternative to `fieldIds` for filtering by symbolic codes.
An array of form fill IDs. Returns values bound to specific fills.
An array of form layout UUIDs.
An array of form layout slugs.
An array of layout modes. Possible values: `Custom`, `Form`, `Signup`, `Welcome`, `Participant`.
An array of product IDs. Returns values related to the specified products.
Values of fields with `api = false` in the RBAC settings are automatically excluded from the response. Field visibility is configured in the school's admin panel.
The exclusion happens after the page is fetched: `count` includes hidden values too, so a page may
contain fewer items than `take`. To iterate over pages, rely on `isLast` / `pages`, not on
the size of `items`.
You can get `fieldId`, the field `slug` and `layoutId` from the nested `field` object in this method's response, and
the list of forms with their `id` from the [form list](/en/exode-api/school/form-layout/list). The form fields themselves are created and
configured in the school's admin panel — they cannot be created via the API.
```bash cURL theme={null}
curl --location --request GET 'https://api.exode.biz/saas/v2/form/custom-field/value/get?userIds=27&userIds=42&fieldSlugs=city&fieldSlugs=company&createdAt=DESC' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const getCustomFieldValues = async () => {
try {
const response = await axios.get('https://api.exode.biz/saas/v2/form/custom-field/value/get', {
params: {
userIds: [ 27, 42 ],
fieldSlugs: [ 'city', 'company' ],
createdAt: 'DESC'
},
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Values:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
getCustomFieldValues();
```
```php PHP theme={null}
[ 27, 42 ],
'fieldSlugs' => [ 'city', 'company' ]
], '', '&', PHP_QUERY_RFC3986);
// For arrays use the format: userIds=27&userIds=42
$queryString = 'userIds=27&userIds=42&fieldSlugs=city&fieldSlugs=company&createdAt=DESC';
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url . '?' . $queryString);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$result = json_decode($response, true);
echo "Values retrieved successfully\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/form/custom-field/value/get'
params = [
('userIds', 27),
('userIds', 42),
('fieldSlugs', 'city'),
('fieldSlugs', 'company'),
('createdAt', 'DESC')
]
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.get(url, params=params, headers=headers)
response.raise_for_status()
result = response.json()
print('Values retrieved successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/form/custom-field/value/get?userIds=27&userIds=42&fieldSlugs=city&fieldSlugs=company&createdAt=DESC");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Field values retrieved");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"count": 3,
"page": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"next": { "skip": 0, "take": 100, "page": 1 },
"prev": { "skip": 0, "take": 100, "page": 1 },
"items": [
{
"id": 1,
"userId": 27,
"fieldId": 10,
"fillId": null,
"value": "Tashkent",
"createdAt": "2025-03-10T12:00:00.000Z",
"updatedAt": "2025-03-10T12:00:00.000Z",
"field": {
"id": 10,
"slug": "city",
"type": "Text",
"order": 0,
"layoutId": 5
}
},
{
"id": 2,
"userId": 27,
"fieldId": 11,
"fillId": null,
"value": "Acme LLC",
"createdAt": "2025-03-10T12:00:00.000Z",
"updatedAt": "2025-03-10T12:00:00.000Z",
"field": {
"id": 11,
"slug": "company",
"type": "Text",
"order": 1,
"layoutId": 5
}
},
{
"id": 3,
"userId": 42,
"fieldId": 10,
"fillId": null,
"value": "Tashkent",
"createdAt": "2025-03-10T14:30:00.000Z",
"updatedAt": "2025-03-10T14:30:00.000Z",
"field": {
"id": 10,
"slug": "city",
"type": "Text",
"order": 0,
"layoutId": 5
}
}
]
}
}
```
```json Success - Empty Result theme={null}
{
"success": true,
"code": 200,
"payload": {
"count": 0,
"page": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"next": { "skip": 0, "take": 100, "page": 1 },
"prev": { "skip": 0, "take": 100, "page": 1 },
"items": []
}
}
```
```json Error - Unauthorized theme={null}
{
"code": 401,
"success": false,
"cause": "Unauthorized",
"message": "Unauthorized",
"error": "Unauthorized"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - permissions FormManage",
"error": "Forbidden seller resource - permissions FormManage"
}
```
## Response fields
Each item contains:
* `value` — the field value (a string, number, boolean, date or JSON object, depending on the field type)
* `field` — a nested object with field information (`id`, `slug`, `type`, `order`, `layoutId`, `props`, `preference`, `permissions`)
* `userId`, `fieldId` — identifiers for linking
* typed columns (`text`, `number`, `boolean`, `date`, `json`) are also present in the response depending on the field type; the convenience field `value` holds the already-cast value
## Permission requirements
Getting custom field values requires the service user to have the **"Forms management"** permission (`FormManage`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
Data is returned only within the specified school. Field values of users from other schools are not included in the response.
***
*Updated: 2026-09-25 14:33 UTC*
# Set field values
Source: https://docs.exode.biz/en/exode-api/school/custom-field/set
Create or update custom field values for a school user
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
POST /saas/v2/form/custom-field/value/set
POST /saas/v2/form/custom-field/value/set-by-slug
```
Requires authentication and the [**"Forms management"**](/en/exode-api/permissions) permission (`FormManage`).
## Request parameters
Two formats are available for passing values: **by slug** (recommended) and **by fieldId** (advanced).
Numeric ID of the Exode user (the `id` field from [`user/find`](/en/exode-api/school/user/find)) for whom
the field values are set. The user must belong to the school from the `School-Id` header.
ID of the form layout the fields belong to — the `id` field from the [form list](/en/exode-api/school/form-layout/list).
All fields from `values` must belong to this layout (otherwise — `FieldNotBelongsToLayout`).
**Where to get `slug` and `fieldId`.** Form fields are created in the school's admin panel (not through the API); the
field's code (`slug`) is set there as well. `fieldId`, `slug` and the field type are also returned by
[getting values](/en/exode-api/school/custom-field/get) in the nested `field` object.
**Which method to choose:** `set-by-slug` is more convenient — the slug is stable and the value type is matched to the field
automatically; use `set` if you already store `fieldId` on your side.
### Format 1: By slug (recommended)
**Endpoint:** `POST /saas/v2/form/custom-field/value/set-by-slug`
Simplified format — the value type is determined automatically based on the field type in the layout.
Array of field values. At least 1 element.
The field code.
The field value. The type must match the field type:
| Field type | Expected `value` type | Example |
| - | - | - |
| Text, Textarea, Select, Radio | `string` | `"Tashkent"` |
| Number | `number` | `25` |
| Boolean, Switch, Checkbox | `boolean` | `true` |
| Date | `string` (ISO 8601) | `"2025-03-10T12:00:00.000Z"` |
| Json, File, Multiselect | `object` | `{"key": "value"}` |
Pass `null` to clear the value.
### Format 2: By fieldId (advanced)
**Endpoint:** `POST /saas/v2/form/custom-field/value/set`
Typed format — you specify the column for the value yourself.
Array of field values. At least 1 element.
ID of the field for which the value is set.
Text value. For fields: Text, Select, Radio, Textarea.
Numeric value. For fields: Number.
Boolean value. For fields: Boolean, Switch, Checkbox.
Date in ISO 8601 format. For fields: Date.
JSON object. For fields: Json, File, Multiselect.
Both methods work in **upsert** mode — if a field value already exists for this user, it is
updated. If it doesn't exist, it is created. One value is stored per "user + field" pair, so
a repeated call is safe and simply overwrites the value. Fields not passed in `values` are not changed.
If at least one field from the request is not found or not writable, the entire request is rejected.
Fields whose RBAC settings have `api = false` (API access denied) cannot be written
through this method — the `FieldWritePermissionDenied` error is returned. The permission is changed in the field settings in the admin panel.
```bash By slug (cURL) theme={null}
curl --location 'https://api.exode.biz/saas/v2/form/custom-field/value/set-by-slug' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userId": 27,
"layoutId": 5,
"values": [
{ "slug": "city", "value": "Tashkent" },
{ "slug": "age", "value": 25 },
{ "slug": "is-active", "value": true }
]
}'
```
```bash By fieldId (cURL) theme={null}
curl --location 'https://api.exode.biz/saas/v2/form/custom-field/value/set' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userId": 27,
"layoutId": 5,
"values": [
{ "fieldId": 10, "text": "Tashkent" },
{ "fieldId": 11, "number": 25 },
{ "fieldId": 12, "boolean": true }
]
}'
```
```javascript Node.js (by slug) theme={null}
const axios = require('axios');
const setFieldValues = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/form/custom-field/value/set-by-slug', {
userId: 27,
layoutId: 5,
values: [
{ slug: 'city', value: 'Tashkent' },
{ slug: 'age', value: 25 },
{ slug: 'is-active', value: true }
]
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Values set:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
setFieldValues();
```
```php PHP (by slug) theme={null}
27,
'layoutId' => 5,
'values' => [
[ 'slug' => 'city', 'value' => 'Tashkent' ],
[ 'slug' => 'age', 'value' => 25 ],
[ 'slug' => 'is-active', 'value' => true ]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 201) {
$result = json_decode($response, true);
echo "Values set successfully\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python (by slug) theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/form/custom-field/value/set-by-slug'
data = {
'userId': 27,
'layoutId': 5,
'values': [
{ 'slug': 'city', 'value': 'Tashkent' },
{ 'slug': 'age', 'value': 25 },
{ 'slug': 'is-active', 'value': True }
]
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('Values set successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1C (by slug) theme={null}
Значение1 = Новый Структура;
Значение1.Вставить("slug", "city");
Значение1.Вставить("value", "Tashkent");
Значение2 = Новый Структура;
Значение2.Вставить("slug", "age");
Значение2.Вставить("value", 25);
Значение3 = Новый Структура;
Значение3.Вставить("slug", "is-active");
Значение3.Вставить("value", Истина);
Значения = Новый Массив;
Значения.Добавить(Значение1);
Значения.Добавить(Значение2);
Значения.Добавить(Значение3);
Данные = Новый Структура;
Данные.Вставить("userId", 27);
Данные.Вставить("layoutId", 5);
Данные.Вставить("values", Значения);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/form/custom-field/value/set-by-slug");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Field values saved");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": [
{
"id": 1,
"userId": 27,
"fieldId": 10,
"fillId": null,
"value": "Tashkent",
"createdAt": "2025-03-10T12:00:00.000Z",
"updatedAt": "2025-03-10T12:00:00.000Z"
},
{
"id": 2,
"userId": 27,
"fieldId": 11,
"fillId": null,
"value": 25,
"createdAt": "2025-03-10T12:00:00.000Z",
"updatedAt": "2025-03-10T12:00:00.000Z"
},
{
"id": 3,
"userId": 27,
"fieldId": 12,
"fillId": null,
"value": true,
"createdAt": "2025-03-10T12:00:00.000Z",
"updatedAt": "2025-03-10T12:00:00.000Z"
}
]
}
```
```json Error - Field Not Found (slug) theme={null}
{
"code": 400,
"success": false,
"cause": "FieldNotFound",
"message": "Fields not found: unknown-slug",
"error": "Fields not found: unknown-slug"
}
```
```json Error - User Not In School theme={null}
{
"code": 400,
"success": false,
"cause": "UserNotBelongsToSchool",
"message": "User does not belong to the school",
"error": "User does not belong to the school"
}
```
```json Error - Layout Not Owned By Seller theme={null}
{
"code": 400,
"success": false,
"cause": "LayoutNotOwnedBySeller",
"message": "Layout does not belong to this seller",
"error": "Layout does not belong to this seller"
}
```
```json Error - Write Denied theme={null}
{
"code": 400,
"success": false,
"cause": "FieldWritePermissionDenied",
"message": "No Write permission for field 12",
"error": "No Write permission for field 12"
}
```
## Permission requirements
Setting custom field values requires the service user to have the **"Forms management"** permission (`FormManage`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
The form layout (`layoutId`) must belong to the current school. The user (`userId`) must be a member of this school.
***
*Updated: 2026-09-25 14:33 UTC*
# Create a form layout
Source: https://docs.exode.biz/en/exode-api/school/form-layout/create
Create a new form layout for the school with mode, status and product binding settings
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
POST /saas/v2/form/layout/create
```
Requires authentication and the [**"Forms management"**](/en/exode-api/permissions) permission (`FormManage`).
**Layout and custom fields.** A form layout is a container for fields. Only the layout itself is created via the API;
the fields in it (their type and `slug`) are added in the school's admin panel. Field values for specific users
are read and written with the [get](/en/exode-api/school/custom-field/get) and
[set values](/en/exode-api/school/custom-field/set) methods, where the layout is passed as `layoutId` — the `id` from
this method's response.
## Request parameters
Form layout mode. Determines where and how the form is used.
Possible values:
* `Custom` — user custom fields
* `Form` — a fillable form (questionnaire)
* `Signup` — sign-up form (one per school)
* `Welcome` — welcome form (one per school)
* `Participant` — product participant form
Form layout name. Up to 255 characters.
Internal layout name. Up to 255 characters. Used to identify the layout in the management interface.
Layout status. Possible values: `Draft`, `Published`. Defaults to `Draft`.
Symbolic code of the layout. From 1 to 50 characters. Must be unique within the seller (school).
A note for the layout. Up to 255 characters.
An array of IDs of products the layout is bound to — the `productId` field from the
[course list](/en/exode-api/school/course/list).
Layout settings.
Form resubmission mode. Possible values:
* `Overwrite` — overwrite the existing fill (default)
* `NewFill` — create a new fill
* `NotAllowed` — resubmission is not allowed
For the `Signup` and `Welcome` modes, only one layout per seller (school) is allowed. Attempting to create a second layout with the same mode returns the `LayoutWithModeAlreadyExists` error.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/form/layout/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"mode": "Custom",
"name": "Additional client data",
"internalName": "CRM extra fields",
"status": "Published",
"slug": "client-extra",
"note": "Additional fields for CRM",
"config": {
"resubmitMode": "Overwrite"
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const createLayout = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/form/layout/create', {
mode: 'Custom',
name: 'Additional client data',
internalName: 'CRM extra fields',
status: 'Published',
slug: 'client-extra',
note: 'Additional fields for CRM',
config: {
resubmitMode: 'Overwrite'
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Layout created:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
createLayout();
```
```php PHP theme={null}
'Custom',
'name' => 'Additional client data',
'internalName' => 'CRM extra fields',
'status' => 'Published',
'slug' => 'client-extra',
'note' => 'Additional fields for CRM',
'config' => [
'resubmitMode' => 'Overwrite'
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 201) {
$result = json_decode($response, true);
echo "Layout created successfully\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/form/layout/create'
data = {
'mode': 'Custom',
'name': 'Additional client data',
'internalName': 'CRM extra fields',
'status': 'Published',
'slug': 'client-extra',
'note': 'Additional fields for CRM',
'config': {
'resubmitMode': 'Overwrite'
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('Layout created successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Конфигурация = Новый Структура;
Конфигурация.Вставить("resubmitMode", "Overwrite");
Данные = Новый Структура;
Данные.Вставить("mode", "Custom");
Данные.Вставить("name", "Additional client data");
Данные.Вставить("internalName", "CRM extra fields");
Данные.Вставить("status", "Published");
Данные.Вставить("slug", "client-extra");
Данные.Вставить("note", "Additional fields for CRM");
Данные.Вставить("config", Конфигурация);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/form/layout/create");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Form created");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 5,
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"mode": "Custom",
"status": "Published",
"slug": "client-extra",
"name": "Additional client data",
"internalName": "CRM extra fields",
"note": "Additional fields for CRM",
"sellerId": 1,
"createdAt": "2025-03-10T12:00:00.000Z",
"updatedAt": "2025-03-10T12:00:00.000Z",
"config": {
"resubmitMode": "Overwrite"
}
}
}
```
```json Error - Duplicate Slug theme={null}
{
"code": 400,
"success": false,
"cause": "LayoutWithSlugAlreadyExists",
"message": "Form layout with slug \"client-extra\" already exists",
"error": "Form layout with slug \"client-extra\" already exists"
}
```
```json Error - Duplicate Mode (Signup / Welcome) theme={null}
{
"code": 400,
"success": false,
"cause": "LayoutWithModeAlreadyExists",
"message": "Form layout with mode \"Signup\" already exists",
"error": "Form layout with mode \"Signup\" already exists"
}
```
```json Error - Validation theme={null}
{
"code": 400,
"success": false,
"cause": "BadRequest",
"message": "name must be shorter than or equal to 255 characters",
"error": "name must be shorter than or equal to 255 characters"
}
```
## Permission requirements
Creating a form layout requires the service user to have the **"Forms management"** permission (`FormManage`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# Delete a form layout
Source: https://docs.exode.biz/en/exode-api/school/form-layout/delete
Soft-delete a form layout by its ID
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
DELETE /saas/v2/form/layout/:layoutId/delete
```
Requires authentication and the [**"Forms management"**](/en/exode-api/permissions) permission (`FormManage`).
## Path parameters
ID of the form layout to delete. The layout must belong to the current school.
The deletion is a **soft delete** — the layout is marked as deleted but is not physically removed from the database. Related fields and values remain unchanged.
After deletion, the layout's `slug` is released and can be used for a new layout (the same applies to the
single layouts of the `Signup` / `Welcome` modes). A deleted layout cannot be restored via the API. Calling the
method again for an already deleted layout returns `401` with the message `seller not entity owner`.
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/form/layout/5/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const deleteLayout = async () => {
const layoutId = 5;
try {
const response = await axios.delete(`https://api.exode.biz/saas/v2/form/layout/${layoutId}/delete`, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Layout deleted:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
deleteLayout();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
import json
layout_id = 5
url = f'https://api.exode.biz/saas/v2/form/layout/{layout_id}/delete'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.delete(url, headers=headers)
response.raise_for_status()
result = response.json()
print('Layout deleted successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/form/layout/5/delete");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("DELETE", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Form deleted");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Layout Not Found / Not Owned theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - permissions FormManage",
"error": "Forbidden seller resource - permissions FormManage"
}
```
## Permission requirements
Deleting a form layout requires the service user to have the **"Forms management"** permission (`FormManage`).
The layout must belong to the current school (checked automatically).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# Form layout list
Source: https://docs.exode.biz/en/exode-api/school/form-layout/list
Paginated list of the school's form layouts with filtering by mode and status, and search
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/form/layout/list
```
Requires authentication and the [**"Forms management"**](/en/exode-api/permissions) permission (`FormManage`).
All filter parameters are optional. Without filters, the method returns all of the school's form layouts, paginated.
The layout structure is the same as in the [`form/layout/create`](/en/exode-api/school/form-layout/create) response.
Array parameters are passed by repeating the parameter: `modes=Form&modes=Signup`.
## Request parameters
### Pagination
Number of records per page. From `1` to `1000`. Defaults to `100`.
Page number (starting at `1`). An alternative to `skip`.
Number of records to skip. Defaults to `0`.
### Filtering
Filter by layout IDs.
Filter by layout UUIDs.
Filter by layout slugs.
Filter by layout mode: `Form` (form submission), `Signup` (user fields at registration),
`Custom`, `Welcome`, `Participant` (product participant questionnaire).
Filter by layout status: `Draft`, `Published`.
Filter by product IDs (for layouts linked to a product, such as participant questionnaires).
Search by layout name. Maximum 50 characters.
### Sorting
Sort by ID: `ASC` or `DESC`.
Sort by creation date: `ASC` or `DESC`.
## Response fields
Paginated list of form layouts.
Array of layouts.
Layout ID.
Layout UUID.
Layout slug.
Layout name.
Internal name.
Note.
Mode: `Form`, `Signup`, `Custom`, `Welcome`, `Participant`.
Status: `Draft`, `Published`.
Layout configuration (fields, settings).
Seller ID.
Whether the layout has edits.
Creation date (ISO 8601).
Update date (ISO 8601).
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Parameters of the next page (`skip`, `take`, `page`).
Parameters of the previous page (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/form/layout/list?take=20&modes=Signup&statuses=Published' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listFormLayouts = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/form/layout/list', {
params: { take: 20, modes: ['Signup'], statuses: ['Published'] },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listFormLayouts();
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/form/layout/list'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={
'take': 20,
'modes': ['Signup'],
'statuses': ['Published'],
}, headers=headers)
print(response.json()['payload']['items'])
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/form/layout/list?take=20&modes=Signup&statuses=Published");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Layout list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 2,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 34,
"createdAt": "2026-05-12T09:30:00.000Z",
"updatedAt": "2026-06-01T14:20:00.000Z",
"uuid": "b3f1a2c4-88f0-4f4e-9a2f-1c2d3e4f5a6b",
"slug": "signup-main",
"name": "Registration form",
"internalName": "Main questionnaire",
"note": null,
"mode": "Signup",
"status": "Published",
"config": { "fields": [] },
"sellerId": 77,
"isEdited": false
}
],
"next": { "skip": 20, "take": 20, "page": 2 },
"prev": { "skip": 0, "take": 20, "page": 1 }
}
}
```
## Permission requirements
Requires token authentication and the **"Forms management"** permission (`FormManage`).
***
*Updated: 2026-09-25 14:33 UTC*
# Update a form layout
Source: https://docs.exode.biz/en/exode-api/school/form-layout/update
Update an existing form layout by its ID
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
PUT /saas/v2/form/layout/:layoutId/update
```
Requires authentication and the [**"Forms management"**](/en/exode-api/permissions) permission (`FormManage`).
## Path parameters
ID of the form layout to update. The layout must belong to the current school.
## Request parameters
All request body parameters are optional. Pass only the fields you want to update.
Form layout mode. Possible values: `Custom`, `Form`, `Signup`, `Welcome`, `Participant`.
Form layout name. Maximum 255 characters.
Internal layout name. Maximum 255 characters.
Layout status. Possible values: `Draft`, `Published`.
Layout slug. From 1 to 50 characters. Must be unique within the seller (school).
Layout note. Maximum 255 characters.
Array of IDs of the products the layout is linked to. When passed, it fully replaces the current list of linked products.
Layout settings.
Form resubmission mode. Possible values: `Overwrite`, `NewFill`, `NotAllowed`.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/form/layout/5/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Updated name",
"status": "Published",
"config": {
"resubmitMode": "NotAllowed"
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const updateLayout = async () => {
const layoutId = 5;
try {
const response = await axios.put(`https://api.exode.biz/saas/v2/form/layout/${layoutId}/update`, {
name: 'Updated name',
status: 'Published',
config: {
resubmitMode: 'NotAllowed'
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Layout updated:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
updateLayout();
```
```php PHP theme={null}
'Updated name',
'status' => 'Published',
'config' => [
'resubmitMode' => 'NotAllowed'
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$result = json_decode($response, true);
echo "Layout updated successfully\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
layout_id = 5
url = f'https://api.exode.biz/saas/v2/form/layout/{layout_id}/update'
data = {
'name': 'Updated name',
'status': 'Published',
'config': {
'resubmitMode': 'NotAllowed'
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.put(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('Layout updated successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Конфигурация = Новый Структура;
Конфигурация.Вставить("resubmitMode", "NotAllowed");
Данные = Новый Структура;
Данные.Вставить("name", "Updated name");
Данные.Вставить("status", "Published");
Данные.Вставить("config", Конфигурация);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/form/layout/5/update");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("PUT", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Form updated");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 5,
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"mode": "Custom",
"status": "Published",
"slug": "client-extra",
"name": "Updated name",
"note": "Additional fields for CRM",
"sellerId": 1,
"createdAt": "2025-03-10T12:00:00.000Z",
"updatedAt": "2025-03-12T09:15:00.000Z",
"config": {
"resubmitMode": "NotAllowed"
}
}
}
```
```json Error - Layout Not Found / Not Owned theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
```json Error - Duplicate Slug theme={null}
{
"code": 400,
"success": false,
"cause": "LayoutWithSlugAlreadyExists",
"message": "Form layout with slug \"client-extra\" already exists",
"error": "Form layout with slug \"client-extra\" already exists"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - permissions FormManage",
"error": "Forbidden seller resource - permissions FormManage"
}
```
## Permission requirements
Updating a form layout requires the service user to have the **"Forms management"** permission (`FormManage`).
The layout must belong to the current school (checked automatically).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# Add members
Source: https://docs.exode.biz/en/exode-api/school/group-member/create-many
Add multiple users to a school group
This operation adds multiple users to a group at once. If a user is already
a member of the group, they are not added again.
When users are added to a group, the related member and access records are created automatically. If a user is already
a member of the group, they are not added again.
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
POST /saas/v2/group/:groupId/member/create-many
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
## Request parameters
The maximum number of users you can add in a single request is 250. To add more
users, use several requests.
Group ID — the `groupId` field from the [group list](/en/exode-api/school/group/list). To enroll a user in a
course, find that course's group with the `courseIds` filter (see [Course enrollment](/en/exode-api/school/course/enroll)).
The group must be bound to a product, otherwise the `GroupNotBoundToProduct` error is returned.
An array of numeric user IDs in Exode (the `id` field from [`user/find`](/en/exode-api/school/user/find) or
[`user/list`](/en/exode-api/school/user/list)). Up to 250 users per request.
If a user is already a member of the group, they are not added again. The response shows which
users already exist in the group and which were added. IDs not found in the school from the `School-Id` header
are silently skipped and appear in neither `exist` nor `created` — reconcile the response with the request. Repeating
the call with the same `userIds` is safe.
For each **new** member, access to the group's product is granted (or reactivated if it was disabled),
and the [`ProductEnrolledViaLms`](/en/exode-api/webhooks/about) webhook is sent. For users in `exist`,
access and webhooks are not affected.
```bash cURL theme={null}
curl --location --request POST 'https://api.exode.biz/saas/v2/group/{{ groupId }}/member/create-many' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userIds": [8, 15, 23]
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const addMembersToGroup = async () => {
try {
const response = await axios.post(
'https://api.exode.biz/saas/v2/group/{{ groupId }}/member/create-many',
{
userIds: [8, 15, 23]
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
}
);
console.log('Members added:', response.data.payload.created);
console.log('Existing members:', response.data.payload.exist);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
addMembersToGroup();
```
```php PHP theme={null}
[8, 15, 23]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 201) {
$result = json_decode($response, true);
echo "Members added successfully\n";
echo "Created: " . count($result['payload']['created']) . "\n";
echo "Existing: " . count($result['payload']['exist']) . "\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/group/{{ groupId }}/member/create-many'
data = {
'userIds': [8, 15, 23]
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('Members added successfully:')
print(f"Created: {len(result['payload']['created'])}")
print(f"Existing: {len(result['payload']['exist'])}")
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
ИдентификаторыПользователей = Новый Массив;
ИдентификаторыПользователей.Добавить(8);
ИдентификаторыПользователей.Добавить(15);
ИдентификаторыПользователей.Добавить(23);
Данные = Новый Структура;
Данные.Вставить("userIds", ИдентификаторыПользователей);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/group/{{ groupId }}/member/create-many");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Members added");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success - New Members Added theme={null}
{
"success": true,
"code": 201,
"payload": {
"exist": [],
"created": [
{
"id": 1227,
"createdAt": "2025-07-19T15:45:51.212Z",
"updatedAt": "2025-07-19T15:45:51.212Z",
"groupId": 501,
"userId": 15,
"inviterId": 42,
"active": true,
"blockedUntil": null,
"isAddedToTg": false,
"user": {
"id": 15,
"uuid": "YRnh3REH1Wbd",
"status": "Active",
"active": true,
"activated": true,
"banned": false,
"domain": "id15",
"email": "user@example.com",
"phone": "+987654321",
"extId": "crm_12345",
"language": "Ru",
"timezone": 5,
"lastOnlineAt": "2025-07-20T19:14:03.972Z",
"starsBalance": 0
}
}
],
"excluded": []
}
}
```
```json Success - Some Members Already Exist theme={null}
{
"success": true,
"code": 201,
"payload": {
"exist": [
{
"id": 1226,
"createdAt": "2025-07-19T15:45:51.212Z",
"updatedAt": "2025-07-19T15:45:51.212Z",
"groupId": 501,
"userId": 8,
"inviterId": 42,
"active": true,
"blockedUntil": null,
"isAddedToTg": false,
"user": {
"id": 8,
"uuid": "YRnh3REH1Wbd",
"status": "Active",
"active": true,
"activated": true,
"banned": false,
"domain": "id8",
"email": "test+school+omar@exode.ru",
"phone": null,
"extId": "crm_54321",
"language": "Ru",
"timezone": 5,
"lastOnlineAt": "2025-07-20T19:14:03.972Z",
"starsBalance": 12
}
}
],
"created": [],
"excluded": []
}
}
```
```json Error - Group Not Found / Not Owned theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
```json Error - Insufficient Permissions theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - permissions SchoolManageUsers",
"error": "Forbidden seller resource - permissions SchoolManageUsers"
}
```
```json Error - Too Many Users theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": "Max length is 250 elements",
"error": "Bad Request"
}
```
```json Error - userIds Is Not An Array theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": "ids must be an array",
"error": "Bad Request"
}
```
```json Error - Group Not Tied To Product theme={null}
{
"code": 400,
"success": false,
"cause": "GroupNotBoundToProduct",
"message": "Group not tied to product",
"error": "Group not tied to product"
}
```
## Response parameters
An array of users who are already members of the group.
Unique identifier of the group member.
Date and time the member record was created.
Date and time the member record was last updated.
Group ID.
User ID.
ID of the user who added the member.
Whether the member is active in the group.
Date until which the member is blocked (if applicable).
Whether the member has been added to the group's Telegram chat/channel.
User object.
Unique identifier of the user.
User UUID.
User status: `Active`, `OnLeave`, `Banned`, `Blocked`, `Terminated`, `Deleted`.
Not deleted (derived from `status`).
Whether the user has confirmed sign-in.
Banned or deleted (derived from `status`).
The user's registration domain.
The user's email address.
The user's phone number.
The user's external identifier.
The user's interface language.
The user's time zone (offset).
Date and time of last activity.
The user's stars balance.
An array of users who were added to the group. Each item has the same structure as the items of the
`exist` array.
An array of users skipped because they are excluded from automatic assignment. Items are user
objects (the same structure as the nested `user` in `exist` items).
For this method the array is **always empty**: exclusions only limit automatic assignment by
org structure rules, and adding via the API counts as manual. The field is always present in the response — take
it into account if you validate the schema strictly.
Manual addition **removes** a previously set exclusion: if the user was earlier removed from the group
manually, after adding them via the API the automatic assignment rules can assign them again.
## Permission requirements
Adding members to a group requires the **"School User Management"** permission (`SchoolManageUsers`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# Remove members
Source: https://docs.exode.biz/en/exode-api/school/group-member/delete-many
Remove multiple users from a school group
This operation removes multiple users from a group at once.
When members are removed from a group, the system automatically checks their product accesses:
-> If the user remains a member of other groups with the same product, the access is kept.
-> If the user is no longer a member of any group with this product, the access is fully deactivated.
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
DELETE /saas/v2/group/:groupId/member/delete-many
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
## Request parameters
The maximum number of users you can remove in one request is 250. To remove more
users, use several requests.
Group ID: the `groupId` field from the [group list](/en/exode-api/school/group/list).
Array of numeric Exode user IDs (not the member record IDs `id` from the
[member list](/en/exode-api/school/group-member/list), but their `userId`). Up to 250 users per
request.
If a user is not a member of the group, they are ignored. The response contains the number of
members actually removed.
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/group/{{ groupId }}/member/delete-many' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userIds": [8, 15, 23]
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const removeMembersFromGroup = async () => {
try {
const response = await axios.delete(
'https://api.exode.biz/saas/v2/group/{{ groupId }}/member/delete-many',
{
data: {
userIds: [8, 15, 23]
},
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
}
);
console.log('Members removed:', response.data.payload.affected);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
removeMembersFromGroup();
```
```php PHP theme={null}
[8, 15, 23]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$result = json_decode($response, true);
echo "Members removed successfully\n";
echo "Affected: " . $result['payload']['affected'] . "\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/group/{{ groupId }}/member/delete-many'
data = {
'userIds': [8, 15, 23]
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.delete(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('Members removed successfully:')
print(f"Affected: {result['payload']['affected']}")
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
ИдентификаторыПользователей = Новый Массив;
ИдентификаторыПользователей.Добавить(8);
ИдентификаторыПользователей.Добавить(15);
ИдентификаторыПользователей.Добавить(23);
Данные = Новый Структура;
Данные.Вставить("userIds", ИдентификаторыПользователей);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/group/{{ groupId }}/member/delete-many");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("DELETE", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Members removed");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Success - No Members Found theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 0
}
}
```
```json Error - Group Not Found / Not Owned theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
```json Error - Insufficient Permissions theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - permissions SchoolManageUsers",
"error": "Forbidden seller resource - permissions SchoolManageUsers"
}
```
```json Error - Too Many Users theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": "Max length is 250 elements",
"error": "Bad Request"
}
```
```json Error - userIds Is Not An Array theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": "ids must be an array",
"error": "Bad Request"
}
```
## Response parameters
Number of group members actually removed. Users who were not members of the group are not counted in
this number.
## Interaction with automatic assignment
Removal via the API is considered **manual** and blocks subsequent automatic assignment: for each removed
user, an exclusion is created with the reason "removed from the group manually". Assignment rules based on the org structure will no longer
add the user back to this group.
Exclusions are created only for corporate groups that have automatic assignment configured. To lift
the block, add the user back via
[adding members](/en/exode-api/school/group-member/create-many); this clears the exclusion.
If you synchronize group membership from an external system (for example, from 1C), keep this in mind for regular exports:
removing a member in one synchronization iteration permanently excludes them from automatic assignment until they
are added back via the API.
## Permission requirements
Removing members from a group requires the **"School User Management"** permission (`SchoolManageUsers`).
The service user must be authenticated with a token and have the appropriate access rights to the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# List group members
Source: https://docs.exode.biz/en/exode-api/school/group-member/list
Get a list of school group members filtered by group, user and date added
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/group/member/list/raw
```
All filter parameters are optional. If you pass no filters, all members
of all school groups are returned, subject to pagination.
Pass array parameters by repeating the parameter in the query string: `groupIds=1&groupIds=2&groupIds=3`.
## Request parameters
### Pagination
Number of records to skip. Default: `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. From `1` to `1000`. Default: `100`.
### Filtering
Filter by group IDs.
Filter by member user IDs.
Filter by member record IDs.
Filter by IDs of inviting users.
Filter by IDs of the products the groups are linked to.
Filter by whether the member is active in the group.
Search by the member's login (email, phone) and name. Maximum 50 characters.
Date range of being added to the group.
Range start (ISO 8601), exclusive.
Range end (ISO 8601), inclusive.
## Response fields
Paginated list of group members. For the base member structure, see the
[`groupMember`](/en/exode-api/objects/entities/group-member) reference; for the user, see
the [`user`](/en/exode-api/objects/entities/user) reference.
Array of members.
Member record ID.
Group ID.
Group name.
Member user ID.
Inviting user ID.
Whether the member is active in the group.
Block end date (ISO 8601) or `null`.
How the member joined the group: `Manual`: added manually by an administrator,
`System`: added by the platform (access granted, course enrollment),
`Automatic`: added by an auto-enrollment policy.
Member user with profile: a [`user`](/en/exode-api/objects/entities/user) object.
Inviting user with profile, or `null` if the member was added by the platform.
Metadata of being added to a Telegram channel (for example, `chatInviteLink`).
Metadata of being added to a Telegram group chat (for example, `chatInviteLink`).
Date added to the group (ISO 8601).
Record update date (ISO 8601).
Archive date or `null`.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Next page parameters (`skip`, `take`, `page`).
Previous page parameters (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/group/member/list/raw?take=20&groupIds=501' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listGroupMembers = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/group/member/list/raw', {
params: { take: 20, groupIds: [501] },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listGroupMembers();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/group/member/list/raw'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'take': 20, 'groupIds': [501] }, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/group/member/list/raw?take=20&groupIds=501");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Member list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 1227,
"createdAt": "2026-07-19T15:45:51.212Z",
"updatedAt": "2026-07-19T15:45:51.212Z",
"archivedAt": null,
"groupId": 501,
"groupName": "Cohort #1",
"userId": 1683,
"inviterId": null,
"active": true,
"blockedUntil": null,
"enrollmentSource": "System",
"tgChannelMeta": {},
"tgGroupChatMeta": {},
"user": {
"id": 1683,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1683",
"email": "user@example.com",
"phone": null,
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"profile": {
"id": 1665,
"userId": 1683,
"official": false,
"firstName": "Firstname",
"lastName": "Lastname",
"fullName": "Firstname Lastname",
"sex": "Ufo",
"role": "Student"
}
},
"inviter": null
}
],
"next": {
"skip": 20,
"take": 20,
"page": 2
},
"prev": {
"skip": 0,
"take": 20,
"page": 1
}
}
}
```
## Permission requirements
Requires token authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
***
*Updated: 2026-09-25 14:33 UTC*
# List groups
Source: https://docs.exode.biz/en/exode-api/school/group/list
Get a list of school groups with filtering and pagination
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/group/list/raw
```
**Course visibility restrictions do not apply to the API.** A course has access modes
for the school team ("All" / "Assigned") that hide a restricted course from managers
in the interface. The API returns data for all school courses regardless of these modes:
access is determined only by the service user's token and its permissions.
All filter parameters are optional. If you pass no filters, all school groups are returned,
subject to pagination.
Pass array parameters by repeating the parameter in the query string: `groupIds=1&groupIds=2&groupIds=3`.
## Request parameters
### Pagination
Number of records to skip. Default: `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. From `1` to `1000`. Default: `100`.
### Filtering
Filter by group IDs.
Filter by IDs of the products the groups are linked to.
Filter by course IDs.
Search by group name. Maximum 50 characters.
## Response fields
Paginated list of groups (compact projection). For the full structure, see the
[`group`](/en/exode-api/objects/entities/group) reference.
Array of groups.
Group ID.
Group name.
ID of the linked course.
Name of the linked course.
Number of group members.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Next page parameters (`skip`, `take`, `page`).
Previous page parameters (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/group/list/raw?take=20&courseIds=10' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listGroups = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/group/list/raw', {
params: { take: 20, courseIds: [10] },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listGroups();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/group/list/raw'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'take': 20, 'courseIds': [10] }, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/group/list/raw?take=20&courseIds=10");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Group list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"groupId": 501,
"name": "Cohort #1",
"courseId": 10,
"courseName": "Exam preparation",
"membersCount": 42
}
],
"next": {
"skip": 20,
"take": 20,
"page": 2
},
"prev": {
"skip": 0,
"take": 20,
"page": 1
}
}
}
```
## Permission requirements
Requires token authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
***
*Updated: 2026-09-25 14:33 UTC*
# Sync example
Source: https://docs.exode.biz/en/exode-api/school/integrations/examples/1c-staff-sync
A complete example of syncing employees from 1C to Exode: create and update (2-in-1), HR changes, BSL code and a 1-to-1 JSON mapping
Input: the JSON export from the HR system (the client's structure, as is):
```json theme={null}
{
"partial": false,
"items": [
{
"last_name": "Ivanov",
"middle_name": "Ivanovich",
"first_name": "Ivan",
"email": "ivanov@company.ru",
"job_position_name": "Manager",
"job_position_external_id": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01",
"department_external_ids": ["ПОДР-001"],
"city": null,
"status": "Работает",
"gender": "м",
"external_id": "i.ivanov.01011990",
"leader_external_ids": ["p.petrov.15051980"],
"role": "user",
"date_of_hire": "2020-03-01T00:00:00Z",
"date_of_hire_job_poition": "2020-03-01T00:00:00Z",
"tag_list": null,
"email_for_notifications": "",
"date_of_birth": "1990-01-01T00:00:00Z",
"login": "i.ivanov.01011990",
"password": null
}
]
}
```
Each item is processed as follows: `PUT /saas/v2/user/ext/{extId}/update` (updates the profile
by external ID); if the employee does not exist (`NotFound`), `POST /saas/v2/user/create`
(creates the user **and** hires them via the `extra.staff.employments` array). The user,
department and position are addressed directly by extId, so you do not need our system's internal
IDs at all. `extra` is a container for related entities: `staff.employments` for now, and later
custom field values and so on.
```bsl theme={null}
// ============================================================
// Loading employees into Exode: update by extId → NotFound → create (2-in-1)
// ============================================================
Процедура ЗагрузитьСотрудников(СтрокаJSONВыгрузки) Экспорт
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(СтрокаJSONВыгрузки);
Выгрузка = ПрочитатьJSON(ЧтениеJSON, Ложь);
Для Каждого Сотрудник Из Выгрузка.items Цикл
// ---------- profile ----------
Профиль = Новый Структура;
Профиль.Вставить("firstName", Сотрудник.first_name); // "Ivan"
Профиль.Вставить("lastName", Сотрудник.last_name); // "Ivanov"
// middle_name ("Ivanovich") has no separate field; if needed:
// Профиль.Вставить("firstName", Сотрудник.first_name + " " + Сотрудник.middle_name);
Если ЗначениеЗаполнено(Сотрудник.date_of_birth) Тогда
Профиль.Вставить("bdate", Лев(Сотрудник.date_of_birth, 10)); // "1990-01-01"
КонецЕсли;
Если Сотрудник.gender = "м" Тогда
Профиль.Вставить("sex", "Men");
ИначеЕсли Сотрудник.gender = "ж" Тогда
Профиль.Вставить("sex", "Women");
КонецЕсли;
// ---------- employments (staff is an ARRAY, supports secondary employment) ----------
// In a Corporate school, at least one employment is REQUIRED on creation;
// without it you get the StaffEmploymentInputRequired error
Назначение = Новый Структура;
Назначение.Вставить("departmentExtId", Сотрудник.department_external_ids[0]); // "ПОДР-001"
// The employment extId is IMPORTANT to set: HR changes are addressed by it later
// (transfer/promote/terminate; see "What to do when something changes in 1C")
Назначение.Вставить("extId", Сотрудник.external_id + ":" + Сотрудник.department_external_ids[0]);
// startAt = date of appointment to the position; if absent, the company hire date
Если ЗначениеЗаполнено(ПолучитьЗначение(Сотрудник, "date_of_hire_job_poition")) Тогда
Назначение.Вставить("startAt", Сотрудник.date_of_hire_job_poition);
Иначе
Назначение.Вставить("startAt", Сотрудник.date_of_hire);
КонецЕсли;
// Position: the correct way is the GUID from 1C (extId); the name is mutable and not unique.
// The GUID is optional: if it is missing (old export format), fall back to resolving by name
ГуидДолжности = ПолучитьЗначение(Сотрудник, "job_position_external_id"); // GUID or Неопределено
Если ЗначениеЗаполнено(ГуидДолжности) Тогда
ОбеспечитьДолжностьПоГуид(ГуидДолжности, Сотрудник.job_position_name);
Назначение.Вставить("positionExtId", ГуидДолжности);
Иначе
Назначение.Вставить("positionId", ИдДолжностиПоИмени(Сотрудник.job_position_name));
КонецЕсли;
Штат = Новый Массив;
Штат.Добавить(Назначение);
// Secondary employment: a second employment is another array element
// with kind = "InternalSecondary" and its own department/position
// ---------- user ----------
Тело = Новый Структура;
Тело.Вставить("extId", Сотрудник.external_id); // "i.ivanov.01011990" is the sync key
Тело.Вставить("profile", Профиль);
// Login: if there is an email, the employee signs in with it; if not, pass domain
// so the employee can sign in with it (login + password)
Если ЗначениеЗаполнено(Сотрудник.email) Тогда
Тело.Вставить("email", Сотрудник.email);
Иначе
Тело.Вставить("domain", ДоменИзЛогина(Сотрудник.login)); // "i.ivanov.01011990" → "i.ivanov.01011990" (as is)
КонецЕсли;
Если ЗначениеЗаполнено(Сотрудник.password) Тогда
Тело.Вставить("password", Сотрудник.password);
КонецЕсли;
// status "Работает" (Working) → "Active" (lifts a previously set block).
// "Уволен" (Dismissed) is NOT mapped to a status: termination goes through employment/terminate
// (the Terminated status is set automatically; see Prerequisites).
// Active for a user terminated in Exode (Terminated) re-enables sign-in but does NOT restore
// employment; handle re-hiring as a hire (see "What to do when something changes in 1C")
Если Сотрудник.status = "Работает" Тогда
Тело.Вставить("status", "Active");
КонецЕсли;
// city, role, tag_list, email_for_notifications, date_of_hire_job_poition,
// leader_external_ids are not sent to the API
// (managers are assigned with a separate department-manager/set call)
// ---------- UPDATE: existing employee's profile (by extId, no internal IDs) ----------
// (HR changes go through /staff/employment/*; see Prerequisites)
Ответ = ВызватьAPI("PUT",
"/saas/v2/user/ext/" + КодироватьСтроку(Сотрудник.external_id, СпособКодированияСтроки.КодировкаURL) + "/update",
Тело);
Если Ответ.КодОтвета >= 300 И Ответ.Причина = "NotFound" Тогда
// ---------- CREATE: user + hire (2-in-1) ----------
// Related entities go in extra (staff.employments now, form fills etc. later)
СтаффЭкстра = Новый Структура;
СтаффЭкстра.Вставить("employments", Штат);
Экстра = Новый Структура;
Экстра.Вставить("staff", СтаффЭкстра);
Тело.Вставить("extra", Экстра);
Ответ = ВызватьAPI("POST", "/saas/v2/user/create", Тело);
КонецЕсли;
Если Ответ.КодОтвета >= 300 Тогда
ВызватьИсключение "Employee " + Сотрудник.external_id + ": " + Ответ.Причина;
КонецЕсли;
КонецЦикла;
КонецПроцедуры
// Domain allows Latin letters, digits, "_" and dots (a dot only inside:
// not first/last and not consecutive). Logins like "i.ivanov.01011990" pass as is;
// other characters are replaced with "_"
Функция ДоменИзЛогина(Логин)
Разрешённые = "abcdefghijklmnopqrstuvwxyz0123456789_.";
Результат = "";
Для Номер = 1 По СтрДлина(Логин) Цикл
Символ = НРег(Сред(Логин, Номер, 1));
Результат = Результат + ?(СтрНайти(Разрешённые, Символ) > 0, Символ, "_");
КонецЦикла;
// Dots at the edges and consecutive dots are not allowed
Пока СтрНайти(Результат, "..") > 0 Цикл
Результат = СтрЗаменить(Результат, "..", ".");
КонецЦикла;
Результат = ?(Лев(Результат, 1) = ".", Сред(Результат, 2), Результат);
Результат = ?(Прав(Результат, 1) = ".", Лев(Результат, СтрДлина(Результат) - 1), Результат);
Возврат Результат;
КонецФункции
// Position by GUID (the correct way): update the name by extId;
// if not found, create it with extId = GUID. No internal IDs needed at all.
Процедура ОбеспечитьДолжностьПоГуид(Гуид, Наименование)
Ответ = ВызватьAPI("PUT",
"/saas/v2/staff/position/ext/" + Гуид + "/update",
Новый Структура("name", Наименование)); // the name is mutable, so refresh it on every sync
Если Ответ.КодОтвета < 300 Тогда
Возврат;
КонецЕсли;
Если Ответ.Причина <> "StaffPositionNotFound" Тогда
ВызватьИсключение "Position " + Гуид + ": " + Ответ.Причина;
КонецЕсли;
Тело = Новый Структура;
Тело.Вставить("name", Наименование);
Тело.Вставить("extId", Гуид);
Ответ = ВызватьAPI("POST", "/saas/v2/staff/position/create", Тело);
Если Ответ.КодОтвета >= 300 Тогда
// StaffPositionNameIsNotUniq: a position with this name already exists under a DIFFERENT GUID.
// The name is unique within the school; consolidate the organizations' GUIDs into one on your side
ВызватьИсключение "Position """ + Наименование + """: " + Ответ.Причина;
КонецЕсли;
КонецПроцедуры
// Fallback for exports without a GUID: search by name; if not found, create
Функция ИдДолжностиПоИмени(Наименование)
Ответ = ВызватьAPI("GET",
"/saas/v2/staff/position/list?search=" + КодироватьСтроку(Наименование, СпособКодированияСтроки.КодировкаURL));
// Successful response: { "success": true, "code": 200, "payload": { "items": [...] } }
// search is fuzzy, so compare the name exactly (case-insensitive, like name uniqueness in Exode)
Для Каждого Должность Из Ответ.Тело.payload.items Цикл
Если НРег(Должность.name) = НРег(Наименование) Тогда
Возврат Должность.id;
КонецЕсли;
КонецЦикла;
Ответ = ВызватьAPI("POST", "/saas/v2/staff/position/create",
Новый Структура("name", Наименование));
Если Ответ.КодОтвета >= 300 Тогда
ВызватьИсключение "Position """ + Наименование + """: " + Ответ.Причина;
КонецЕсли;
Возврат Ответ.Тело.payload.id;
КонецФункции
// Safe read of an optional export field
Функция ПолучитьЗначение(Структура, Имя)
Значение = Неопределено;
Структура.Свойство(Имя, Значение);
Возврат Значение;
КонецФункции
// HTTP wrapper
Функция ВызватьAPI(Метод, Путь, ТелоЗапроса = Неопределено)
Соединение = Новый HTTPСоединение("api.exode.biz", 443,,,, 30, Новый ЗащищенноеСоединениеOpenSSL());
Запрос = Новый HTTPЗапрос(Путь);
Запрос.Заголовки.Вставить("Authorization", "Bearer ");
Запрос.Заголовки.Вставить("school-id", "");
Запрос.Заголовки.Вставить("seller-id", "");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Если ТелоЗапроса <> Неопределено Тогда
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, ТелоЗапроса);
Запрос.УстановитьТелоИзСтроки(ЗаписьJSON.Закрыть(), КодировкаТекста.UTF8);
КонецЕсли;
Ответ = Соединение.ВызватьHTTPМетод(Метод, Запрос);
Результат = Новый Структура("КодОтвета, Тело, Причина", Ответ.КодСостояния, Неопределено, "");
СтрокаОтвета = Ответ.ПолучитьТелоКакСтроку();
Если ЗначениеЗаполнено(СтрокаОтвета) Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(СтрокаОтвета);
Результат.Тело = ПрочитатьJSON(ЧтениеJSON, Ложь);
КонецЕсли;
Если Результат.КодОтвета >= 400 И Результат.Тело <> Неопределено И Результат.Тело.Свойство("cause") Тогда
Результат.Причина = Результат.Тело.cause; // e.g. StaffDepartmentNotFound
КонецЕсли;
Возврат Результат;
КонецФункции
```
## What actually goes to the API for Ivanov: a 1-to-1 mapping
The input item from the export:
```json theme={null}
{
"last_name": "Ivanov",
"middle_name": "Ivanovich",
"first_name": "Ivan",
"email": "ivanov@company.ru",
"job_position_name": "Manager",
"job_position_external_id": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01",
"department_external_ids": ["ПОДР-001"],
"city": null,
"status": "Работает",
"gender": "м",
"external_id": "i.ivanov.01011990",
"leader_external_ids": ["p.petrov.15051980"],
"role": "user",
"date_of_hire": "2020-03-01T00:00:00Z",
"date_of_hire_job_poition": "2020-03-01T00:00:00Z",
"tag_list": null,
"email_for_notifications": "",
"date_of_birth": "1990-01-01T00:00:00Z",
"login": "i.ivanov.01011990",
"password": null
}
```
What goes to the API (a new employee: create + hire in a single call):
```jsonc theme={null}
// POST /saas/v2/user/create
// Authorization: Bearer
// school-id:
// seller-id:
{
"extId": "i.ivanov.01011990", // ← external_id (sync key)
"email": "ivanov@company.ru", // ← email (if present)
"domain": "i.ivanov.01011990", // ← login, ONLY if email is empty (see the variant below)
"password": "", // ← you can pass i.ivanov.01011990, come up with your own, or omit it
// if you omit password and pass email, we send the password ourselves
"status": "Active", // ← status: "Работает" (Working) → "Active"; "Уволен" (Dismissed) → NOT via status,
// but via employment/terminate (Terminated is set automatically)
"profile": {
"firstName": "Ivan", // ← first_name
"lastName": "Ivanov", // ← last_name
// ← middle_name ("Ivanovich") has no separate field;
// optionally: "firstName": "Ivan Ivanovich"
"bdate": "1990-01-01", // ← date_of_birth (date only, no time)
"sex": "Men" // ← gender: "м" → "Men", "ж" → "Women"
},
"extra": {
"staff": {
"employments": [
{
"extId": "i.ivanov.01011990:ПОДР-001", // ← employment key (external_id + department code);
// used later for transfer/promote/terminate
"departmentExtId": "ПОДР-001", // ← department_external_ids[0]
"positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01", // ← job_position_external_id (GUID);
// job_position_name is only the name for creating the position
"startAt": "2020-03-01T00:00:00Z" // ← date_of_hire_job_poition (or date_of_hire)
}
]
}
}
}
// NOT sent (no counterpart in the API):
// city, role, tag_list, email_for_notifications
// Separate calls:
// leader_external_ids → POST /staff/department-manager/set (department manager)
// date_of_hire (company hire date), if it differs from the appointment date;
// the API only stores the employment's startAt
```
Response: `{ "success": true, "code": 201, "payload": { "user": { ... } } }`: the user is created and hired
into "ПОДР-001" as "Manager" in a single call.
An existing employee: update the profile by extId, no internal ID needed
(if the user does not exist, you get `NotFound`, and then you go to create):
```http theme={null}
PUT /saas/v2/user/ext/i.ivanov.01011990/update
{
"email": "ivanov@company.ru",
"status": "Active",
"profile": {
"firstName": "Ivan",
"lastName": "Ivanov",
"bdate": "1990-01-01",
"sex": "Men"
}
}
```
No `extra.staff` here, on purpose: sending the employment again is harmless (an idempotent no-op),
but if the employee was **transferred** to another department in 1C, `extra` would create a second
employment for them (secondary employment) rather than a transfer. HR changes go through `/staff/employment/*`.
`extra` is also supported in update, in the same format as in create. Let's look at what happens
to each `employments` element:
```jsonc theme={null}
// PUT /saas/v2/user/ext/i.ivanov.01011990/update
// Authorization: Bearer
// school-id:
// seller-id:
{
// ----- profile: updated as usual -----
"email": "ivanov@company.ru",
"status": "Active",
"profile": {
"firstName": "Ivan",
"lastName": "Ivanov"
},
// ----- employments: applied IDEMPOTENTLY -----
"extra": {
"staff": {
"employments": [
{
// Ivanov ALREADY has an active employment in ПОДР-001 with this position:
// nothing happens (no-op, no duplicate is created), so it is safe
// to send on every sync
"departmentExtId": "ПОДР-001",
"positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01"
},
{
// Ivanov has NO active employment with this department + position pair:
// a NEW employment is created (secondary employment),
// and the old one in ПОДР-001 stays active
"departmentExtId": "ПОДР-002",
"positionExtId": "0f8e2d10-11aa-4c00-8b2f-777d12003b02",
"kind": "InternalSecondary",
"startAt": "2026-07-01T00:00:00Z"
}
]
}
}
}
```
The rule is simple: `extra.employments` can only **"make sure the employment exists"**
(if not, hire; if it does, skip). It CANNOT close or change existing employments:
* the employee was **transferred** → `POST /staff/employment/ext/{extId}/transfer`;
* **promoted** (position changed) → `POST /staff/employment/ext/{extId}/promote`;
* **dismissed** → `POST /staff/employment/ext/{extId}/terminate`.
The mirror consequence: if you do **NOT include** an existing employment in `employments`,
NOTHING happens to it, and it stays active. The array is not a "full list to
sync": an employment missing from it ≠ termination of that employment. You can remove an employment
only with an explicit `terminate` call. So you can send one employment or all of them:
listed ones are hired or skipped, unlisted ones are left untouched.
If you send a new pair in `extra` during a transfer, you get an employee with TWO active
employments (the old and the new one), not a transfer.
Secondary employment: several employments in one array (up to 10):
```json theme={null}
"extra": {
"staff": {
"employments": [
{ "departmentExtId": "ПОДР-001", "positionExtId": "ДОЛЖ-РУК", "kind": "Main" },
{ "departmentExtId": "ПОДР-002", "positionExtId": "ДОЛЖ-МЕНТОР", "kind": "InternalSecondary" }
]
}
}
```
The variant without email: sign-in with domain + password (domain is built from login by replacing
disallowed characters with `_`):
```jsonc theme={null}
// POST /saas/v2/user/create
{
"extId": "s.sidorov.02021991", // ← external_id
"domain": "s.sidorov.02021991", // ← login "s.sidorov.02021991" (email is empty, passed as is)
"password": "Sid$2024!", // ← password (REQUIRED without email, otherwise there is no way to sign in)
"status": "Active", // ← status "Работает" (Working)
"profile": {
"firstName": "Semyon", // ← first_name
"lastName": "Sidorov", // ← last_name
"bdate": "1991-02-02", // ← date_of_birth
"sex": "Men" // ← gender "м"
},
"extra": {
"staff": {
"employments": [
{
"departmentExtId": "ПОДР-001", // ← department_external_ids[0]
"positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01", // ← job_position_external_id
"startAt": "2021-06-15T00:00:00Z" // ← date_of_hire_job_poition
}
]
}
}
}
```
## What to do when something changes in 1C
The loop above is idempotent: you can run it as often as every hour, and repeated runs are safe.
It picks up profile changes on its own, but **HR** changes it does not; they have separate calls.
All of them are addressed by the **employment** `extId` (the same `extId` you passed in the
`employments` element when hiring, which is why it matters to set it).
| Changed in 1C | What to call |
| - | - |
| Full name, email, date of birth, gender | Nothing separate: the next run of the loop (`PUT /user/ext/{extId}/update`) updates the profile |
| Unblocked ("Работает" (Working) is back) | Also the loop: `"status": "Active"` lifts the block |
| Transferred to another department | `POST /staff/employment/ext/{employment extId}/transfer` |
| Position changed (promotion) | `POST /staff/employment/ext/{employment extId}/promote` |
| Dismissed | `POST /staff/employment/ext/{employment extId}/terminate` |
| Re-hired | `PUT /user/ext/{extId}/update` with `extra.staff.employments` (or `POST /staff/employment/hire`); the `Active` status returns automatically. The regular loop does not work for this: it does not send `extra` for an existing employee |
| Transferred and changed position at the same time | A single `.../transfer` with `toDepartmentExtId` and `toPositionId`; the Exode position ID by GUID: `GET /staff/position/list?extIds=` |
| Grade changed (if the school uses grades) | `POST /staff/employment/ext/{employment extId}/promote { "toGradeExtId": "" }` |
| Secondary employment added | One more element in `extra.staff.employments` (or `POST /staff/employment/hire`) |
| Vacation / sick leave | `POST /staff/absence/create`; the `OnLeave` status is set automatically |
| Position renamed | Nothing: `ОбеспечитьДолжностьПоГуид` refreshes the name during the run |
| Department renamed | `PUT /staff/department/ext/{code}/update { "name": "..." }` |
| New department | `POST /staff/department/create` (before syncing employees; see Prerequisites) |
Examples of HR calls (the employment extId from the example above is `i.ivanov.01011990:ПОДР-001`). For
readability, the extId in paths is shown as is; in a real request, encode it
(`КодироватьСтроку(..., СпособКодированияСтроки.КодировкаURL)`), otherwise Cyrillic characters in the path will not arrive correctly:
`i.ivanov.01011990%3A%D0%9F%D0%9E%D0%94%D0%A0-001`.
```http theme={null}
// Ivanov was transferred from ПОДР-001 to ПОДР-002 (the old employment is closed, a new one is opened):
POST /saas/v2/staff/employment/ext/i.ivanov.01011990:ПОДР-001/transfer
{ "toDepartmentExtId": "ПОДР-002" }
```
```http theme={null}
// Promoted: the position changed (GUID of the new position from 1C):
POST /saas/v2/staff/employment/ext/i.ivanov.01011990:ПОДР-001/promote
{ "toPositionExtId": "0f8e2d10-11aa-4c00-8b2f-777d12003b02" }
```
```http theme={null}
// Transferred to ПОДР-002 and changed position at the same time, in a single call.
// toPositionId is the internal Exode position ID: GET /saas/v2/staff/position/list?extIds= → payload.items[0].id
POST /saas/v2/staff/employment/ext/i.ivanov.01011990:ПОДР-001/transfer
{ "toDepartmentExtId": "ПОДР-002", "toPositionId": 17 }
```
```http theme={null}
// Dismissed (if this is the last active employment, user.status becomes Terminated automatically,
// sign-in is disabled and sessions are ended):
POST /saas/v2/staff/employment/ext/i.ivanov.01011990:ПОДР-001/terminate
{}
```
```http theme={null}
// On vacation from August 1 to August 14 inclusive (the OnLeave status is set automatically; sign-in is NOT blocked).
// finishAt is the end moment: to cover the whole last day, pass its end.
// extId is the ID of the absence document in 1C: used later for update/delete
POST /saas/v2/staff/absence/create
{
"extId": "ОТП-2026-0001",
"employmentExtId": "i.ivanov.01011990:ПОДР-001",
"type": "Vacation",
"startAt": "2026-08-01T00:00:00Z",
"finishAt": "2026-08-14T23:59:59Z"
}
```
A nuance with extId on transfer/promote: the employment is closed and a new one is opened, but **the extId
moves to the new record**: after the transfer, the same `i.ivanov.01011990:ПОДР-001` still
points to the employee's current employment. If the extId embeds a department code (as in the
example), it will no longer match the actual department; this is not an error, it is just a key.
If you want precision, use the ID of the employment record from 1C as the extId.
## Prerequisites
* Departments (`ПОДР-001`, etc.) must be created in advance:
`POST /saas/v2/staff/department/create { "name": "Sales Department", "extId": "ПОДР-001", "parentExtId": "ПОДР-000" }`.
If `departmentExtId` is not found, create returns `StaffDepartmentNotFound` and the user is not created.
* **HR changes for an existing employee** go through the employment endpoints by employment extId:
transfer: `POST /staff/employment/ext/{extId}/transfer { "toDepartmentExtId": "ПОДР-002" }`,
position change: `.../promote { "toPositionExtId": "" }`, termination: `.../terminate`.
A new employment (including secondary employment): `POST /staff/employment/hire`, or `extra.staff.employments`
in `user/update`/`user/upsert`, which hires idempotently (repeating the same active
department + position pair is a no-op); transfers and terminations cannot be expressed through `extra`.
* **Termination of the last active employment automatically closes the account**: `user.status`
becomes `Terminated`, all sessions are ended, and sign-in is blocked. A repeated hire (`hire`)
automatically restores `Active`. You cannot terminate the school owner or yourself
(`StaffCannotTerminateSchoolOwner` / `StaffCannotTerminateSelf`).
* **The `Blocked` status (blocked by an admin)** is set via `user/update { "status": "Blocked" }`:
sign-in is disabled, sessions are ended, and new course assignments are prohibited; the data is kept
and visible in reports. `{ "status": "Active" }` lifts the block, and access is restored.
The school owner cannot be blocked.
* **Absences control the `OnLeave` status**: when an employee's absence becomes effective
(vacation, sick leave, etc.), `user.status` automatically becomes `OnLeave`; when it ends or is deleted,
it returns to `Active`. `OnLeave` is an informational status: **sign-in remains available**.
* `leader_external_ids` maps not to the employee but to the department manager, with a separate call:
`POST /saas/v2/staff/department-manager/set { "departmentExtId": "ПОДР-001", "employmentExtId": "", "isPrimary": true }`.
Pass `isPrimary` explicitly on every call: `set` without it turns the primary manager into a regular one.
* **`status` from the export maps to `user.status`**: "Работает" (Working) → `"Active"`. "Уволен" (Dismissed) is not
passed directly as a status: termination is done via `employment/terminate`, and `Terminated`
is set automatically. `OnLeave` is not set manually either; absences control it.
* `role`/`tag_list`/`city`/`email_for_notifications` have no counterpart in the API; these fields are ignored.
* **Employees without email**: pass `domain` (Latin letters/digits/`_`/dots; a dot not at the edges and not consecutive;
the server converts it to lowercase; unique within the school; if taken → `DomainIsBusy`) and **always
`password`**: without email and phone, domain + password becomes the only way to sign in.
* **Positions by GUID (`positionExtId`), not by name**: the name is mutable and not unique on the 1C side
("someone adds a soft sign and the integration breaks"). The GUID is optional: without it, the name fallback works.
Limitation: a position name is unique within the school, so you cannot create two positions with the same name but different GUIDs
(different organizations in 1C) (`StaffPositionNameIsNotUniq`); consolidate the GUIDs into one
or do not pass a GUID for the second organization.
* **Linking a position to departments is optional.** `ОбеспечитьДолжностьПоГуид` creates positions without
links: such a position is common to the whole school, and this has no side effects. If a position should be
available only in certain departments, pass the `departmentIds` field to `position/create` or
`position/ext/{GUID}/update` — an array of **internal Exode department IDs** (returned by
`GET /saas/v2/staff/department/list?extIds=ПОДР-001`). The array replaces the whole list, `[]` removes the links,
and without the field the links stay unchanged. Once linked, hiring, promoting or transferring to this position in
another department returns `StaffPositionNotInDepartment`. See [Positions](/en/exode-api/school/staff/position)
for details.
* **If the school has an org-structure source app connected** (for example, the Verifix integration), it manages
the records that have an `extId`: creating with an `extId`, renaming, deleting, transferring and terminating such
records via the API returns `MiniappInstallationManagedEntity`. See the
[sync guide](/en/exode-api/school/staff/sync-guide) for details.
***
*Updated: 2026-10-16 13:24 UTC*
# List invoices
Source: https://docs.exode.biz/en/exode-api/school/invoice/list
Get a list of the school's invoices with filtering and pagination
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/invoice/list/raw
```
All filter parameters are optional. Array parameters are passed by repeating the parameter in the query string:
`userIds=1&userIds=2`. Ranges (`*Range`) are passed as nested parameters, for example
`createdAtDateRange[from]` and `createdAtDateRange[to]`.
## Request parameters
### Pagination
Number of records to skip. Defaults to `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. From `1` to `1000`. Defaults to `100`.
### Filtering
Filter by invoice IDs.
Filter by user IDs.
Filter by product IDs.
Filter by invoice type:
* `Regular`: one-time payment;
* `InstallmentInit` / `SubscriptionInit`: setting up an installment plan / subscription (first payment and card binding);
* `InstallmentPay` / `SubscriptionPay`: recurring automatic charges for an installment plan / subscription.
Search (by user/identifiers). Up to 50 characters; leading and trailing spaces are trimmed.
Invoice creation date range.
Start of the range (ISO 8601). Must be ≤ `to`.
End of the range (ISO 8601). Must be ≥ `from`.
Range of the invoice total amount. At least one of the fields must be set.
Minimum amount.
Maximum amount.
Filter by the invoice's UTM tags.
Array of key-value pairs. Each item: `{ "key": "utm_source", "value": "google" }`
(`key` and `value` are non-empty strings).
Filter by the related payment.
Payment IDs.
Acquiring IDs.
Payment statuses: `Created`, `WaitingPay`, `WaitingForBinding`, `Processing`, `Completed`,
`BindingCompleted`, `Canceled`. An invoice is included if **at least one** of its payments
(payment attempts) has the specified status.
Status of the invoice's **latest** payment; same values as `statuses`. Use it to
get the current payment state: for example, `payment[actualStatuses]=Completed` returns paid invoices.
## Response fields
Paginated list of invoices (compact projection). For the full structure, see `invoice` in the
[`payment`](/en/exode-api/objects/entities/payment) reference.
Array of invoices.
Invoice ID.
Invoice UUID.
Invoice type (see the `types` filter).
Invoice status: `Active` or `Canceled`. This is the status of the invoice itself, not of the payment: `Active` does not
mean the invoice is paid. Check payment with the `payment[actualStatuses]` filter or the
`PaymentCompleted` webhook.
Invoice total amount.
Discount amount.
Currency: `Free`, `Exes`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`.
Creation date (ISO 8601).
Expiration date (ISO 8601).
Buyer: `id`, `tgId`, `login`, `email`, `phone`, `fullName`.
Invoice line items: an array of `{ productId, courseId, totalPrice, discountAmount }` objects.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Parameters of the next page (`skip`, `take`, `page`).
Parameters of the previous page (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/invoice/list/raw?take=20&types=Regular' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listInvoices = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/invoice/list/raw', {
params: { take: 20, types: ['Regular'] },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listInvoices();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/invoice/list/raw'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'take': 20, 'types': ['Regular'] }, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/invoice/list/raw?take=20&types=Regular");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Invoice list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"invoiceId": 7001,
"invoiceUuid": "b1f2c3d4-...",
"type": "Regular",
"status": "Active",
"totalAmount": 1990,
"discountAmount": 0,
"currency": "Rub",
"createdAt": "2025-01-15T10:30:00Z",
"expireAt": null,
"user": {
"id": 123,
"tgId": null,
"login": "user@example.com",
"email": "user@example.com",
"phone": "+9876543210",
"fullName": "John Doe"
},
"products": [
{
"productId": 200,
"courseId": 10,
"totalPrice": 1990,
"discountAmount": 0
}
]
}
],
"next": {
"skip": 20,
"take": 20,
"page": 2
},
"prev": {
"skip": 0,
"take": 20,
"page": 1
}
}
}
```
## Permission requirements
Requires token authentication and the [**"School Sales"**](/en/exode-api/permissions) permission (`SellerSales`).
***
*Updated: 2026-09-25 14:33 UTC*
# List product accesses
Source: https://docs.exode.biz/en/exode-api/school/product-access/list
Get a list of users' accesses to products (courses) with filtering and pagination
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/product-access/list/raw
```
Returns users' accesses to the school's products. All filter parameters are optional.
Array parameters are passed by repeating the parameter: `userIds=1&userIds=2`.
To check whether a user has access to a course: `userIds=&courseIds=&active=true`.
You can get a course's `productId` and `courseId` from the [course list](/en/exode-api/school/course/list).
## Request parameters
### Pagination
Number of records to skip. Defaults to `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. From `1` to `1000`. Defaults to `100`.
### Filtering
Filter by access IDs.
Filter by user IDs.
Only active (`true`) or inactive (`false`) accesses. An access becomes inactive, for example, when
the user is removed from all groups of the product ([`member/delete-many`](/en/exode-api/school/group-member/delete-many)).
Check expiration (`expireAt`) separately, with the `expiryStatuses` or `expireAtDateRange` filter.
Filter by group IDs: accesses of users who are currently members of at least one of the specified groups.
Access expiry status (values are combined with "OR"):
* `NoExpiry`: access with no expiration (`expireAt` = `null`);
* `Active`: expires in more than 14 days;
* `Expiring`: expires within the next 14 days;
* `Expired`: already expired.
Search by user/product. Up to 50 characters; leading and trailing spaces are trimmed.
Filter by launch (cohort) IDs.
Filter by course IDs: accesses to the products that the specified courses belong to.
Filter by the participant's current lesson ID.
Filter by IDs of the users who granted the access.
Filter by curator IDs (seller managers).
Participant status: `InUse`, `Completed`.
Include accesses granted as part of a parent access (bundle).
Access expiration date range.
Start of the range (ISO 8601).
End of the range (ISO 8601).
Access grant date range.
Start of the range (ISO 8601).
End of the range (ISO 8601).
Completion percentage range. Fields `from` / `to` (number); at least one is required.
### Billing filters (subscriptions/installments)
Only with active (`true`) billing. "Active" means renewal is not canceled **and** the first payment has already gone through
(accesses awaiting the entry payment, `WaitingEntryFee`, are not considered active).
Product billing type: `Installment` (installment plan), `Subscription` (subscription).
Access billing status: `Scheduled`, `Processing`, `Paid`, `Failed`, `WaitingEntryFee`,
`CanceledByUser`, `CanceledByFailedAutoCharge`, `CanceledByReinitialization`,
`CanceledByFailedManualCharge`, `CanceledByPreviousCancellation`.
Charge interval: `Week`, `Month`, `Year`.
Filter by billing invoice IDs.
Billing amount range. Fields `from` / `to` (number); at least one is required.
Date range of the current billing payment. Fields `from` / `to` (ISO 8601).
Date range of the next billing payment. Fields `from` / `to` (ISO 8601).
### Nested filters
Filter by product.
Product IDs.
Seller IDs.
Statuses: `Draft`, `OnCheck`, `Declined`, `ReadyToPublish`, `Published`.
Currencies: `Free`, `Exes`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`.
Whether it is shown in the catalog.
Whether the product is on sale.
Include archived.
Search by product (up to 50 characters).
Filter by the product's plan (price).
Plan IDs.
Product ID.
Product IDs.
Launch (cohort) IDs.
Group IDs.
Plan types (`ProductPriceType`).
Active.
Active at the current moment.
Include archived.
Hidden plans.
Search by plan (up to 50 characters).
Activity period range (`from`/`to`).
Filter by user.
User IDs.
External IDs (up to 250 values, each up to 50 characters).
Search by user (up to 50 characters).
Has confirmed sign-in.
Account statuses: `Active`, `OnLeave`, `Banned`, `Blocked`, `Terminated`, `Deleted`
(the `active`/`banned` fields have been removed from the filter; use `statuses`).
Archived.
Registration domains: `Ru`, `Uz`, `Kz`, `Biz`, `Global`.
Products: `BizSchool`, `Marketplace`.
Registration date range (`from`/`to`).
Last activity range (`from`/`to`).
## Response fields
Paginated list of accesses (compact projection). For the full structure, see `productAccess` in the
[`product`](/en/exode-api/objects/entities/product) reference.
Array of accesses.
Access ID.
Product ID.
ID of the linked course.
Whether the access is active.
Access expiration date (ISO 8601).
User: `id`, `extId`, `tgId`, `login`, `email`, `phone`, `fullName`. `login` is the first
non-empty value of `phone`, `email`, `domain`; `fullName` is the first and last name from the profile.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Parameters of the next page (`skip`, `take`, `page`).
Parameters of the previous page (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/product-access/list/raw?take=20&active=true' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listAccess = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/product-access/list/raw', {
params: { take: 20, active: true },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listAccess();
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/product-access/list/raw'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'take': 20, 'active': 'true' }, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/product-access/list/raw?take=20&active=true");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Access list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"accessId": 4001,
"productId": 200,
"courseId": 10,
"active": true,
"expireAt": "2025-12-31T23:59:59Z",
"user": {
"id": 123,
"extId": "crm_12345",
"tgId": null,
"login": "user@example.com",
"email": "user@example.com",
"phone": "+9876543210",
"fullName": "John Doe"
}
}
],
"next": {
"skip": 20,
"take": 20,
"page": 2
},
"prev": {
"skip": 0,
"take": 20,
"page": 1
}
}
}
```
## Permission requirements
Requires token authentication and one of the permissions: [**"School User Management"**](/en/exode-api/permissions) (`SchoolManageUsers`)
or **"Course Student Management"** (`CourseStudentManage`).
***
*Updated: 2026-09-25 14:33 UTC*
# Generation request
Source: https://docs.exode.biz/en/exode-api/school/query-export/generate
Create a request to export data in XLSX, CSV or JSON format
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
## How it works
Report generation runs as an asynchronous workflow. The process consists of three steps:
Send a `POST` request to `/saas/v2/query-export/generate` with the report type, filter variables and
format.
In response (HTTP `201`), you receive an object whose `payload` field contains a `uuid` — the identifier
for tracking the result. The file itself has not been created yet at this step.
Send a `GET` request
to
[`/saas/v2/workflow-execution/:executionUuid/result`](/en/exode-api/school/query-export/result),
substituting
the `uuid` from the first step, at an interval of 2–5 seconds.
Repeat the request until `status` becomes `Completed` (or `Failed`). Right after launch,
`payload` may be `null` — the task has not started yet, so keep polling.
When `status: Completed`, download the file from the `payload.result.fileUrl` link. Download it right away: the result
is stored for a limited time (see the [result retrieval](/en/exode-api/school/query-export/result) page for details).
Only a request with an unknown `type` or `format`, or with `variables` that is not an object, is rejected
immediately with status `400` and `cause: "validation"`. Errors inside `variables` (an unknown filter field, an invalid enum value, a missing `filter`) and missing permissions
for the data do not cause this request to fail: the task is created, and polling the result returns
`status: Failed`. The reason for the failure is not included in the response — check your request against the report type page and
the token's [permissions](#permission-requirements).
***
## Create an export request
```
POST /saas/v2/query-export/generate
```
Rate limit: **100 requests per hour** per service user (token). When exceeded, HTTP `429` is returned —
see the [Rate-limit](/en/exode-api/setup#rate-limits) section for details. Polling for the result does not count toward this limit.
Language of column headers and translatable values in the file: `ru`, `en`, `uz`, `qa`. If the header is not sent,
columns are named in English. Column names on the report type pages are given in English.
### Request parameters
Report type. Determines which data is exported. Available values:
* `QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY` — [school users](/en/exode-api/school/query-export/school-user-find-many)
* `QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY` — [school students](/en/exode-api/school/query-export/school-student-find-many)
* `QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY` — [group members](/en/exode-api/school/query-export/group-member-find-many)
* `QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY` — [practice assignment attempts](/en/exode-api/school/query-export/practice-attempt-find-many)
* `QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY` — [invoices](/en/exode-api/school/query-export/invoice-manage-find-many)
* `QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY` — [product access billing](/en/exode-api/school/query-export/product-billing-access-find-many)
Query variables: `filter` (required for all types; to export all records, pass `{}`) and
`sort` (optional). The set of fields depends on the selected `type` and is described on the report type page — see the links
in the list above.
Fields are validated during generation, not when the task is created: an unknown field or an invalid value results
in `status: Failed` at the result retrieval step. Dates in ranges (`...DateRange`) are passed in
ISO 8601 format, for example `2025-01-01T00:00:00Z`.
Output file format. Defaults to `EXPORT_FORMAT_XLSX`.
* `EXPORT_FORMAT_XLSX` — Excel (.xlsx). The only format with additional sheets (for example, **Course
Progress** or **Billing Details** — see the report type page).
* `EXPORT_FORMAT_CSV` — CSV (.csv): main sheet only, comma-separated, UTF-8 with BOM encoding
(opens in Excel without issues with Cyrillic), the first row contains the column headers.
* `EXPORT_FORMAT_JSON` — JSON (.json): an array of objects, one per report row. Keys are the technical
English column names (`userId`, `fullName`, …); they do not depend on `Ux-Language`.
### Response fields
Result of the request.
HTTP response code (for example, `201`).
Generation task object.
Task UUID — used to [get the result](/en/exode-api/school/query-export/result).
Process type. For exports, `QueryExport`.
Task status: `Waiting`, `Processing`, `Failed`, `Canceled`, `Completed` — described on the
[result retrieval](/en/exode-api/school/query-export/result) page. On creation, `Processing`.
Task completion flag. On creation, `false`.
ID of the task initiator — the service user who owns the token.
Task creation date (ISO 8601).
Task last update date (ISO 8601).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/query-export/generate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data '{
"type": "QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY",
"variables": {
"filter": { "groupIds": [1, 2, 3] }
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const generateReport = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/query-export/generate', {
type: 'QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY',
variables: {
filter: { groupIds: [1, 2, 3] }
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
const { uuid } = response.data.payload;
console.log('Execution UUID:', uuid);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
generateReport();
```
```php PHP theme={null}
'QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY',
'variables' => [
'filter' => ['groupIds' => [1, 2, 3]]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 || $httpCode === 201) {
$result = json_decode($response, true);
echo "Execution UUID: " . $result['payload']['uuid'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/query-export/generate'
data = {
'type': 'QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY',
'variables': {
'filter': {'groupIds': [1, 2, 3]}
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(f"Execution UUID: {result['payload']['uuid']}")
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
```
```bsl 1С theme={null}
ИдентификаторыГрупп = Новый Массив;
ИдентификаторыГрупп.Добавить(1);
ИдентификаторыГрупп.Добавить(2);
ИдентификаторыГрупп.Добавить(3);
Фильтр = Новый Структура;
Фильтр.Вставить("groupIds", ИдентификаторыГрупп);
Переменные = Новый Структура;
Переменные.Вставить("filter", Фильтр);
Данные = Новый Структура;
Данные.Вставить("type", "QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY");
Данные.Вставить("variables", Переменные);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/query-export/generate");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 200 ИЛИ Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Export started, uuid: " + Результат.payload.uuid);
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"uuid": "ac4140c9-12d3-4c1f-a7aa-d12f16c7bbdd",
"flow": "QueryExport",
"status": "Processing",
"isCompleted": false,
"userId": 5562,
"createdAt": "2025-01-18T12:44:55.812Z"
}
}
```
***
## Permission requirements
The generation request itself requires only token authentication as the school's service user. The data for the file
is collected on behalf of the same user, so the token needs a permission for the exported data — one of those
listed for the report type:
| Report type | Required permission (any of) |
| - | - |
| `QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY` | **"School User Management"** (`SchoolManageUsers`) |
| `QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY` | **"School User Management"** (`SchoolManageUsers`), **"Course Student Management"** (`CourseStudentManage`) |
| `QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY` | **"School User Management"** (`SchoolManageUsers`), **"Course Management"** (`CourseManage`), **"Course Curator"** (`CourseCurator`) |
| `QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY` | **"Course Management"** (`CourseManage`), **"Course Curator"** (`CourseCurator`) |
| `QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY` | **"School Sales"** (`SellerSales`) |
| `QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY` | **"School User Management"** (`SchoolManageUsers`), **"Course Student Management"** (`CourseStudentManage`) |
If the permission is missing, the generation request still returns `201`, and the export finishes with `status: Failed`.
To learn where to enable permissions, see the [permissions reference](/en/exode-api/permissions).
***
*Updated: 2026-09-25 14:33 UTC*
# Group members
Source: https://docs.exode.biz/en/exode-api/school/query-export/group-member-find-many
Export of group members with course progress, curators and tariffs
**type:** `QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY`
Exports a list of group members with information about course progress, curator, tariff and activity.
This is a report type for the [`query-export/generate`](/en/exode-api/school/query-export/generate) method: pass it
in the `type` field, and the fields described below in `variables`.
### Report columns
Column names are given as they appear without the `Ux-Language` header (English by default); the header switches
them to another language, e.g. `Ux-Language: ru` (see [`generate`](/en/exode-api/school/query-export/generate) for details).
Main sheet **GroupMembers**:
* User ID
* Full name
* Login method
* Phone number
* Course ID
* Course name
* Access status
* Group
* Curator
* Current lesson
* Progress (%)
* Completed lessons
* Total lessons
* Date added
* Tariff ID
* Tariff
* Invited by
* Completion date
* Last activity
In XLSX format only, if the export contains bundles, a **Course Progress** sheet is generated — a breakdown of progress
by the bundle's nested courses:
* User ID
* Full name
* Bundle ID
* Bundle name
* Course ID
* Course name
* Progress (%)
* Completed lessons
* Total lessons
* Current lesson
***
### Variables (`variables`)
Filtering parameters. Required: to export all records without filtering, pass an empty object `{}`.
Filter by group member IDs.
Filter by group IDs.
Filter by product IDs.
Filter by user IDs.
Filter by IDs of members' current lessons.
Filter by curator IDs (seller manager IDs).
Filter by IDs of users who added the member.
Filter by access status. `true` — active only, `false` — deactivated only.
Filter by product participant statuses. An array of values: `InUse`, `Completed`.
Text search (up to 50 characters).
Filter by progress range (in percent). The `from` and `to` properties are numbers.
Filter by access creation date range. The `from` and `to` properties are dates in ISO 8601 format.
Nested product filter. The set of fields matches the `product` filter of the
["List product accesses"](/en/exode-api/school/product-access/list) method.
Nested filter by the product's tariff (price). The set of fields matches the `price` filter of the
["List product accesses"](/en/exode-api/school/product-access/list) method.
Sorting parameters. Values: `ASC` (ascending) or `DESC` (descending).
Sort by ID. `ASC` | `DESC`.
Sort by creation date. `ASC` | `DESC`.
Sort by update date. `ASC` | `DESC`.
Sort by sequence number. `ASC` | `DESC`.
Sort by progress (%). `ASC` | `DESC`.
Sort by the user's last activity date. `ASC` | `DESC`.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/query-export/generate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data '{
"type": "QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY",
"variables": {
"filter": {
"groupIds": [10, 20],
"active": true
},
"sort": {
"createdAt": "DESC"
}
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const exportGroupMembers = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/query-export/generate', {
type: 'QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY',
variables: {
filter: {
groupIds: [10, 20],
active: true
},
sort: {
createdAt: 'DESC'
}
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
const { uuid } = response.data.payload;
console.log('Execution UUID:', uuid);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
exportGroupMembers();
```
```php PHP theme={null}
'QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY',
'variables' => [
'filter' => [
'groupIds' => [10, 20],
'active' => true
],
'sort' => [
'createdAt' => 'DESC'
]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 || $httpCode === 201) {
$result = json_decode($response, true);
echo "Execution UUID: " . $result['payload']['uuid'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/query-export/generate'
data = {
'type': 'QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY',
'variables': {
'filter': {
'groupIds': [10, 20],
'active': True
},
'sort': {
'createdAt': 'DESC'
}
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(f"Execution UUID: {result['payload']['uuid']}")
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
```
```bsl 1С theme={null}
ИдентификаторыГрупп = Новый Массив;
ИдентификаторыГрупп.Добавить(10);
ИдентификаторыГрупп.Добавить(20);
Фильтр = Новый Структура;
Фильтр.Вставить("groupIds", ИдентификаторыГрупп);
Фильтр.Вставить("active", Истина);
Сортировка = Новый Структура;
Сортировка.Вставить("createdAt", "DESC");
Переменные = Новый Структура;
Переменные.Вставить("filter", Фильтр);
Переменные.Вставить("sort", Сортировка);
Данные = Новый Структура;
Данные.Вставить("type", "QUERY_EXPORT_TYPE_GROUP_MEMBER_FIND_MANY");
Данные.Вставить("variables", Переменные);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/query-export/generate");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 200 ИЛИ Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Export started");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"uuid": "ac4140c9-12d3-4c1f-a7aa-d12f16c7bbdd",
"flow": "QueryExport",
"status": "Processing",
"isCompleted": false,
"userId": 5562,
"createdAt": "2025-01-18T12:44:55.812Z",
"updatedAt": "2025-01-18T12:44:55.812Z"
}
}
```
The method responds with a generation task with a `uuid`. Get the file through
[getting the result](/en/exode-api/school/query-export/result).
## Permission requirements
Requires token authentication and one of the permissions: [**"School User Management"**](/en/exode-api/permissions) (`SchoolManageUsers`),
**"Course Management"** (`CourseManage`) or **"Course Curator"** (`CourseCurator`). Without the permission, the generation
request succeeds, but the export ends with `status: Failed`.
***
*Updated: 2026-09-25 14:33 UTC*
# Invoices
Source: https://docs.exode.biz/en/exode-api/school/query-export/invoice-manage-find-many
Export school invoices with amounts, types, payment statuses and UTM tags
**type:** `QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY`
Exports the list of the school's invoices with amounts, types, payment statuses and related payments.
This is a report type for the [`query-export/generate`](/en/exode-api/school/query-export/generate) method: pass it
in the `type` field, and pass the fields described below in `variables`.
The set of filters matches the [Invoice list](/en/exode-api/school/invoice/list) method, which also describes
the nested `payment` and `utmParams` filters. An unknown field or an invalid value is not ignored: the export
finishes with `status: Failed`.
***
### Variables (`variables`)
Filter parameters. Required: to export all records without filtering, pass an empty object `{}`.
Filter by invoice IDs.
Filter by user (payer) IDs.
Filter by product IDs.
Filter by invoice types. An array of values: `Regular`, `InstallmentInit`, `InstallmentPay`,
`SubscriptionInit`, `SubscriptionPay`.
Text search, up to 50 characters.
Invoice amount range. The `from` and `to` properties are numbers.
Range of invoice creation dates. The `from` and `to` properties are dates in ISO 8601 format.
Nested filter by the related payment (status, provider, etc.).
Filter by the invoice's UTM tags. The `value` field is an array of `{ key, value }` pairs (non-empty strings).
Sorting parameters. Values: `ASC` (ascending) or `DESC` (descending).
Sort by ID. `ASC` | `DESC`.
Sort by creation date. `ASC` | `DESC`.
Sort by update date. `ASC` | `DESC`.
Sort by invoice status. `ASC` | `DESC`.
Sort by amount. `ASC` | `DESC`.
Sort by payment date (or creation date). `ASC` | `DESC`.
Sort by the payer's last activity. `ASC` | `DESC`.
Sort by the actual payment status. `ASC` | `DESC`.
### Report columns
**Invoices** sheet: User ID, Full name, Login method, Phone number, Email, Telegram, Payment ID, Payment
date, Course ID, Course name, Tariff ID, Tariff, Payment type, Payment method, Status, Total amount,
Currency.
Column names are given as they appear without the `Ux-Language` header (in English); with the header, they are translated
into the specified language (see the [`generate`](/en/exode-api/school/query-export/generate) description for details).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/query-export/generate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data '{
"type": "QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY",
"variables": {
"filter": {
"types": ["Regular"],
"createdAtDateRange": { "from": "2025-01-01T00:00:00Z", "to": "2025-12-31T23:59:59Z" }
},
"sort": {
"paidAtOrCreatedAt": "DESC"
}
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const exportInvoices = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/query-export/generate', {
type: 'QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY',
variables: {
filter: {
types: ['Regular'],
createdAtDateRange: { from: '2025-01-01T00:00:00Z', to: '2025-12-31T23:59:59Z' }
},
sort: {
paidAtOrCreatedAt: 'DESC'
}
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
const { uuid } = response.data.payload;
console.log('Execution UUID:', uuid);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
exportInvoices();
```
```php PHP theme={null}
'QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY',
'variables' => [
'filter' => [
'types' => ['Regular'],
'createdAtDateRange' => ['from' => '2025-01-01T00:00:00Z', 'to' => '2025-12-31T23:59:59Z']
],
'sort' => [
'paidAtOrCreatedAt' => 'DESC'
]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 || $httpCode === 201) {
$result = json_decode($response, true);
echo "Execution UUID: " . $result['payload']['uuid'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/query-export/generate'
data = {
'type': 'QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY',
'variables': {
'filter': {
'types': ['Regular'],
'createdAtDateRange': {'from': '2025-01-01T00:00:00Z', 'to': '2025-12-31T23:59:59Z'}
},
'sort': {
'paidAtOrCreatedAt': 'DESC'
}
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(f"Execution UUID: {result['payload']['uuid']}")
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
```
```bsl 1С theme={null}
Типы = Новый Массив;
Типы.Добавить("Regular");
ДиапазонДат = Новый Структура;
ДиапазонДат.Вставить("from", "2025-01-01T00:00:00Z");
ДиапазонДат.Вставить("to", "2025-12-31T23:59:59Z");
Фильтр = Новый Структура;
Фильтр.Вставить("types", Типы);
Фильтр.Вставить("createdAtDateRange", ДиапазонДат);
Сортировка = Новый Структура;
Сортировка.Вставить("paidAtOrCreatedAt", "DESC");
Переменные = Новый Структура;
Переменные.Вставить("filter", Фильтр);
Переменные.Вставить("sort", Сортировка);
Данные = Новый Структура;
Данные.Вставить("type", "QUERY_EXPORT_TYPE_INVOICE_MANAGE_FIND_MANY");
Данные.Вставить("variables", Переменные);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/query-export/generate");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 200 ИЛИ Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Export started");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"uuid": "ac4140c9-12d3-4c1f-a7aa-d12f16c7bbdd",
"flow": "QueryExport",
"status": "Processing",
"isCompleted": false,
"userId": 5562,
"createdAt": "2025-01-18T12:44:55.812Z",
"updatedAt": "2025-01-18T12:44:55.812Z"
}
}
```
The method's response is a generation task with a `uuid`. Get the file via
[result retrieval](/en/exode-api/school/query-export/result).
## Permission requirements
Requires token authentication and the [**"School Sales"**](/en/exode-api/permissions) permission (`SellerSales`). Without the permission, the generation request
succeeds, but the export finishes with `status: Failed`.
***
*Updated: 2026-09-25 14:33 UTC*
# Practice attempts
Source: https://docs.exode.biz/en/exode-api/school/query-export/practice-attempt-find-many
Export of practice assignment attempts with grades, statuses and student information
**type:** `QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY`
This is a report type for the [`query-export/generate`](/en/exode-api/school/query-export/generate) method: pass it
in the `type` field, and the fields described below in `variables`.
Exports a list of practice assignment attempts with grades, statuses and student information.
### Report columns
Column names are shown as returned without the `Ux-Language: ru` header (in English); with that header, columns are named in Russian
(see [`generate`](/en/exode-api/school/query-export/generate) for details). Sheet: **PracticeAttempts**.
* User ID
* Full name
* Login method
* Phone number
* Course ID
* Course name
* Curator/Reviewer
* Lesson
* Status
* Correct answers count
* Total questions
* Scored points
* Total points
* Scored points %
* Attempt #
* Max attempts
* Sent to review
* Deadline
* After deadline
* Created at
* Last activity
An export in **JSON** format additionally includes technical fields: `email`, `attemptId`, `practiceId`,
`studentSex`, `dateOfBirth`, `addedDate`, `lessonId`, `totalTasks`, `maxPointsAmount`, `pointsAmount`,
`statusHistoryLogs`, `attemptUpdatedAt`.
### Variables (`variables`)
Filter parameters. Required: to export all records without filtering, pass an empty object `{}`.
Filter by practice IDs.
Filter by user IDs.
Filter by reviewing curator IDs (seller manager IDs).
Filter by course IDs.
Filter by product IDs.
Filter by lesson IDs.
Filter by launch IDs.
Filter by whether the attempt is finished.
Filter by message IDs (review comments).
Filter by attempt statuses. An array of values: `Created`, `OnReview`, `Verified`, `AutoVerified`, `OnCorrection`, `Failed`, `Stacked`.
Text search (up to 50 characters). Searches by student name.
Filter by update date range.
Range start in ISO 8601 format (for example, `2025-01-01T00:00:00Z`).
Range end in ISO 8601 format (for example, `2025-12-31T23:59:59Z`).
Filter by date range of submission for review.
Range start in ISO 8601 format.
Range end in ISO 8601 format.
Sort parameters. Values: `ASC` (ascending) or `DESC` (descending).
Sort by ID. `ASC` | `DESC`.
Sort by creation date. `ASC` | `DESC`.
Sort by update date. `ASC` | `DESC`.
Sort by date of submission for review. `ASC` | `DESC`.
Sort by sequence number. `ASC` | `DESC`.
Sort by attempt status. `ASC` | `DESC`.
Sort by percentage of correct answers. `ASC` | `DESC`.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/query-export/generate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data '{
"type": "QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY",
"variables": {
"filter": {
"courseIds": [5],
"statuses": ["OnReview", "Verified"],
"sentToReviewAtDateRange": {
"from": "2025-01-01T00:00:00Z",
"to": "2025-06-30T23:59:59Z"
}
},
"sort": {
"sentToReviewAt": "DESC"
}
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const exportPracticeAttempts = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/query-export/generate', {
type: 'QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY',
variables: {
filter: {
courseIds: [5],
statuses: ['OnReview', 'Verified'],
sentToReviewAtDateRange: {
from: '2025-01-01T00:00:00Z',
to: '2025-06-30T23:59:59Z'
}
},
sort: {
sentToReviewAt: 'DESC'
}
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
const { uuid } = response.data.payload;
console.log('Execution UUID:', uuid);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
exportPracticeAttempts();
```
```php PHP theme={null}
'QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY',
'variables' => [
'filter' => [
'courseIds' => [5],
'statuses' => ['OnReview', 'Verified'],
'sentToReviewAtDateRange' => [
'from' => '2025-01-01T00:00:00Z',
'to' => '2025-06-30T23:59:59Z'
]
],
'sort' => [
'sentToReviewAt' => 'DESC'
]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 || $httpCode === 201) {
$result = json_decode($response, true);
echo "Execution UUID: " . $result['payload']['uuid'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/query-export/generate'
data = {
'type': 'QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY',
'variables': {
'filter': {
'courseIds': [5],
'statuses': ['OnReview', 'Verified'],
'sentToReviewAtDateRange': {
'from': '2025-01-01T00:00:00Z',
'to': '2025-06-30T23:59:59Z'
}
},
'sort': {
'sentToReviewAt': 'DESC'
}
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(f"Execution UUID: {result['payload']['uuid']}")
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
```
```bsl 1С theme={null}
ИдентификаторыКурсов = Новый Массив;
ИдентификаторыКурсов.Добавить(5);
Статусы = Новый Массив;
Статусы.Добавить("OnReview");
Статусы.Добавить("Verified");
ДиапазонДат = Новый Структура;
ДиапазонДат.Вставить("from", "2025-01-01T00:00:00Z");
ДиапазонДат.Вставить("to", "2025-06-30T23:59:59Z");
Фильтр = Новый Структура;
Фильтр.Вставить("courseIds", ИдентификаторыКурсов);
Фильтр.Вставить("statuses", Статусы);
Фильтр.Вставить("sentToReviewAtDateRange", ДиапазонДат);
Сортировка = Новый Структура;
Сортировка.Вставить("sentToReviewAt", "DESC");
Переменные = Новый Структура;
Переменные.Вставить("filter", Фильтр);
Переменные.Вставить("sort", Сортировка);
Данные = Новый Структура;
Данные.Вставить("type", "QUERY_EXPORT_TYPE_COURSE_LESSON_PRACTICE_ATTEMPT_FIND_MANY");
Данные.Вставить("variables", Переменные);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/query-export/generate");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 200 ИЛИ Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Export started");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"uuid": "ac4140c9-12d3-4c1f-a7aa-d12f16c7bbdd",
"flow": "QueryExport",
"status": "Processing",
"isCompleted": false,
"userId": 5562,
"createdAt": "2025-01-18T12:44:55.812Z",
"updatedAt": "2025-01-18T12:44:55.812Z"
}
}
```
The method returns a generation task with a `uuid`. Get the file via
[get result](/en/exode-api/school/query-export/result).
## Permission requirements
Requires token authentication and one of the permissions: [**"Course Management"**](/en/exode-api/permissions) (`CourseManage`) or
**"Course Curator"** (`CourseCurator`). Without the permission, the generation request succeeds, but the export ends with
`status: Failed`.
***
*Updated: 2026-09-25 14:33 UTC*
# Product access billing
Source: https://docs.exode.biz/en/exode-api/school/query-export/product-billing-access-find-many
Export of product access billing records: charge statuses, renewal and end dates
**type:** `QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY`
Exports product access billing records: charge statuses (subscriptions/installments), start dates,
end dates and charge retry dates.
This is a report type for the [`query-export/generate`](/en/exode-api/school/query-export/generate) method: pass it
in the `type` field, and the fields described below in `variables`.
The report is built from product accesses (`productAccessManageFindMany`) with a focus on billing (subscriptions and
installments). `variables` is the access filter and sort (`FilterAccessProductInput` / `SortAccessProductInput`),
the same structure as in the ["School students"](/en/exode-api/school/query-export/school-student-find-many) report,
which also describes the other filter fields (`courseIds`, `groupIds`, `search`, date ranges, etc.).
An unknown field or an invalid value is not ignored: the export finishes with
`status: Failed`.
***
### Variables (`variables`)
Access filter parameters (billing fields have the `billing` prefix). Required: to export all
records without filtering, pass an empty object `{}`.
Filter by user IDs.
Filter by access IDs.
Only active (`true`) or deactivated (`false`) accesses.
Only accesses with active billing (subscription/installment).
Billing type. An array of values: `Subscription`, `Installment`.
Billing status. An array of values: `Scheduled`, `Processing`, `WaitingEntryFee`, `Paid`, `Failed`,
`CanceledByUser`, `CanceledByFailedAutoCharge`, `CanceledByFailedManualCharge`,
`CanceledByPreviousCancellation`, `CanceledByReinitialization`.
Charge interval. An array of values: `Week`, `Month`, `Year`.
Filter by billing invoice IDs.
Charge amount range. The `from` and `to` properties are numbers.
Date range of the last charge. The `from` and `to` properties are ISO 8601 dates.
Date range of the next charge. The `from` and `to` properties are ISO 8601 dates.
Nested product filter (for example, `{ "productIds": [12] }`). The set of fields matches the
`product` filter of the ["Product access list"](/en/exode-api/school/product-access/list) method.
Nested price plan filter. The set of fields matches the `price` filter of the
["Product access list"](/en/exode-api/school/product-access/list) method.
Sort parameters. Values: `ASC` (ascending) or `DESC` (descending).
Sort by ID. `ASC` | `DESC`.
Sort by access grant date. `ASC` | `DESC`.
Sort by update date. `ASC` | `DESC`.
Sort by billing status. `ASC` | `DESC`.
Sort by payment percentage. `ASC` | `DESC`.
Sort by last charge date. `ASC` | `DESC`.
Sort by next charge date. `ASC` | `DESC`.
Sort by number of completed charges. `ASC` | `DESC`.
### Report columns
**Billing Access** sheet: User ID, Full name, Login method, Phone number, Email, Telegram, Payment ID,
Last payment date, Next payment date, Last payment amount, Paid amount, Total amount,
*Payment percent*, *Completed payments*, *Total payments*, *Renewal count*, *Renewal period*,
Course ID, Course name, Tariff ID, Tariff, Payment method, Status.
The *Payment percent*, *Completed payments* and *Total payments* columns appear only with an
installment filter (`hasProductBillingTypes: ["Installment"]`), and *Renewal count* and *Renewal period*
only with a subscription filter (`hasProductBillingTypes: ["Subscription"]`).
In XLSX format only, an additional **Billing Details** sheet is generated with a per-payment breakdown: User ID, Access ID, Billing ID,
Number, Payment date, Status, Amount, Invoice ID, Used attempts, Total attempts, Next retry date,
Cancel date.
Column names are given as they appear without the `Ux-Language` header (in English); with `Ux-Language: ru` the columns are named in Russian
(see the [`generate`](/en/exode-api/school/query-export/generate) description for details).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/query-export/generate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data '{
"type": "QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY",
"variables": {
"filter": {
"billingStatuses": ["Paid", "Failed"]
},
"sort": {
"billingStatus": "DESC"
}
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const exportBillingAccess = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/query-export/generate', {
type: 'QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY',
variables: {
filter: {
billingStatuses: ["Paid", "Failed"]
},
sort: {
billingStatus: 'DESC'
}
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
const { uuid } = response.data.payload;
console.log('Execution UUID:', uuid);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
exportBillingAccess();
```
```php PHP theme={null}
'QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY',
'variables' => [
'filter' => [
'billingStatuses' => ['Paid', 'Failed']
],
'sort' => [
'billingStatus' => 'DESC'
]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 || $httpCode === 201) {
$result = json_decode($response, true);
echo "Execution UUID: " . $result['payload']['uuid'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/query-export/generate'
data = {
'type': 'QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY',
'variables': {
'filter': {
'billingStatuses': ['Paid', 'Failed']
},
'sort': {
'billingStatus': 'DESC'
}
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(f"Execution UUID: {result['payload']['uuid']}")
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
```
```bsl 1С theme={null}
Статусы = Новый Массив;
Статусы.Добавить("Paid");
Статусы.Добавить("Failed");
Фильтр = Новый Структура;
Фильтр.Вставить("billingStatuses", Статусы);
Сортировка = Новый Структура;
Сортировка.Вставить("billingStatus", "DESC");
Переменные = Новый Структура;
Переменные.Вставить("filter", Фильтр);
Переменные.Вставить("sort", Сортировка);
Данные = Новый Структура;
Данные.Вставить("type", "QUERY_EXPORT_TYPE_PRODUCT_BILLING_ACCESS_FIND_MANY");
Данные.Вставить("variables", Переменные);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/query-export/generate");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 200 ИЛИ Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Export started");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"uuid": "ac4140c9-12d3-4c1f-a7aa-d12f16c7bbdd",
"flow": "QueryExport",
"status": "Processing",
"isCompleted": false,
"userId": 5562,
"createdAt": "2025-01-18T12:44:55.812Z",
"updatedAt": "2025-01-18T12:44:55.812Z"
}
}
```
The method responds with a generation task that has a `uuid`. Get the file via
[fetching the result](/en/exode-api/school/query-export/result).
## Permission requirements
Requires token authentication and one of the permissions: [**"School User Management"**](/en/exode-api/permissions) (`SchoolManageUsers`)
or **"Course Student Management"** (`CourseStudentManage`). Without the permission, the generation request succeeds, but
the export finishes with `status: Failed`.
***
*Updated: 2026-09-25 14:33 UTC*
# Getting the result
Source: https://docs.exode.biz/en/exode-api/school/query-export/result
Get the progress and result of a generated report
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
## Getting the report result
```
GET /saas/v2/workflow-execution/:executionUuid/result
```
Use the `uuid` you received when [creating the generation task](/en/exode-api/school/query-export/generate).
Send the request periodically (polling) at 2–5 second intervals until `status` becomes `Completed` or
`Failed`. This method is not subject to the generation rate limit, but frequent polling does not speed up the export.
Right after the task is created, `payload` may be `null`: the task is queued and has not reported progress yet.
Keep polling. If `payload` stays `null` for a `uuid` you have already received a status for, the result's
retention period has expired; start the generation again. Add an overall polling timeout to your code so you do not poll
an unknown `uuid` forever.
### Path parameters
UUID of the generation task, taken from the `POST /generate` response.
### Response fields
The result of the request.
HTTP response code (for example, `200`).
Data on the generation progress and result. `null` if the task has not started yet, if no task with this
`executionUuid` was found, or if the result's retention period has expired.
Total progress (always `100`: percentage mode).
Current progress from `0` to `100`. It grows unevenly: most of the time is spent fetching data, then
progress jumps to `100`. May be `0` when the status is `Failed`.
Execution status:
* `Waiting` — the task is queued and waiting to run
* `Processing` — the report is being generated; keep polling
* `Completed` — the report is ready; the `result` field contains the file link
* `Failed` — generation failed. The reason is not returned in the response. Common causes: `variables` has no
`filter`, contains an unknown field or an invalid enum value, or the token lacks the permission for the exported data
(see [permissions by report type](/en/exode-api/school/query-export/generate#permission-requirements)).
Fix the request and start the generation again
* `Canceled` — the task was canceled. You cannot cancel an export through the API, so in practice this status
is rare; start the generation again
The generation result (available only when `status: Completed`). In other statuses it is `null` or absent.
URL for downloading the generated file. No authorization headers are needed to download it. The link may
be temporary, so download the file right after `Completed` instead of storing the link.
File name (for example, `79d46a97-a149-432b-bac7-45f6732b9115.xlsx`).
File size in bytes.
Internal information about the task in the export service. Not needed for integration: rely on the top-level
`status` and the fields above.
The result is stored for **24 hours** after generation completes. After that, the method returns `payload: null`;
to get the data again, start the generation again.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/workflow-execution/550e8400-e29b-41d4-a716-446655440000/result' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const pollResult = async (executionUuid) => {
const url = `https://api.exode.biz/saas/v2/workflow-execution/${executionUuid}/result`;
const headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
};
const deadline = Date.now() + 30 * 60 * 1000; // overall polling timeout: 30 minutes
while (Date.now() < deadline) {
const { data } = await axios.get(url, { headers });
const { payload } = data;
if (!payload) {
// The task has not started yet (or the uuid is unknown): wait and retry
await new Promise((r) => setTimeout(r, 3000));
continue;
}
console.log(`Progress: ${payload.completed}/${payload.total} — ${payload.status}`);
if (payload.status === 'Completed') {
console.log('File URL:', payload.result.fileUrl);
return payload.result;
}
if (payload.status === 'Failed' || payload.status === 'Canceled') {
throw new Error(`Export ${payload.status}`);
}
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error('Export polling timed out');
};
pollResult('550e8400-e29b-41d4-a716-446655440000');
```
```php PHP theme={null}
```
```python Python theme={null}
import requests
import time
execution_uuid = '550e8400-e29b-41d4-a716-446655440000'
url = f'https://api.exode.biz/saas/v2/workflow-execution/{execution_uuid}/result'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
deadline = time.time() + 30 * 60 # overall polling timeout: 30 minutes
while time.time() < deadline:
response = requests.get(url, headers=headers)
data = response.json()
payload = data['payload']
if payload is None:
# The task has not started yet (or the uuid is unknown): wait and retry
time.sleep(3)
continue
print(f"Progress: {payload['completed']}/{payload['total']} — {payload['status']}")
if payload['status'] == 'Completed':
print(f"File URL: {payload['result']['fileUrl']}")
break
if payload['status'] in ('Failed', 'Canceled'):
print(f"Error: Export {payload['status']}")
break
time.sleep(3)
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/workflow-execution/550e8400-e29b-41d4-a716-446655440000/result");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
// payload = Неопределено means the task has not started yet: repeat the request in a few seconds
Если Результат.payload <> Неопределено И Результат.payload.status = "Completed" Тогда
Сообщить("File: " + Результат.payload.result.fileUrl);
ИначеЕсли Результат.payload <> Неопределено Тогда
Сообщить("Status: " + Результат.payload.status);
КонецЕсли;
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Processing theme={null}
{
"success": true,
"code": 200,
"payload": {
"total": 100,
"completed": 45,
"status": "Processing",
"result": null
}
}
```
```json Completed theme={null}
{
"success": true,
"code": 200,
"payload": {
"total": 100,
"completed": 100,
"status": "Completed",
"result": {
"fileUrl": "https://static.exode.biz/generated/reports/79d46a97-a149-432b-bac7-45f6732b9115.xlsx",
"fileName": "79d46a97-a149-432b-bac7-45f6732b9115.xlsx",
"fileSize": 6748
}
}
}
```
```json Failed theme={null}
{
"success": true,
"code": 200,
"payload": {
"total": 100,
"completed": 0,
"status": "Failed",
"result": null
}
}
```
```json Not started theme={null}
{
"success": true,
"code": 200,
"payload": null
}
```
## Permission requirements
Token authentication is required. The method does not require any specific permissions: data permissions are checked at generation time
(see [permissions by report type](/en/exode-api/school/query-export/generate#permission-requirements)).
***
*Updated: 2026-09-25 14:33 UTC*
# School students
Source: https://docs.exode.biz/en/exode-api/school/query-export/school-student-find-many
Export school students (course accesses) with progress, groups, curators and tariffs
**type:** `QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY`
Exports the school's students through their course accesses: course progress, groups, curator, tariff and access status
(one student = one product access).
This is a report type for the [`query-export/generate`](/en/exode-api/school/query-export/generate) method: pass it
in the `type` field, and pass the filter and sorting in `variables`.
Students are exported from product accesses (`productAccessManageFindMany`): one student = one course access.
That is why `variables` is the access filter and sorting (`FilterAccessProductInput` / `SortAccessProductInput`),
the same structure as in the
["Access billing"](/en/exode-api/school/query-export/product-billing-access-find-many) report.
An unknown field or an invalid value is not ignored: the export finishes with
`status: Failed`.
***
### Variables (`variables`)
Access filter parameters. Required: to export all records without filtering, pass an empty
object `{}`.
Filter by user IDs.
Filter by access IDs.
Filter by course IDs.
Filter by IDs of the groups the student belongs to.
Filter by launch (cohort) IDs.
Only active (`true`) or deactivated (`false`) accesses.
Text search (name, email, phone, domain), up to 50 characters.
Filter by IDs of the students' current lessons.
Filter by curator IDs (seller manager IDs).
Filter by IDs of the users who granted the access.
Product participant status. An array of values: `InUse`, `Completed`.
Progress range (in percent). The `from` and `to` properties are numbers.
Range of access grant dates. The `from` and `to` properties are ISO 8601 dates.
Range of access expiration dates. The `from` and `to` properties are ISO 8601 dates.
Nested product filter (for example, `{ "productIds": [12] }`). The set of fields matches the `product`
filter of the ["Product access list"](/en/exode-api/school/product-access/list) method. If a
non-empty `product.productIds` is set, the "Last activity" and "Access status" columns are added to the report.
Nested tariff filter. The set of fields matches the `price` filter of the
["Product access list"](/en/exode-api/school/product-access/list) method.
Sorting parameters. Values: `ASC` (ascending) or `DESC` (descending).
Sort by ID. `ASC` | `DESC`.
Sort by access grant date. `ASC` | `DESC`.
Sort by update date. `ASC` | `DESC`.
Sort by access expiration date. `ASC` | `DESC`.
Sort by progress (%). `ASC` | `DESC`.
Sort by the student's last activity. `ASC` | `DESC`.
### Report columns
**SchoolStudents** sheet: User ID, Full name, Login method, Course ID, Course name, Group ID, Group,
Curator, Current lesson, Progress (%), Completed lessons, Total lessons, Date added, Access expiration
date, Tariff ID, Tariff. With a non-empty `filter.product.productIds`, "Last activity" and "Access
status" are added. Corporate schools do not have the "Tariff ID" and "Tariff" columns.
In XLSX format only, if the export contains bundles, a **Course Progress** sheet is generated with a breakdown of progress
by the bundle's nested courses: User ID, Full name, Bundle ID, Bundle name, Course ID, Course name,
Progress (%), Completed lessons, Total lessons, Current lesson.
Column names are given as they appear without the `Ux-Language` header (in English); with the header, they are translated
into the specified language (see the [`generate`](/en/exode-api/school/query-export/generate) description for details).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/query-export/generate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data '{
"type": "QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY",
"variables": {
"filter": {
"active": true
},
"sort": {
"createdAt": "DESC"
}
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const exportStudents = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/query-export/generate', {
type: 'QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY',
variables: {
filter: {
active: true
},
sort: {
createdAt: 'DESC'
}
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
const { uuid } = response.data.payload;
console.log('Execution UUID:', uuid);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
exportStudents();
```
```php PHP theme={null}
'QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY',
'variables' => [
'filter' => [
'active' => true
],
'sort' => [
'createdAt' => 'DESC'
]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 || $httpCode === 201) {
$result = json_decode($response, true);
echo "Execution UUID: " . $result['payload']['uuid'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/query-export/generate'
data = {
'type': 'QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY',
'variables': {
'filter': {
'active': True
},
'sort': {
'createdAt': 'DESC'
}
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(f"Execution UUID: {result['payload']['uuid']}")
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
```
```bsl 1С theme={null}
Фильтр = Новый Структура;
Фильтр.Вставить("active", Истина);
Сортировка = Новый Структура;
Сортировка.Вставить("createdAt", "DESC");
Переменные = Новый Структура;
Переменные.Вставить("filter", Фильтр);
Переменные.Вставить("sort", Сортировка);
Данные = Новый Структура;
Данные.Вставить("type", "QUERY_EXPORT_TYPE_SCHOOL_STUDENT_FIND_MANY");
Данные.Вставить("variables", Переменные);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/query-export/generate");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 200 ИЛИ Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Export started");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"uuid": "ac4140c9-12d3-4c1f-a7aa-d12f16c7bbdd",
"flow": "QueryExport",
"status": "Processing",
"isCompleted": false,
"userId": 5562,
"createdAt": "2025-01-18T12:44:55.812Z",
"updatedAt": "2025-01-18T12:44:55.812Z"
}
}
```
The method's response is a generation task with a `uuid`. Get the file via
[result retrieval](/en/exode-api/school/query-export/result).
## Permission requirements
Requires token authentication and one of the permissions: [**"School User Management"**](/en/exode-api/permissions) (`SchoolManageUsers`)
or **"Course Student Management"** (`CourseStudentManage`). Without the permission, the generation request succeeds, but
the export finishes with `status: Failed`.
***
*Updated: 2026-09-25 14:33 UTC*
# School users
Source: https://docs.exode.biz/en/exode-api/school/query-export/school-user-find-many
Export school users with profile, contacts, statuses and activity
**type:** `QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY`
Exports the list of school users with profile data, contacts, access statuses and activity.
This is a report type for the [`query-export/generate`](/en/exode-api/school/query-export/generate) method: pass it
in the `type` field, and the fields described below in `variables`.
The `variables` structure mirrors the filter and sorting of the school user search.
An unknown field or an invalid value is not ignored: the export finishes with
`status: Failed`.
***
### Variables (`variables`)
Filter parameters. Required: to export all records without filtering, pass an empty object `{}`.
Filter by user IDs.
Filter by external identifiers (`extId`). Up to 250 values, each up to 50 characters.
Text search (name, email, phone, domain), up to 50 characters.
Filter by the confirmed-login flag.
Include archived users.
Filter by account statuses. An array of values: `Active`, `OnLeave`, `Banned`, `Blocked`,
`Terminated`, `Deleted`. The filter has no `active`/`banned` fields; use `statuses` instead
(for example, blocked users: `["Banned", "Blocked"]`). A request with `active` or `banned` finishes
with `status: Failed`.
Sign-up date range. The `from` and `to` properties are dates in ISO 8601 format.
Last activity date range. The `from` and `to` properties are dates in ISO 8601 format.
Sorting parameters. Values: `ASC` (ascending) or `DESC` (descending).
Sort by ID. `ASC` | `DESC`.
Sort by sign-up date. `ASC` | `DESC`.
Sort by update date. `ASC` | `DESC`.
Sort by last activity. `ASC` | `DESC`.
Sort by stars balance. `ASC` | `DESC`.
Sort by the number of product accesses. `ASC` | `DESC`.
### Report columns
**SchoolUsers** sheet: User ID, First name, Last name, Login method, Phone number, Email, Telegram, Courses count,
Registration date, Registration type, Created by, Date of birth, Last login date. With `filter.archived: true`,
"Deleted at" and "Deleted by" are added.
Column names are given as they appear without the `Ux-Language` header (in English); with `Ux-Language: ru` the columns
are named in Russian (see [`generate`](/en/exode-api/school/query-export/generate) for details).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/query-export/generate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data '{
"type": "QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY",
"variables": {
"filter": {
"statuses": ["Active"]
},
"sort": {
"createdAt": "DESC"
}
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const exportSchoolUsers = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/query-export/generate', {
type: 'QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY',
variables: {
filter: {
statuses: ['Active']
},
sort: {
createdAt: 'DESC'
}
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
const { uuid } = response.data.payload;
console.log('Execution UUID:', uuid);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
exportSchoolUsers();
```
```php PHP theme={null}
'QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY',
'variables' => [
'filter' => [
'statuses' => ['Active']
],
'sort' => [
'createdAt' => 'DESC'
]
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 || $httpCode === 201) {
$result = json_decode($response, true);
echo "Execution UUID: " . $result['payload']['uuid'] . "\n";
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/query-export/generate'
data = {
'type': 'QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY',
'variables': {
'filter': {
'statuses': ['Active']
},
'sort': {
'createdAt': 'DESC'
}
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(f"Execution UUID: {result['payload']['uuid']}")
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
```
```bsl 1С theme={null}
Статусы = Новый Массив;
Статусы.Добавить("Active");
Фильтр = Новый Структура;
Фильтр.Вставить("statuses", Статусы);
Сортировка = Новый Структура;
Сортировка.Вставить("createdAt", "DESC");
Переменные = Новый Структура;
Переменные.Вставить("filter", Фильтр);
Переменные.Вставить("sort", Сортировка);
Данные = Новый Структура;
Данные.Вставить("type", "QUERY_EXPORT_TYPE_SCHOOL_USER_FIND_MANY");
Данные.Вставить("variables", Переменные);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/query-export/generate");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 200 ИЛИ Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Export started");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"uuid": "ac4140c9-12d3-4c1f-a7aa-d12f16c7bbdd",
"flow": "QueryExport",
"status": "Processing",
"isCompleted": false,
"userId": 5562,
"createdAt": "2025-01-18T12:44:55.812Z",
"updatedAt": "2025-01-18T12:44:55.812Z"
}
}
```
The method returns a generation task with a `uuid`. Get the file via
[getting the result](/en/exode-api/school/query-export/result).
## Permission requirements
Token authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`) are required. Without the permission,
the generation request succeeds, but the export finishes with `status: Failed`.
***
*Updated: 2026-09-25 14:33 UTC*
# Absences
Source: https://docs.exode.biz/en/exode-api/school/staff/absence
Track vacations, sick leave and business trips of corporate school employees
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
An absence (`absence`) records a period when an employee is not working: an absence with no specified reason
(`Absent`), a vacation (`Vacation`), a day off (`DayOff`), a business trip (`BusinessTrip`), sick leave (`SickLeave`),
parental leave (`ParentalLeave`) or study leave (`StudyLeave`). An absence is linked to the employee's
employment, not to the user: if the employee has several jobs, specify the one the absence
applies to (this does not affect the user's status — absences across all of their
employments are taken into account).
`startAt` and `finishAt` are points in time (ISO 8601, UTC), not calendar days. An absence is considered current
while `startAt` ≤ now ≤ `finishAt`. Pass a vacation "from August 1 to August 14 inclusive" as
`startAt: "2026-08-01T00:00:00Z"`, `finishAt: "2026-08-14T23:59:59Z"` (adjusted for the company's time zone):
the value `2026-08-14T00:00:00Z` would end the absence at the start of the 14th. Without `finishAt`, the absence lasts
indefinitely until you set an end date.
Creating, updating and deleting an absence automatically syncs the user status `Active` ↔
`OnLeave`: if the user has a current (in effect right now) absence, the status becomes
`OnLeave`; once no current absences remain, it returns to `Active`. `OnLeave` is an informational status:
it does **not** block sign-in or access to the platform. These transitions do not affect users in other statuses
(`Banned`, `Blocked`, `Terminated`). In addition to recalculating on API calls, the platform automatically
reconciles statuses with the calendar once an hour: an absence with a future start date moves the employee to `OnLeave` when
that date arrives, and an expired one returns them to `Active`, with no calls required on your side.
All staff module endpoints are available **only** to `Corporate`-segment schools. For other segments, the request
returns `401` with `cause: "Forbidden"` and the message `Allowed only for Corporate school`.
## List absences
```
GET /saas/v2/staff/absence/list
```
Requires authentication and the [**"Staff browsing"**](/en/exode-api/permissions) permission (`StaffView`).
### Request parameters
Pass array parameters by repeating the parameter in the query string: `employmentIds=1&employmentIds=2`.
#### Pagination
Number of records to skip. Defaults to `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. Defaults to `100`, maximum `1000`.
#### Filtering
Filter by absence external IDs (`extId`). Up to 250 values, each up to 50 characters.
Filter by employment IDs. Up to 250 values. On a transfer or position change, absences stay on the closed
employment record, so to get an employee's full history, pass the IDs of all of their records — you can get them
via [`employment/list?userIds=...`](/en/exode-api/school/staff/employment#list-employments).
Filter by position IDs (the position is taken from the employment the absence is linked to). Up to 250
values.
Filter by absence types. Possible values: `Absent`, `Vacation`, `DayOff`, `BusinessTrip`, `SickLeave`,
`ParentalLeave`, `StudyLeave`. Up to 250 values.
If `true`, return only current absences: `startAt` has already passed, and `finishAt` is not set or has not
passed yet.
### Response fields
Paginated list of absences.
Array of absences.
Absence ID.
School ID.
Employee's employment ID.
External ID from the client's system. Unique within the school among non-deleted records.
Absence type: `Absent`, `Vacation`, `DayOff`, `BusinessTrip`, `SickLeave`, `ParentalLeave` or `StudyLeave`.
Absence start date (ISO 8601).
Absence end date (ISO 8601) or `null`.
Note on the absence.
Record creation date.
Last update date.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Next page parameters (`skip`, `take`, `page`).
Previous page parameters (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/absence/list?take=10&types=Vacation' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listAbsences = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/staff/absence/list', {
params: { take: 10, types: ['Vacation'] },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listAbsences();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 1,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 2,
"createdAt": "2026-07-02T11:15:47.341Z",
"updatedAt": "2026-07-02T11:15:47.354Z",
"archivedAt": null,
"schoolId": 198,
"employmentId": 9,
"extId": "1c-absence-2024-001",
"type": "Vacation",
"startAt": "2026-07-06T00:00:00.000Z",
"finishAt": "2026-07-20T00:00:00.000Z",
"note": "Summer vacation (updated)"
}
],
"next": {
"skip": 0,
"take": 10,
"page": 1
},
"prev": {
"skip": 0,
"take": 10,
"page": 1
}
}
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffView",
"message": "Forbidden seller resource - permissions StaffView"
}
```
## Create an absence
```
POST /saas/v2/staff/absence/create
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
### Request parameters
Specify the employment with **exactly one** of two fields: `employmentId` or `employmentExtId`.
External ID of the absence from the client's system. 1 to 50 characters, no `/` or whitespace. Must be
unique within the school among non-deleted records — otherwise the `StaffAbsenceExtIdIsNotUniq` error is returned.
ID of the employee's employment the absence applies to. Required if `employmentExtId` is not passed.
You can also specify an already closed record — for example, when migrating absence history.
External ID (`extId`) of the employment — the one you passed when hiring. An alternative to `employmentId`;
required if `employmentId` is not passed. Looked up only among the school's open (not terminated) employments;
if none is found, the `StaffEmploymentNotFound` error is returned.
Absence type. Possible values: `Absent` (absent), `Vacation` (vacation), `DayOff` (day off),
`BusinessTrip` (business trip), `SickLeave` (sick leave), `ParentalLeave` (parental leave),
`StudyLeave` (study leave).
Absence start date in ISO 8601 format. Must not be later than `finishAt`.
Absence end date in ISO 8601 format. Must not be earlier than `startAt`, otherwise the request is rejected.
Omit it if the end date is not yet known.
Note on the absence. Maximum 500 characters. Leading and trailing whitespace is trimmed automatically.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/absence/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"extId": "1c-absence-2024-001",
"employmentId": 9,
"type": "Vacation",
"startAt": "2026-07-06T00:00:00.000Z",
"finishAt": "2026-07-20T00:00:00.000Z",
"note": "Summer vacation"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const createAbsence = async () => {
const { data } = await axios.post('https://api.exode.biz/saas/v2/staff/absence/create', {
extId: '1c-absence-2024-001',
employmentId: 9,
type: 'Vacation',
startAt: '2026-07-06T00:00:00.000Z',
finishAt: '2026-07-20T00:00:00.000Z',
note: 'Summer vacation',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
createAbsence();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 2,
"createdAt": "2026-07-02T11:15:47.341Z",
"updatedAt": "2026-07-02T11:15:47.341Z",
"archivedAt": null,
"schoolId": 198,
"employmentId": 9,
"extId": "1c-absence-2024-001",
"type": "Vacation",
"startAt": "2026-07-06T00:00:00.000Z",
"finishAt": "2026-07-20T00:00:00.000Z",
"note": "Summer vacation"
}
}
```
```json Error - Invalid Interval theme={null}
{
"code": 400,
"success": false,
"cause": "StaffAbsenceInvalidInterval",
"message": "Staff absence interval is invalid",
"error": "Staff absence interval is invalid"
}
```
```json Error - ExtId Is Not Uniq theme={null}
{
"code": 400,
"success": false,
"cause": "StaffAbsenceExtIdIsNotUniq",
"message": "Staff absence extId is not uniq",
"error": "Staff absence extId is not uniq"
}
```
```json Error - Employment Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentNotFound",
"message": "Staff employment not found",
"error": "Staff employment not found"
}
```
## Update an absence
```
PUT /saas/v2/staff/absence/{absenceId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Updates absence fields. All body fields are optional — pass only the ones you want to change.
The employment link (`employmentId`) cannot be changed.
### Request parameters
ID of the absence to update.
External ID of the absence from the client's system. 1 to 50 characters, no `/` or whitespace. Must be
unique within the school among non-deleted records — otherwise the `StaffAbsenceExtIdIsNotUniq` error is returned.
Absence type. Possible values: `Absent`, `Vacation`, `DayOff`, `BusinessTrip`, `SickLeave`,
`ParentalLeave`, `StudyLeave`.
Absence start date in ISO 8601 format. Must not be later than `finishAt`.
Absence end date in ISO 8601 format. Must not be earlier than `startAt` (taking into account the already stored
value if `startAt` is not passed), otherwise `StaffAbsenceInvalidInterval` is returned. `null` clears the end
date — the absence becomes open-ended.
Note on the absence. Maximum 500 characters.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/absence/2/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"note": "Summer vacation (updated)"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const updateAbsence = async () => {
const { data } = await axios.put('https://api.exode.biz/saas/v2/staff/absence/2/update', {
note: 'Summer vacation (updated)',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
updateAbsence();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 2,
"createdAt": "2026-07-02T11:15:47.341Z",
"updatedAt": "2026-07-02T11:15:47.354Z",
"archivedAt": null,
"schoolId": 198,
"employmentId": 9,
"extId": "1c-absence-2024-001",
"type": "Vacation",
"startAt": "2026-07-06T00:00:00.000Z",
"finishAt": "2026-07-20T00:00:00.000Z",
"note": "Summer vacation (updated)"
}
}
```
```json Error - Invalid Interval theme={null}
{
"code": 400,
"success": false,
"cause": "StaffAbsenceInvalidInterval",
"message": "Staff absence interval is invalid",
"error": "Staff absence interval is invalid"
}
```
```json Error - ExtId Is Not Uniq theme={null}
{
"code": 400,
"success": false,
"cause": "StaffAbsenceExtIdIsNotUniq",
"message": "Staff absence extId is not uniq",
"error": "Staff absence extId is not uniq"
}
```
## Update an absence by extId
```
PUT /saas/v2/staff/absence/ext/{extId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Same as updating by `absenceId`, but the absence is identified by its external ID (`extId`) within the
school. The request body is the same as for `PUT /saas/v2/staff/absence/{absenceId}/update`.
### Request parameters
External ID of the absence to update. Pass it URL-encoded.
New external ID of the absence. 1 to 50 characters, no `/` or whitespace. Must be unique
within the school among non-deleted records.
Absence type. Possible values: `Absent`, `Vacation`, `DayOff`, `BusinessTrip`, `SickLeave`,
`ParentalLeave`, `StudyLeave`.
Absence start date in ISO 8601 format. Must not be later than `finishAt`.
Absence end date in ISO 8601 format. Must not be earlier than `startAt` (taking into account the already stored
value if `startAt` is not passed), otherwise `StaffAbsenceInvalidInterval` is returned. `null` clears the end
date — the absence becomes open-ended.
Note on the absence. Maximum 500 characters.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/absence/ext/1c-absence-2024-001/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"note": "Summer vacation (updated)"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const updateAbsenceByExtId = async () => {
const extId = encodeURIComponent('1c-absence-2024-001');
const { data } = await axios.put(`https://api.exode.biz/saas/v2/staff/absence/ext/${extId}/update`, {
note: 'Summer vacation (updated)',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
updateAbsenceByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 2,
"createdAt": "2026-07-02T11:15:47.341Z",
"updatedAt": "2026-07-02T11:15:47.354Z",
"archivedAt": null,
"schoolId": 198,
"employmentId": 9,
"extId": "1c-absence-2024-001",
"type": "Vacation",
"startAt": "2026-07-06T00:00:00.000Z",
"finishAt": "2026-07-20T00:00:00.000Z",
"note": "Summer vacation (updated)"
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffAbsenceNotFound",
"message": "Staff absence not found",
"error": "Staff absence not found"
}
```
## Delete an absence
```
DELETE /saas/v2/staff/absence/{absenceId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
### Request parameters
ID of the absence to delete.
### Response
Number of deleted records (`1` on successful deletion).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/absence/2/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const deleteAbsence = async () => {
const { data } = await axios.delete('https://api.exode.biz/saas/v2/staff/absence/2/delete', {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
deleteAbsence();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Delete an absence by extId
```
DELETE /saas/v2/staff/absence/ext/{extId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Same as deleting by `absenceId`, but the absence is identified by its external ID (`extId`) within the school.
### Request parameters
External ID of the absence to delete. Pass it URL-encoded.
### Response
Number of deleted records (`1` on successful deletion).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/absence/ext/1c-absence-2024-001/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const deleteAbsenceByExtId = async () => {
const extId = encodeURIComponent('1c-absence-2024-001');
const { data } = await axios.delete(`https://api.exode.biz/saas/v2/staff/absence/ext/${extId}/delete`, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
deleteAbsenceByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffAbsenceNotFound",
"message": "Staff absence not found",
"error": "Staff absence not found"
}
```
## Permission requirements
The staff module is available only to `Corporate`-segment schools. Reading the list requires the **"Staff browsing"** permission (`StaffView`);
creating, updating and deleting absences requires the **"Staff Management"** permission (`StaffManage`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# Departments
Source: https://docs.exode.biz/en/exode-api/school/staff/department
REST endpoints for managing the departments (organizational units) of a corporate school
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
Departments are the hierarchical organizational units of a school. Each department can have a parent department
(`parentId`), which is how the organizational structure tree is built. The staff module uses departments to
assign employees to organizational units and to appoint managers.
The `extId` field is the department's external identifier from the client's system (CRM, 1C, etc.). It is unique
within the school among non-deleted records, is 1 to 50 characters long, and cannot contain `/` or whitespace
(the value must be URL-safe because it is used in `ext/{extId}` paths). Separate update and delete endpoints are
available by `extId`.
All staff module endpoints are available **only to corporate-segment schools** (`Corporate`).
Requests from a school in any other segment are rejected.
If the school has an org-structure source app connected (for example, the Verifix integration), departments with an
`extId` are managed by it: creating a department with an `extId`, changing the `name`, `extId` or parent
(`parentId`, `parentExtId`) of such a department and deleting it via the API return the
`MiniappInstallationManagedEntity` error (`data.installationId` holds the ID of the connected app).
A position can be linked to one or more departments — see
[Linking to departments and grades](/en/exode-api/school/staff/position#linking-to-departments-and-grades).
## Permission requirements
Reading (`tree`, `list`) requires the [**"Staff browsing"**](/en/exode-api/permissions) permission (`StaffView`). Creating, updating and deleting require the
**"Staff Management"** permission (`StaffManage`). In all cases, token authentication is required and the school must belong to the
`Corporate` segment.
The service user must be authenticated by token and have the appropriate access permissions for the specified
school.
## Department tree
```
GET /saas/v2/staff/department/tree
```
Requires authentication and the **"Staff browsing"** permission (`StaffView`).
The endpoint returns a **flat array** of all departments of the school. The hierarchy is defined by each
department's `parentId` field (`null` means a root department). You build the tree itself on the client side by
grouping items by `parentId`.
### Response
Array of the school's departments. The `parentId` field points to the parent department (`null` means root).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/department/tree' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function getDepartmentTree() {
const { data } = await axios.get(
'https://api.exode.biz/saas/v2/staff/department/tree',
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
getDepartmentTree();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": [
{
"id": 3,
"createdAt": "2026-07-02T11:15:47.223Z",
"updatedAt": "2026-07-02T11:15:47.223Z",
"archivedAt": null,
"schoolId": 198,
"parentId": null,
"extId": "dep_engineering",
"name": "Engineering"
},
{
"id": 4,
"createdAt": "2026-07-02T11:15:47.234Z",
"updatedAt": "2026-07-02T11:15:47.247Z",
"archivedAt": null,
"schoolId": 198,
"parentId": 3,
"extId": null,
"name": "Backend & Infra Team"
}
]
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffView",
"message": "Forbidden seller resource - permissions StaffView"
}
```
## Department list
```
GET /saas/v2/staff/department/list
```
Requires authentication and the **"Staff browsing"** permission (`StaffView`).
Unlike `tree`, this endpoint returns a paginated list of departments. Pagination and filter parameters are
passed as query parameters.
### Parameters
Number of records to skip (offset pagination). Minimum `0`.
Number of records to return per page. From `1` to `1000`.
Page number (an alternative to `skip`). Minimum `1`.
Array of department IDs to filter by (up to 250 values).
Array of parent department IDs to filter by (up to 250 values). Returns only the child departments of the
specified parents.
Array of external identifiers (`extId`) to filter by (up to 250 values, each up to 50 characters).
Search by department name. Maximum 50 characters.
Sort direction by creation date. Possible values: `ASC`, `DESC`. The `id` and `updatedAt` parameters work the
same way.
### Response
Paginated object: `items` (array of departments), `page`, `count`, `pages`, `isFirst`, `isLast`, `next`,
`prev`.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/department/list?take=10&page=1&search=Engineering' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function getDepartmentList() {
const { data } = await axios.get(
'https://api.exode.biz/saas/v2/staff/department/list',
{
params: {
take: 10,
page: 1,
search: 'Engineering',
parentIds: [3],
},
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
getDepartmentList();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 2,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 4,
"createdAt": "2026-07-02T11:15:47.234Z",
"updatedAt": "2026-07-02T11:15:47.247Z",
"archivedAt": null,
"schoolId": 198,
"parentId": 3,
"extId": null,
"name": "Backend & Infra Team"
},
{
"id": 3,
"createdAt": "2026-07-02T11:15:47.223Z",
"updatedAt": "2026-07-02T11:15:47.223Z",
"archivedAt": null,
"schoolId": 198,
"parentId": null,
"extId": "dep_engineering",
"name": "Engineering"
}
],
"next": {
"skip": 0,
"take": 10,
"page": 1
},
"prev": {
"skip": 0,
"take": 10,
"page": 1
}
}
}
```
## Create a department
```
POST /saas/v2/staff/department/create
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
### Request parameters
Department name. From 1 to 100 characters. Leading and trailing whitespace is trimmed automatically.
External identifier of the department from the client's system (CRM/1C). From 1 to 50 characters; cannot contain `/`
or whitespace (URL-safe, because it is used in `ext/{extId}` paths). Must be unique within the school among
non-deleted departments; otherwise the `StaffDepartmentExtIdIsNotUniq` error is returned.
ID of the parent department. Specify the parent with `parentId` **or** `parentExtId` — pass at most one field
of the pair. If specified, the parent must belong to the same school; otherwise the `StaffDepartmentNotFound`
error is returned. If neither field specifies a parent, the department is created as a root department.
External identifier (`extId`) of the parent department — an alternative to `parentId` (pass at most one field
of the pair). From 1 to 50 characters. The parent must be created before the child department and belong to
the same school; otherwise the `StaffDepartmentNotFound` error is returned.
Employment to assign as the primary manager of the department being created. Optional. Specify it
with `primaryManagerEmploymentId` **or** `primaryManagerEmploymentExtId`. The employment must be
active and already started (`startAt` is not in the future); otherwise `StaffEmploymentNotFound` is returned.
The employment's department can be any department. The result is the same as calling
[`department-manager/set`](/en/exode-api/school/staff/department-manager) with `isPrimary: true`.
External identifier (`extId`) of the manager's employment — an alternative to `primaryManagerEmploymentId`
(pass at most one field of the pair). From 1 to 50 characters.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/department/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Engineering",
"extId": "dep_engineering",
"parentId": null
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function createDepartment() {
const { data } = await axios.post(
'https://api.exode.biz/saas/v2/staff/department/create',
{
name: 'Engineering',
extId: 'dep_engineering',
parentId: null,
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
createDepartment();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 3,
"createdAt": "2026-07-02T11:15:47.223Z",
"updatedAt": "2026-07-02T11:15:47.223Z",
"archivedAt": null,
"schoolId": 198,
"parentId": null,
"extId": "dep_engineering",
"name": "Engineering"
}
}
```
```json Error - Parent Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Staff department not found",
"error": "Staff department not found"
}
```
```json Error - ExtId Is Not Uniq theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentExtIdIsNotUniq",
"message": "Staff department extId is not uniq",
"error": "Staff department extId is not uniq"
}
```
## Update a department
```
PUT /saas/v2/staff/department/{departmentId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
The request body is the same as for creation (all fields are optional): `name`, `extId`, the parent
(`parentId`/`parentExtId`) and the primary manager (`primaryManagerEmploymentId`/`primaryManagerEmploymentExtId`).
Pass only the fields you want to change.
### Parameters
ID of the department to update within the school.
New department name. From 1 to 100 characters. Leading and trailing whitespace is trimmed automatically.
New external identifier of the department (it can be reassigned). From 1 to 50 characters; cannot contain `/`
or whitespace (URL-safe). Must be unique within the school among non-deleted departments; otherwise the
`StaffDepartmentExtIdIsNotUniq` error is returned.
New parent department (re-parenting). Specify it with `parentId` **or** `parentExtId`.
Pass `null` to make the department a root department. The new parent must belong to the same school
(`StaffDepartmentNotFound`) and cannot be the department itself or one of its descendants; otherwise the
`StaffDepartmentParentCreatesCycle` error is returned.
External identifier (`extId`) of the new parent — an alternative to `parentId` (pass at most one field of the
pair). From 1 to 50 characters. `null` makes the department a root department. If the field is omitted, the parent does not change —
so always pass it when syncing, so that re-parenting in your HR system is reflected in Exode.
Employment to assign as the primary manager of the department. Specify it with
`primaryManagerEmploymentId` **or** `primaryManagerEmploymentExtId`. Pass `null` so that the department
has no primary manager: the current primary manager becomes a regular manager (the manager record
is kept; you can remove it completely with
[`department-manager/remove`](/en/exode-api/school/staff/department-manager#remove-a-manager)).
External identifier (`extId`) of the manager's employment — an alternative to `primaryManagerEmploymentId`
(pass at most one field of the pair). From 1 to 50 characters.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/department/4/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Backend & Infra Team"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function updateDepartment() {
const { data } = await axios.put(
'https://api.exode.biz/saas/v2/staff/department/4/update',
{
name: 'Backend & Infra Team',
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
updateDepartment();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 4,
"createdAt": "2026-07-02T11:15:47.234Z",
"updatedAt": "2026-07-02T11:15:47.247Z",
"archivedAt": null,
"schoolId": 198,
"parentId": 3,
"extId": null,
"name": "Backend & Infra Team"
}
}
```
```json Error - ExtId Is Not Uniq theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentExtIdIsNotUniq",
"message": "Staff department extId is not uniq",
"error": "Staff department extId is not uniq"
}
```
## Update a department by extId
```
PUT /saas/v2/staff/department/ext/{extId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Works like the regular update, but the department is looked up by its external identifier (`extId`) within the school.
The request body is the same as for `PUT /saas/v2/staff/department/{departmentId}/update`. The `extId` value in the path
must be URL-encoded. If no department with this `extId` is found within the school, the
`StaffDepartmentNotFound` error is returned.
### Parameters
External identifier of the department within the school. Must be URL-encoded.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/department/ext/dep_engineering/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Engineering & Research"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function updateDepartmentByExtId() {
const extId = encodeURIComponent('dep_engineering');
const { data } = await axios.put(
`https://api.exode.biz/saas/v2/staff/department/ext/${extId}/update`,
{
name: 'Engineering & Research',
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
updateDepartmentByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 3,
"createdAt": "2026-07-02T11:15:47.223Z",
"updatedAt": "2026-07-02T11:15:47.247Z",
"archivedAt": null,
"schoolId": 198,
"parentId": null,
"extId": "dep_engineering",
"name": "Engineering & Research"
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Staff department not found",
"error": "Staff department not found"
}
```
## Delete a department
```
DELETE /saas/v2/staff/department/{departmentId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
You can delete only a "leaf" department with no active employees:
* if the department has child units, the `StaffDepartmentHasChildren` error is returned (deleting a node
in the middle of the tree would orphan its descendants). Delete or move the child departments first;
* if the department has active employees (including a scheduled hire with a future date), the
`StaffDepartmentHasActiveEmployments` error is returned. Transfer or terminate the employees first. Closed
(terminated) employments do not block deletion.
### Parameters
ID of the department to delete within the school.
### Response
Number of affected (deleted) records. On successful deletion, `1`.
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/department/4/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function deleteDepartment() {
const { data } = await axios.delete(
'https://api.exode.biz/saas/v2/staff/department/4/delete',
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
deleteDepartment();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Has Children theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentHasChildren",
"message": "Staff department has child departments",
"error": "Staff department has child departments"
}
```
```json Error - Has Active Employments theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentHasActiveEmployments",
"message": "Staff department has active employments",
"error": "Staff department has active employments"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Delete a department by extId
```
DELETE /saas/v2/staff/department/ext/{extId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Works like the regular deletion, but the department is looked up by its external identifier (`extId`) within the school. The
same rules apply: you can delete only a "leaf" department with no child units and no active employees
(otherwise `StaffDepartmentHasChildren` / `StaffDepartmentHasActiveEmployments` is returned). The `extId` value in the path must
be URL-encoded. If no department with this `extId` is found within the school, the
`StaffDepartmentNotFound` error is returned.
### Parameters
External identifier of the department within the school. Must be URL-encoded.
### Response
Number of affected (deleted) records. On successful deletion, `1`.
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/department/ext/dep_engineering/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function deleteDepartmentByExtId() {
const extId = encodeURIComponent('dep_engineering');
const { data } = await axios.delete(
`https://api.exode.biz/saas/v2/staff/department/ext/${extId}/delete`,
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
deleteDepartmentByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Staff department not found",
"error": "Staff department not found"
}
```
```json Error - Has Children theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentHasChildren",
"message": "Staff department has child departments",
"error": "Staff department has child departments"
}
```
```json Error - Has Active Employments theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentHasActiveEmployments",
"message": "Staff department has active employments",
"error": "Staff department has active employments"
}
```
***
*Updated: 2026-10-16 13:24 UTC*
# Department managers
Source: https://docs.exode.biz/en/exode-api/school/staff/department-manager
Assign and remove department managers in a corporate school
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
A department manager is linked to an employee's **active employment**, not to the user.
The manager's employment can belong to any department of the school: for example, the head of a unit
can be listed in a higher-level department. A department can have several managers, but only
one of them is the primary manager (`isPrimary`).
The assignment follows the employee: on a [transfer or position change](/en/exode-api/school/staff/employment)
it automatically moves to the new employment record, and on termination of that employment it is
removed. You do not need to call `remove` on termination.
The REST API has no list of managers, and `managerId` is returned only in the `set` response. So when assigning,
pass your own `extId`: you can use it to remove the manager via `ext/{extId}/remove` without storing
internal IDs. You can also set a department's primary manager with the `primaryManagerEmploymentExtId` field
when [creating or updating a department](/en/exode-api/school/staff/department).
All staff module endpoints are available **only** to schools in the `Corporate` segment. For other segments, the request
returns `401` with `cause: "Forbidden"` and the message `Allowed only for Corporate school`.
## Assign a manager
```
POST /saas/v2/staff/department-manager/set
```
Requires authentication and the [**"Staff Management"**](/en/exode-api/permissions) permission (`StaffManage`).
The endpoint works as an upsert: if a manager is already assigned for the department + employment pair,
the `isPrimary` flag and `extId` are updated; otherwise a new record is created. A department can have only one
primary manager: with `isPrimary=true`, the department's previous primary manager is automatically
demoted (their `isPrimary` flag is reset to `false`). The department and the employment must belong to
the school, and the employment must be active and already started (a hire with a future `startAt` cannot be assigned as manager
before that date); otherwise the `StaffDepartmentNotFound` / `StaffEmploymentNotFound` errors are returned.
You can specify the department and the employment either by numeric ID or by external ID:
in each of the pairs `departmentId`/`departmentExtId` and `employmentId`/`employmentExtId`, pass exactly one field.
### Request parameters
Department ID. The department must belong to the school. Required if `departmentExtId` is not passed.
External department ID (`extId`). An alternative to `departmentId`: pass exactly one field of the pair.
ID of the active employment of the employee being assigned as manager. Required if
`employmentExtId` is not passed.
External employment ID (`extId`). An alternative to `employmentId`: pass exactly one field
of the pair.
External ID of the manager record from the client's system (CRM/1C). 1 to 50 characters; cannot contain
`/` or whitespace, since the value is used in `ext/{extId}` paths. Must be unique within the school
among non-deleted records; otherwise the `StaffDepartmentManagerExtIdIsNotUniq` error is returned.
Make the employee the department's primary manager. Defaults to `false`. With `true`, the previous primary
manager automatically stops being primary. The default also applies to an existing
record: a repeated `set` without `isPrimary` turns the primary manager into a regular one. For regular synchronization,
pass `isPrimary` explicitly.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/department-manager/set' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"departmentId": 4,
"employmentId": 9,
"extId": "dm-4b6a-9c2e",
"isPrimary": true
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const setManager = async () => {
const { data } = await axios.post('https://api.exode.biz/saas/v2/staff/department-manager/set', {
departmentId: 4,
employmentId: 9,
extId: 'dm-4b6a-9c2e',
isPrimary: true,
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
setManager();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 2,
"createdAt": "2026-07-02T11:15:47.328Z",
"updatedAt": "2026-07-02T11:15:47.328Z",
"archivedAt": null,
"schoolId": 198,
"departmentId": 4,
"employmentId": 9,
"isPrimary": true,
"extId": "dm-4b6a-9c2e"
}
}
```
```json Error - Manager Already Exists theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentManagerAlreadyExists",
"message": "Staff department manager already exists",
"error": "Staff department manager already exists"
}
```
```json Error - ExtId Is Not Uniq theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentManagerExtIdIsNotUniq",
"message": "Staff department manager extId is not uniq",
"error": "Staff department manager extId is not uniq"
}
```
```json Error - Department Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Staff department not found",
"error": "Staff department not found"
}
```
```json Error - Employment Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentNotFound",
"message": "Staff active employment not found",
"error": "Staff active employment not found"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Remove a manager
```
DELETE /saas/v2/staff/department-manager/{managerId}/remove
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Deletes the department manager record (soft delete). After the primary manager is removed, the department
has no primary manager until a new one is assigned.
### Request parameters
ID of the department manager record (the `id` field from the assignment response).
### Response
Number of deleted records (`1` if the manager was removed successfully).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/department-manager/2/remove' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const removeManager = async () => {
const { data } = await axios.delete('https://api.exode.biz/saas/v2/staff/department-manager/2/remove', {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
removeManager();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Remove a manager by extId
```
DELETE /saas/v2/staff/department-manager/ext/{extId}/remove
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Same as removal by `managerId`, but the manager record is looked up by external ID (`extId`) within the school.
Deletes the department manager record (soft delete). If no record with the given `extId` is found among
the school's non-deleted records, the `StaffDepartmentManagerNotFound` error is returned.
### Request parameters
External ID of the department manager record (`extId`). The path value must be URL-encoded.
### Response
Number of deleted records (`1` if the manager was removed successfully).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/department-manager/ext/dm-4b6a-9c2e/remove' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const removeManagerByExtId = async () => {
const extId = encodeURIComponent('dm-4b6a-9c2e');
const { data } = await axios.delete(`https://api.exode.biz/saas/v2/staff/department-manager/ext/${extId}/remove`, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
removeManagerByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Manager Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentManagerNotFound",
"message": "Staff department manager not found",
"error": "Staff department manager not found"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Permission requirements
The staff module is available only to schools in the `Corporate` segment. Assigning and removing department managers
requires the **"Staff Management"** permission (`StaffManage`).
The service user must be authenticated with a token and have the appropriate access rights to the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# Employments
Source: https://docs.exode.biz/en/exode-api/school/staff/employment
Hire, transfer, promote and terminate employees of a corporate school
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
An employment (`employment`) links a school user to a department and, optionally, a position.
An employee can have several employment records in their history, but only **one active** record for
a given department + position pair. You can omit the position — the employee is then listed in the
department **without a position**, and an active record of this kind is unique per department + user pair.
The record also stores the employment terms: kind (`kind`), type (`type`) and rate (`rate`).
All endpoints of the staff module are available **only** to schools in the `Corporate` segment. For other segments the request
returns `401` with `cause: "Forbidden"` and the message `Allowed only for Corporate school`.
If the school has an org-structure source app connected (for example, the Verifix integration), employments
with an `extId` are managed by that app, and changing such records through the API returns
`MiniappInstallationManagedEntity` (`data.installationId` holds the ID of the app installation). The restriction
applies to `hire` with an `extId` in the body (the `extId` namespace belongs to the integration), to `transfer` and
`terminate` of a record with an `extId`, and to `promote` of a record with an `extId` when the position changes
(or the grade — when the integration syncs grades). Records without an `extId` can still be changed through the API.
Besides a position, an employment can be given a [grade](/en/exode-api/school/staff/grade) (`gradeId`) — a level
of the school's career ladder. A grade is available when grades are enabled in the school, otherwise
`StaffGradesDisabled` is returned. If the position has a limited list of grades, only a grade from that list
can be assigned (`StaffGradeNotForPosition`). If the [position is linked to departments](/en/exode-api/school/staff/position),
it can be assigned only in one of them (`StaffPositionNotInDepartment`); a position without links is common to
the whole school and available in any department.
Transfer (`transfer`) and promotion (`promote`) do not modify the current record; they **close** it (`finishAt`, status
`Terminated`) and create a **new** active record in the target department or with the new position. The employment
terms (`kind`, `type`, `rate`) and the external identifier `extId` are carried over to the new record unchanged.
The response contains the new employment record — **with a new `id`**. Therefore, store the employment's
`extId` in your system, not its `id`: the `id` changes with every transfer and promotion, the `extId` does not. A department
manager assignment moves to the new record automatically; previously created absences remain
linked to the closed record.
An employment can have an external identifier `extId` — the record's ID in the client's system (for example, an HR system).
From 1 to 50 characters, without `/` or whitespace. It is unique within the school **among open (not finished)**
employments: on transfer or promotion the `extId` moves to the new active record (the closed one keeps a
copy), and after termination it is released — you can pass it again when rehiring. Separate routes
`ext/{extId}/transfer`, `ext/{extId}/promote` and `ext/{extId}/terminate` work by `extId`; they only find
an open record, so for an already closed employment they return `StaffEmploymentNotFound`.
### Which operation to choose
| Event in the HR system | Operation |
| - | - |
| Hiring, a new secondary job | [`hire`](#hire-an-employee) — creates another active record |
| Transfer to another department (same position) | [`transfer`](#transfer-to-another-department) |
| Position and/or grade change within the same department | [`promote`](#change-position) |
| Both the department and the position (or grade) changed | [`transfer`](#transfer-to-another-department) with `toPositionId` / `toGradeId` — in a single operation |
| Termination from one job | [`terminate`](#terminate-an-employee) |
The API has no employment deletion: a record is closed only through `terminate` and stays in the history. Every
modifying operation comes in two variants: by `employmentId` in the request body and by `extId` in the path
(`ext/{extId}/...`). They work the same way; the `extId` variant is more convenient for synchronization — you don't need to store
Exode's internal IDs.
## List employments
```
GET /saas/v2/staff/employment/list
```
Requires authentication and the [**"Staff browsing"**](/en/exode-api/permissions) permission (`StaffView`).
### Request parameters
Array parameters are passed by repeating the parameter in the query string: `userIds=1&userIds=2&userIds=3`.
#### Pagination
Number of records to skip. Defaults to `0`.
Page number (an alternative to `skip`). Starts at `1`.
Number of records per page. Defaults to `100`, maximum `1000`.
#### Filtering
Filter by employment IDs. Up to 250 values.
Filter by employment external identifiers (`extId`). Up to 250 values, each up to 50 characters.
Filter by user IDs. Up to 250 values.
Filter by department IDs. Up to 250 values.
Filter by department external identifiers (`extId`). Up to 250 values, each up to 50 characters.
Filter by position IDs. Up to 250 values.
Filter by position external identifiers (`extId`). Up to 250 values, each up to 50 characters.
Filter by [grade](/en/exode-api/school/staff/grade) IDs. Up to 100 values.
`true` — only employees who have met every condition for moving to the next grade; `false` — only those who
have not met them yet.
Filter by employment status. Possible values: `Active`, `Terminated`. Up to 250 values.
If `true`, returns only employments that are in effect right now: status `Active` and a `startAt` date
that has already passed. A hire with a future `startAt` is not included until that date — to get such
records as well, use `statuses=Active`.
Search by employee: full name, login, user ID. Maximum 50 characters.
#### Sorting
Sort direction by record creation date: `ASC` or `DESC`. The `id` and `updatedAt` parameters work
the same way. Without sorting parameters, records of the same employee are listed together (grouped by `userId`).
### Response fields
Paginated list of employments.
Array of employments.
Employment ID.
School ID.
ID of the employee user.
Position ID, or `null` for an employment without a position.
[Grade](/en/exode-api/school/staff/grade) ID, or `null` if no grade is assigned.
Department ID.
External identifier of the employment from the client's system, or `null`. Unique within the school among open (not finished) records; moves to the new record on transfer/promotion.
Employment start date (ISO 8601).
Employment end date (ISO 8601), or `null` for an active record.
Status: `Active` or `Terminated`.
Employment kind: `Main` (main job), `InternalSecondary` (internal secondary job), `ExternalSecondary` (external secondary job).
Employment type: `FullTime` (full-time) or `PartTime` (part-time).
Rate — a fraction of the full rate, from `0.01` to `1` (for example, `0.5`).
Record creation date.
Last update date.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Parameters of the next page (`skip`, `take`, `page`).
Parameters of the previous page (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/list?take=10&activeOnly=true' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listEmployments = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/staff/employment/list', {
params: { take: 10, activeOnly: true },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listEmployments();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 4,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 9,
"createdAt": "2026-07-02T11:15:47.312Z",
"updatedAt": "2026-07-02T11:15:47.312Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1683,
"positionId": 4,
"gradeId": 2,
"departmentId": 4,
"extId": "emp-1683",
"startAt": "2026-07-02T11:15:47.316Z",
"finishAt": null,
"status": "Active",
"kind": "Main",
"type": "FullTime",
"rate": 1
},
{
"id": 8,
"createdAt": "2026-07-02T11:15:47.295Z",
"updatedAt": "2026-07-02T11:15:47.312Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1683,
"positionId": 3,
"gradeId": null,
"departmentId": 4,
"extId": "emp-1683",
"startAt": "2026-07-02T11:15:47.299Z",
"finishAt": "2026-07-02T11:15:47.316Z",
"status": "Terminated",
"kind": "Main",
"type": "FullTime",
"rate": 1
}
],
"next": {
"skip": 0,
"take": 10,
"page": 1
},
"prev": {
"skip": 0,
"take": 10,
"page": 1
}
}
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffView",
"message": "Forbidden seller resource - permissions StaffView"
}
```
## Hire an employee
```
POST /saas/v2/staff/employment/hire
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Creates a new active employment record. The user, the department and the position (if specified) must
belong to the school. The employee cannot have an active duplicate with the same department + position pair, and when
hiring without a position — with the same department + user pair. If the user was previously terminated and has
the `Terminated` status, hiring automatically restores their `Active` status.
The department is **required** and is specified by ID or external identifier: pass exactly one parameter of the
`departmentId` / `departmentExtId` pair. The position is **optional**: pass exactly one parameter of the
`positionId` / `positionExtId` pair, or pass neither — the employee is then hired without a position. You cannot pass
both fields of the same pair at once. If the position is linked to departments, the hiring department must be
among them, otherwise `StaffPositionNotInDepartment` is returned.
The grade is **optional** and is set the same way — with one parameter of the `gradeId` / `gradeExtId` pair. It is
checked for belonging to the school (`StaffGradeNotFound`) and against the position's list of grades
(`StaffGradeNotForPosition`); when grades are disabled in the school, `StaffGradesDisabled` is returned.
### Request parameters
ID of the user being hired. The user must already be registered in the school, otherwise
`UserNotBelongsToSchool` is returned. The ID is returned when the user is created; you can find it by your external
identifier with [`user/find`](/en/exode-api/school/user/find). To create a user
and hire them in a single call, use `extra.staff.employments` in
[`user/create`](/en/exode-api/school/user/create) or [`user/upsert`](/en/exode-api/school/user/upsert).
External identifier of the employment from the client's system. From 1 to 50 characters, without `/` or whitespace.
Must be unique within the school among open (not finished) employments. If the school has an org-structure
source app connected, a hire with an `extId` returns `MiniappInstallationManagedEntity`.
Position ID. The position must belong to the school and be available in the hiring department (a position
without department links is available everywhere). Optional: pass one parameter of the
`positionId` / `positionExtId` pair, or pass neither — the employee is hired without a position.
External identifier (`extId`) of the position — an alternative to `positionId`.
[Grade](/en/exode-api/school/staff/grade) ID. The grade must belong to the school and be in the position's list of
grades (if the list is set). Optional: pass one parameter of the `gradeId` / `gradeExtId` pair, or pass
neither — the employee is hired without a grade.
External identifier (`extId`) of the grade — an alternative to `gradeId`.
Department ID. The department must belong to the school. Pass exactly one parameter of the
`departmentId` / `departmentExtId` pair.
External identifier (`extId`) of the department — an alternative to `departmentId`.
Employment start date in ISO 8601 format (for example, `2026-07-02T00:00:00.000Z`). Defaults to the current
moment. You can specify a past date (history migration) or a future date (a scheduled hire): a future record
gets the `Active` status right away, but until `startAt` arrives it is not included in `activeOnly=true` results and cannot
be assigned as a department manager.
Employment kind: `Main` (main job), `InternalSecondary` (internal secondary job),
`ExternalSecondary` (external secondary job). Defaults to `Main`.
Employment type: `FullTime` (full-time) or `PartTime` (part-time). Defaults to `FullTime`.
Rate — a fraction of the full rate, from `0.01` to `1`. Defaults to `1`.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/hire' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userId": 1683,
"extId": "emp-1683",
"positionId": 3,
"departmentId": 3,
"startAt": "2026-07-02T00:00:00.000Z",
"kind": "Main",
"type": "PartTime",
"rate": 0.5
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const hireEmployment = async () => {
const { data } = await axios.post('https://api.exode.biz/saas/v2/staff/employment/hire', {
userId: 1683,
extId: 'emp-1683',
positionId: 3,
departmentId: 3,
startAt: '2026-07-02T00:00:00.000Z',
kind: 'Main',
type: 'PartTime',
rate: 0.5,
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
hireEmployment();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 6,
"createdAt": "2026-07-02T11:15:47.273Z",
"updatedAt": "2026-07-02T11:15:47.273Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1683,
"positionId": 3,
"gradeId": null,
"departmentId": 3,
"extId": "emp-1683",
"startAt": "2026-07-02T11:15:47.273Z",
"finishAt": null,
"status": "Active",
"kind": "Main",
"type": "PartTime",
"rate": 0.5
}
}
```
```json Error - Employment Already Exists theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentAlreadyExists",
"message": "Staff active employment already exists",
"error": "Staff active employment already exists"
}
```
```json Error - ExtId Is Not Uniq theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentExtIdIsNotUniq",
"message": "Staff employment extId is not uniq",
"error": "Staff employment extId is not uniq"
}
```
```json Error - Position Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNotFound",
"message": "Staff position not found",
"error": "Staff position not found"
}
```
```json Error - Department Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Staff department not found",
"error": "Staff department not found"
}
```
```json Error - Position Not In Department theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNotInDepartment",
"message": "Staff position is not available in the department",
"error": "Staff position is not available in the department"
}
```
```json Error - Grade Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNotFound",
"message": "Staff grade not found",
"error": "Staff grade not found"
}
```
```json Error - Grade Not For Position theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNotForPosition",
"message": "Staff grade is not a grade of the position",
"error": "Staff grade is not a grade of the position"
}
```
```json Error - Grades Disabled theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradesDisabled",
"message": "Staff grades are disabled",
"error": "Staff grades are disabled"
}
```
```json Error - User Not Belongs To School theme={null}
{
"code": 400,
"success": false,
"cause": "UserNotBelongsToSchool",
"message": "User does not belong to the school",
"error": "User does not belong to the school"
}
```
```json Error - Managed By Integration theme={null}
{
"code": 400,
"success": false,
"cause": "MiniappInstallationManagedEntity",
"message": "The record is managed by a miniapp",
"error": "The record is managed by a miniapp",
"data": {
"installationId": 12
}
}
```
## Transfer to another department
```
POST /saas/v2/staff/employment/transfer
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Closes the current active employment record (`finishAt`, status `Terminated`) and creates a **new** active
record in the target department. The target department must belong to the school. If the employee already has
an open record with the same position in the target department, `StaffEmploymentAlreadyExists` is returned.
By default the position and the grade are kept; if the current position is linked to departments and the target
department is not among them, `StaffPositionNotInDepartment` is returned. To change the position and/or the grade
at the same time, pass `toPositionId` and/or `toGradeId` — a single operation instead of a `transfer` + `promote`
pair. The new position and grade are checked the same way as when [changing the position](#change-position), but
against the target department.
### Request parameters
ID of the active employment record being transferred.
ID of the target department. The department must belong to the school. Pass exactly one parameter of the
`toDepartmentId` / `toDepartmentExtId` pair.
External identifier (`extId`) of the target department — an alternative to `toDepartmentId`.
ID of the new position in the target department. The value `null` **removes** the position. Not passed — the
position is kept. Specified by ID only (there is no `extId` variant).
ID of the new [grade](/en/exode-api/school/staff/grade). The value `null` **removes** the grade. Specified by
ID only (there is no `extId` variant).
Transfer date in ISO 8601 format. Must not be earlier than the current record's `startAt`, otherwise
`StaffEmploymentInvalidTransitionDate` is returned. Defaults to the current moment.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/transfer' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"employmentId": 6,
"toDepartmentId": 4,
"startAt": "2026-07-02T00:00:00.000Z"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const transferEmployment = async () => {
const { data } = await axios.post('https://api.exode.biz/saas/v2/staff/employment/transfer', {
employmentId: 6,
toDepartmentId: 4,
startAt: '2026-07-02T00:00:00.000Z',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
transferEmployment();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 8,
"createdAt": "2026-07-02T11:15:47.295Z",
"updatedAt": "2026-07-02T11:15:47.295Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1683,
"positionId": 3,
"gradeId": null,
"departmentId": 4,
"extId": "emp-1683",
"startAt": "2026-07-02T11:15:47.299Z",
"finishAt": null,
"status": "Active",
"kind": "Main",
"type": "FullTime",
"rate": 1
}
}
```
```json Error - Employment Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentNotFound",
"message": "Staff active employment not found",
"error": "Staff active employment not found"
}
```
```json Error - Invalid Transition Date theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentInvalidTransitionDate",
"message": "Staff employment transition date is outside employment interval",
"error": "Staff employment transition date is outside employment interval"
}
```
```json Error - Employment Already Exists theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentAlreadyExists",
"message": "Staff active employment already exists",
"error": "Staff active employment already exists"
}
```
```json Error - Position Not In Department theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNotInDepartment",
"message": "Staff position is not available in the department",
"error": "Staff position is not available in the department"
}
```
## Transfer by external identifier
```
POST /saas/v2/staff/employment/ext/{extId}/transfer
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Same as transfer by `employmentId`, but the active employment record is looked up by the external identifier
`extId` within the school. The request body is the same as for a regular transfer, without the `employmentId` field:
pass `toPositionId` / `toGradeId` if needed to change the position and grade in the same operation.
### Request parameters
External identifier of the active employment record. The value in the path must be URL-encoded.
ID of the target department. The department must belong to the school. Pass exactly one parameter of the
`toDepartmentId` / `toDepartmentExtId` pair.
External identifier (`extId`) of the target department — an alternative to `toDepartmentId`.
ID of the new position in the target department. The value `null` **removes** the position. Not passed — the
position is kept. Specified by ID only (there is no `extId` variant).
ID of the new [grade](/en/exode-api/school/staff/grade). The value `null` **removes** the grade. Specified by
ID only (there is no `extId` variant).
Transfer date in ISO 8601 format. Must not be earlier than the current record's `startAt`, otherwise
`StaffEmploymentInvalidTransitionDate` is returned. Defaults to the current moment.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/ext/emp-1683/transfer' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"toDepartmentExtId": "dep-sales",
"startAt": "2026-07-02T00:00:00.000Z"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const transferEmploymentByExtId = async () => {
const extId = encodeURIComponent('emp-1683');
const { data } = await axios.post(`https://api.exode.biz/saas/v2/staff/employment/ext/${extId}/transfer`, {
toDepartmentExtId: 'dep-sales',
startAt: '2026-07-02T00:00:00.000Z',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
transferEmploymentByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 8,
"createdAt": "2026-07-02T11:15:47.295Z",
"updatedAt": "2026-07-02T11:15:47.295Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1683,
"positionId": 3,
"gradeId": null,
"departmentId": 4,
"extId": "emp-1683",
"startAt": "2026-07-02T11:15:47.299Z",
"finishAt": null,
"status": "Active",
"kind": "Main",
"type": "FullTime",
"rate": 1
}
}
```
```json Error - Employment Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentNotFound",
"message": "Staff active employment not found",
"error": "Staff active employment not found"
}
```
## Change position
```
POST /saas/v2/staff/employment/promote
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Closes the current active employment record (`finishAt`, status `Terminated`) and creates a **new** active
record with the new position and/or grade in the same department. The target position must belong to the school and
be available in the current department (`StaffPositionNotInDepartment`). If the employee already has an open record
with the new position in this department, `StaffEmploymentAlreadyExists` is returned.
The request must state the intent explicitly: pass a position (`toPositionId` **or** `toPositionExtId`) and/or a
grade (`toGradeId` **or** `toGradeExtId`) to assign them, or `null` to **remove** them (the employee stays in the
department without a position or without a grade). If you pass none of these fields, the
`StaffEmploymentInputRequired` error is returned.
The grade is checked the same way as on hire: `StaffGradeNotFound`, `StaffGradeNotForPosition`,
`StaffGradesDisabled`. If only the position changes, the current grade is kept when it is in the new position's list
of grades, and is reset to `null` when it is not.
### Request parameters
ID of the active employment record being changed.
ID of the target position. The position must belong to the school. Pass exactly one parameter of the
`toPositionId` / `toPositionExtId` pair. The value `null` **removes** the position — the employment stays in the
department without a position. You cannot pass both fields of the pair at once.
External identifier (`extId`) of the target position — an alternative to `toPositionId`.
ID of the target [grade](/en/exode-api/school/staff/grade). Pass exactly one parameter of the
`toGradeId` / `toGradeExtId` pair. The value `null` **removes** the grade.
External identifier (`extId`) of the target grade — an alternative to `toGradeId`.
Position change date in ISO 8601 format. Must not be earlier than the current record's `startAt`, otherwise
`StaffEmploymentInvalidTransitionDate` is returned. Defaults to the current moment.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/promote' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"employmentId": 8,
"toPositionId": 4,
"toGradeId": 2,
"startAt": "2026-07-02T00:00:00.000Z"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const promoteEmployment = async () => {
const { data } = await axios.post('https://api.exode.biz/saas/v2/staff/employment/promote', {
employmentId: 8,
toPositionId: 4,
toGradeId: 2,
startAt: '2026-07-02T00:00:00.000Z',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
promoteEmployment();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 9,
"createdAt": "2026-07-02T11:15:47.312Z",
"updatedAt": "2026-07-02T11:15:47.312Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1683,
"positionId": 4,
"gradeId": 2,
"departmentId": 4,
"extId": "emp-1683",
"startAt": "2026-07-02T11:15:47.316Z",
"finishAt": null,
"status": "Active",
"kind": "Main",
"type": "FullTime",
"rate": 1
}
}
```
```json Error - Employment Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentNotFound",
"message": "Staff active employment not found",
"error": "Staff active employment not found"
}
```
```json Error - Invalid Transition Date theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentInvalidTransitionDate",
"message": "Staff employment transition date is outside employment interval",
"error": "Staff employment transition date is outside employment interval"
}
```
```json Error - Employment Already Exists theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentAlreadyExists",
"message": "Staff active employment already exists",
"error": "Staff active employment already exists"
}
```
```json Error - Target Required theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentInputRequired",
"message": "Staff employment promote target position or grade is required",
"error": "Staff employment promote target position or grade is required"
}
```
```json Error - Position Not In Department theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNotInDepartment",
"message": "Staff position is not available in the department",
"error": "Staff position is not available in the department"
}
```
```json Error - Grade Not For Position theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNotForPosition",
"message": "Staff grade is not a grade of the position",
"error": "Staff grade is not a grade of the position"
}
```
```json Error - Managed By Integration theme={null}
{
"code": 400,
"success": false,
"cause": "MiniappInstallationManagedEntity",
"message": "The record is managed by a miniapp",
"error": "The record is managed by a miniapp",
"data": {
"installationId": 12
}
}
```
## Change position by external identifier
```
POST /saas/v2/staff/employment/ext/{extId}/promote
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Same as changing the position by `employmentId`, but the active employment record is looked up by the external identifier
`extId` within the school. The request body is the same as for a regular position change, without the `employmentId` field: pass
a position and/or a grade to assign them, or `null` to remove them; if none of these fields is passed, the
`StaffEmploymentInputRequired` error is returned.
### Request parameters
External identifier of the active employment record. The value in the path must be URL-encoded.
ID of the target position. The position must belong to the school. Pass exactly one parameter of the
`toPositionId` / `toPositionExtId` pair. The value `null` **removes** the position — the employment stays in the
department without a position. You cannot pass both fields of the pair at once.
External identifier (`extId`) of the target position — an alternative to `toPositionId`.
ID of the target [grade](/en/exode-api/school/staff/grade). Pass exactly one parameter of the
`toGradeId` / `toGradeExtId` pair. The value `null` **removes** the grade.
External identifier (`extId`) of the target grade — an alternative to `toGradeId`.
Position change date in ISO 8601 format. Must not be earlier than the current record's `startAt`, otherwise
`StaffEmploymentInvalidTransitionDate` is returned. Defaults to the current moment.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/ext/emp-1683/promote' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"toPositionExtId": "pos-lead",
"startAt": "2026-07-02T00:00:00.000Z"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const promoteEmploymentByExtId = async () => {
const extId = encodeURIComponent('emp-1683');
const { data } = await axios.post(`https://api.exode.biz/saas/v2/staff/employment/ext/${extId}/promote`, {
toPositionExtId: 'pos-lead',
startAt: '2026-07-02T00:00:00.000Z',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
promoteEmploymentByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 9,
"createdAt": "2026-07-02T11:15:47.312Z",
"updatedAt": "2026-07-02T11:15:47.312Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1683,
"positionId": 4,
"gradeId": 2,
"departmentId": 4,
"extId": "emp-1683",
"startAt": "2026-07-02T11:15:47.316Z",
"finishAt": null,
"status": "Active",
"kind": "Main",
"type": "FullTime",
"rate": 1
}
}
```
```json Error - Employment Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentNotFound",
"message": "Staff active employment not found",
"error": "Staff active employment not found"
}
```
## Terminate an employee
```
POST /saas/v2/staff/employment/terminate
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Closes an active employment record: sets `finishAt`, changes the status to `Terminated` and removes
the employee as manager of any departments. The response contains the closed record.
Terminating the **last active** employment automatically switches the user to the
`Terminated` status — access to the platform is closed: sign-in is blocked and active sessions are ended. Rehiring
(`hire`) a user with the `Terminated` status automatically restores their `Active` status.
Safeguards when terminating the last active employment: you cannot terminate yourself — the user
on whose behalf the request is made (`StaffCannotTerminateSelf`) — or the school owner
(`StaffCannotTerminateSchoolOwner`). Skip these users when reconciling the full employee list.
Termination takes effect at the moment of the call: the record gets the `Terminated` status immediately, and when
terminating the last employment, access is closed immediately — even if `finishAt` is set in the future. There is no deferred
termination, so call `terminate` on the actual termination day.
### Request parameters
ID of the active employment record being terminated.
Termination date in ISO 8601 format, stored in the record. Must not be earlier than the employment's `startAt`, otherwise
`StaffEmploymentInvalidTransitionDate` is returned. Defaults to the current moment.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/terminate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"employmentId": 7,
"finishAt": "2026-07-02T00:00:00.000Z"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const terminateEmployment = async () => {
const { data } = await axios.post('https://api.exode.biz/saas/v2/staff/employment/terminate', {
employmentId: 7,
finishAt: '2026-07-02T00:00:00.000Z',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
terminateEmployment();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 7,
"createdAt": "2026-07-02T11:15:47.284Z",
"updatedAt": "2026-07-02T11:15:47.400Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1684,
"positionId": 3,
"gradeId": null,
"departmentId": 4,
"extId": "emp-1684",
"startAt": "2026-07-02T11:15:47.285Z",
"finishAt": "2026-07-02T11:15:47.401Z",
"status": "Terminated",
"kind": "Main",
"type": "FullTime",
"rate": 1
}
}
```
```json Error - Invalid Transition Date theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentInvalidTransitionDate",
"message": "Staff employment transition date is outside employment interval",
"error": "Staff employment transition date is outside employment interval"
}
```
```json Error - Cannot Terminate Self theme={null}
{
"code": 400,
"success": false,
"cause": "StaffCannotTerminateSelf",
"message": "Staff cannot terminate himself",
"error": "Staff cannot terminate himself"
}
```
```json Error - Cannot Terminate School Owner theme={null}
{
"code": 400,
"success": false,
"cause": "StaffCannotTerminateSchoolOwner",
"message": "Staff school owner cannot be terminated",
"error": "Staff school owner cannot be terminated"
}
```
## Terminate by external identifier
```
POST /saas/v2/staff/employment/ext/{extId}/terminate
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Same as termination by `employmentId`, but the active employment record is looked up by the external identifier
`extId` within the school. The request body is the same as for a regular termination, without the `employmentId` field. The same
safeguards apply, as does the same logic of switching the user to the `Terminated` status when their last active
employment is terminated.
### Request parameters
External identifier of the active employment record. The value in the path must be URL-encoded.
Termination date in ISO 8601 format, stored in the record. Must not be earlier than the employment's `startAt`, otherwise
`StaffEmploymentInvalidTransitionDate` is returned. Defaults to the current moment.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/employment/ext/emp-1684/terminate' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"finishAt": "2026-07-02T00:00:00.000Z"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const terminateEmploymentByExtId = async () => {
const extId = encodeURIComponent('emp-1684');
const { data } = await axios.post(`https://api.exode.biz/saas/v2/staff/employment/ext/${extId}/terminate`, {
finishAt: '2026-07-02T00:00:00.000Z',
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload);
};
terminateEmploymentByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 7,
"createdAt": "2026-07-02T11:15:47.284Z",
"updatedAt": "2026-07-02T11:15:47.400Z",
"archivedAt": null,
"schoolId": 198,
"userId": 1684,
"positionId": 3,
"gradeId": null,
"departmentId": 4,
"extId": "emp-1684",
"startAt": "2026-07-02T11:15:47.285Z",
"finishAt": "2026-07-02T11:15:47.401Z",
"status": "Terminated",
"kind": "Main",
"type": "FullTime",
"rate": 1
}
}
```
```json Error - Employment Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentNotFound",
"message": "Staff active employment not found",
"error": "Staff active employment not found"
}
```
## Permission requirements
The staff module is available only to schools in the `Corporate` segment. Reading the list requires the **"Staff browsing"** permission (`StaffView`);
hiring, transfer, promotion and termination require the **"Staff Management"** permission (`StaffManage`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-10-16 13:24 UTC*
# Grades
Source: https://docs.exode.biz/en/exode-api/school/staff/grade
REST endpoints for managing the employee grade ladder in a corporate school
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
Grades are the levels of the school's career ladder (for example, Junior → Middle → Senior). There is one grade
ladder for all positions of the school. A grade is assigned to an employee on hiring, promotion and transfer (see
[employments](/en/exode-api/school/staff/employment)) and is used to calculate readiness for the next grade.
All staff module endpoints are available **only to schools in the corporate segment** (`Corporate`).
Requests from schools in other segments are rejected.
## Permission requirements
Reading (`list`) requires the [**"Staff browsing"**](/en/exode-api/permissions) permission (`StaffView`). Creating, updating and deleting require the **"Staff Management"** permission (`StaffManage`).
In all cases, token authentication is required and the school must belong to the `Corporate` segment.
The service user must be authenticated with a token and have the appropriate access rights to the specified
school.
## How the ladder works
**Order.** The `order` field is the grade's place on the ladder: the higher the `order`, the higher the grade. A new
grade is created at the top of the ladder. The order of grades is changed in the interface (**School → Structure →
Grades**, by dragging a row); the REST API does not change the order. The grade list is always returned from the
lowest grade to the highest.
**Enabling grades.** Grades are enabled in the **"Grades"** section of the interface. The ladder can be set up in
advance — via the API or in the interface — but a grade can be assigned to an employee (`gradeId` in `hire`,
`toGradeId` in `promote` and `transfer`) only after grades are enabled; otherwise the `StaffGradesDisabled` error is
returned.
**Position grades.** A position can limit the list of grades that fit it — the `gradeIds` parameter when
[creating and updating a position](/en/exode-api/school/staff/position). If no limit is set, any grade of the school
fits the position. An employee can be assigned only a grade of their position; otherwise `StaffGradeNotForPosition`
is returned.
**Transition conditions.** The conditions for moving to a grade (the courses an employee in a specific position has
to complete) are configured in the interface. Readiness for the next grade is calculated from them — employees who
have met every condition can be fetched with the `readyForPromotion` filter of the
[employment list](/en/exode-api/school/staff/employment).
A grade name is **unique within the school**, case-insensitive ("Senior" and "senior" are the same name). Creating a
grade with, or renaming a grade to, a name that already exists in the same school returns the
`StaffGradeNameIsNotUniq` error.
The `extId` field is the grade's external ID from the client's system (CRM/1C, for example a GUID). It is unique
within the school among non-deleted records. 1 to 50 characters, cannot contain `/` or whitespace characters,
because the value is used in `ext/{extId}` paths.
## List grades
```
GET /saas/v2/staff/grade/list
```
Requires authentication and the **"Staff browsing"** permission (`StaffView`).
The endpoint returns a paginated list of grades ordered by `order` — from the lowest grade to the highest.
Pagination and filter parameters are passed as query parameters.
### Parameters
Number of records to skip (offset pagination). Minimum `0`.
Number of records returned per page. From `1` to `1000`.
Page number (alternative to `skip`). Minimum `1`.
Filter by grade IDs. Up to 250 values.
Filter by a list of external IDs (`extId`). Up to 250 values, each at most 50 characters.
Grades that fit the position: its own list of grades, or every grade of the school if the position has no limit.
Search by grade name. Maximum 50 characters. The search is fuzzy (it also finds partial matches), so to find a
specific grade by name, compare the `name` of the found records with your value case-insensitively — or, more
reliably, search by `extIds`.
### Response
Paginated object: `items` (array of grades), `page`, `count`, `pages`, `isFirst`, `isLast`, `next`, `prev`.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/grade/list?take=10&page=1' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function getGradeList() {
const { data } = await axios.get(
'https://api.exode.biz/saas/v2/staff/grade/list',
{
params: {
take: 10,
page: 1,
},
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
getGradeList();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 2,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 7,
"createdAt": "2026-10-12T09:20:11.104Z",
"updatedAt": "2026-10-12T09:20:11.104Z",
"archivedAt": null,
"schoolId": 198,
"name": "Junior",
"order": 1,
"description": "Works from ready-made instructions under a mentor's guidance",
"extId": null
},
{
"id": 8,
"createdAt": "2026-10-12T09:21:36.518Z",
"updatedAt": "2026-10-12T09:21:36.518Z",
"archivedAt": null,
"schoolId": 198,
"name": "Middle",
"order": 2,
"description": null,
"extId": "b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18"
}
],
"next": {
"skip": 0,
"take": 10,
"page": 1
},
"prev": {
"skip": 0,
"take": 10,
"page": 1
}
}
}
```
## Create a grade
```
POST /saas/v2/staff/grade/create
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
A new grade goes to the top of the ladder — it gets the highest `order` in the school. To put it in another place,
change the order in the interface.
### Request parameters
Grade name. 1 to 100 characters. Leading and trailing spaces are trimmed automatically. Must be unique within the
school.
Grade description — how it differs from the neighbouring grades. Up to 500 characters. Leading and trailing spaces
are trimmed automatically.
The grade's external ID from the client's system (CRM/1C). 1 to 50 characters, cannot contain `/` or whitespace
characters. Must be unique within the school among non-deleted records.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/grade/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Middle",
"extId": "b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function createGrade() {
const { data } = await axios.post(
'https://api.exode.biz/saas/v2/staff/grade/create',
{
name: 'Middle',
extId: 'b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18',
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
createGrade();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 8,
"createdAt": "2026-10-12T09:21:36.518Z",
"updatedAt": "2026-10-12T09:21:36.518Z",
"archivedAt": null,
"schoolId": 198,
"name": "Middle",
"order": 2,
"description": null,
"extId": "b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18"
}
}
```
```json Error - Name Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNameIsNotUniq",
"message": "Staff grade name is not uniq",
"error": "Staff grade name is not uniq"
}
```
```json Error - ExtId Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeExtIdIsNotUniq",
"message": "Staff grade extId is not uniq",
"error": "Staff grade extId is not uniq"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Update a grade
```
PUT /saas/v2/staff/grade/{gradeId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
### Parameters
ID of the grade to update within the school.
New grade name. 1 to 100 characters. Leading and trailing spaces are trimmed automatically. Must be unique within
the school.
New grade description. Up to 500 characters. `null` clears the description.
The grade's external ID from the client's system (CRM/1C). 1 to 50 characters, cannot contain `/` or whitespace
characters. Must be unique within the school among non-deleted records.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/grade/8/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"description": "Independently handles mid-sized tasks"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function updateGrade() {
const { data } = await axios.put(
'https://api.exode.biz/saas/v2/staff/grade/8/update',
{
description: 'Independently handles mid-sized tasks',
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
updateGrade();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 8,
"createdAt": "2026-10-12T09:21:36.518Z",
"updatedAt": "2026-10-12T10:02:48.330Z",
"archivedAt": null,
"schoolId": 198,
"name": "Middle",
"order": 2,
"description": "Independently handles mid-sized tasks",
"extId": "b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18"
}
}
```
```json Error - Name Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNameIsNotUniq",
"message": "Staff grade name is not uniq",
"error": "Staff grade name is not uniq"
}
```
```json Error - ExtId Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeExtIdIsNotUniq",
"message": "Staff grade extId is not uniq",
"error": "Staff grade extId is not uniq"
}
```
## Update a grade by extId
```
PUT /saas/v2/staff/grade/ext/{extId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
The counterpart of the update by ID: the grade is looked up by its external ID `extId` within the school.
The `extId` value in the path must be URL-encoded.
### Parameters
External ID (`extId`) of the grade to update within the school. Must be URL-encoded.
New grade name. 1 to 100 characters. Leading and trailing spaces are trimmed automatically. Must be unique within
the school.
New grade description. Up to 500 characters. `null` clears the description.
New external ID of the grade. 1 to 50 characters, cannot contain `/` or whitespace characters.
Must be unique within the school among non-deleted records.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/grade/ext/b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Middle+"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function updateGradeByExtId() {
const extId = encodeURIComponent('b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18');
const { data } = await axios.put(
`https://api.exode.biz/saas/v2/staff/grade/ext/${extId}/update`,
{
name: 'Middle+',
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
updateGradeByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 8,
"createdAt": "2026-10-12T09:21:36.518Z",
"updatedAt": "2026-10-12T10:15:02.917Z",
"archivedAt": null,
"schoolId": 198,
"name": "Middle+",
"order": 2,
"description": null,
"extId": "b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18"
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNotFound",
"message": "Staff grade not found",
"error": "Staff grade not found"
}
```
```json Error - Name Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNameIsNotUniq",
"message": "Staff grade name is not uniq",
"error": "Staff grade name is not uniq"
}
```
```json Error - ExtId Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeExtIdIsNotUniq",
"message": "Staff grade extId is not uniq",
"error": "Staff grade extId is not uniq"
}
```
## Delete a grade
```
DELETE /saas/v2/staff/grade/{gradeId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
A grade cannot be deleted while it is assigned to employees in open employments: the method returns
`StaffGradeHasActiveEmployments`. First change the employees' grade via
[`promote`](/en/exode-api/school/staff/employment) (the `toGradeId` parameter) or terminate them.
On deletion the grade is removed from every position it was assigned to, and its transition conditions are deleted.
Employees' readiness for the next grade is recalculated.
### Parameters
ID of the grade to delete within the school.
### Response
Number of deleted records (`1` on successful deletion).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/grade/8/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function deleteGrade() {
const { data } = await axios.delete(
'https://api.exode.biz/saas/v2/staff/grade/8/delete',
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
deleteGrade();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Has Active Employments theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeHasActiveEmployments",
"message": "Staff grade has active employments",
"error": "Staff grade has active employments"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Delete a grade by extId
```
DELETE /saas/v2/staff/grade/ext/{extId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
A grade cannot be deleted while it is assigned to employees in open employments: the method returns
`StaffGradeHasActiveEmployments`. First change the employees' grade via
[`promote`](/en/exode-api/school/staff/employment) (the `toGradeId` parameter) or terminate them.
The counterpart of the deletion by ID: the grade is looked up by its external ID `extId` within the school.
The `extId` value in the path must be URL-encoded.
### Parameters
External ID (`extId`) of the grade to delete within the school. Must be URL-encoded.
### Response
Number of deleted records (`1` on successful deletion).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/grade/ext/b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function deleteGradeByExtId() {
const extId = encodeURIComponent('b7c1e9d2-4a3f-4e8b-9f61-2d0a5c7e3b18');
const { data } = await axios.delete(
`https://api.exode.biz/saas/v2/staff/grade/ext/${extId}/delete`,
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
deleteGradeByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNotFound",
"message": "Staff grade not found",
"error": "Staff grade not found"
}
```
```json Error - Has Active Employments theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeHasActiveEmployments",
"message": "Staff grade has active employments",
"error": "Staff grade has active employments"
}
```
***
*Updated: 2026-10-16 13:24 UTC*
# Positions
Source: https://docs.exode.biz/en/exode-api/school/staff/position
REST endpoints for managing employee positions in a corporate school
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
Positions are a directory of job titles for school employees (for example, "Backend Engineer"). Positions
are assigned to employees on hiring and promotion and are used by the staff module to describe the employment
structure.
All staff module endpoints are available **only to schools in the corporate segment** (`Corporate`).
Requests from schools in other segments are rejected.
## Permission requirements
Reading (`list`) requires the [**"Staff browsing"**](/en/exode-api/permissions) permission (`StaffView`). Creating, updating and deleting require the **"Staff Management"** permission (`StaffManage`).
In all cases, token authentication is required and the school must belong to the `Corporate` segment.
The service user must be authenticated with a token and have the appropriate access rights to the specified
school.
A position name is **unique within the school**, case-insensitive ("Manager" and "manager" are the same
name). Creating a position with, or renaming a position to, a name that already exists in the same school returns
the `StaffPositionNameIsNotUniq` error.
The `extId` field is the position's external ID from the client's system (CRM/1C, for example a GUID). It is unique within the
school among non-deleted records (unique index `school + extId`). 1 to 50 characters, cannot contain
`/` or whitespace characters, because the value is used in `ext/{extId}` paths.
## Linking to departments and grades
A position can be limited to a list of departments and a list of grades. Both links are **optional**: positions can
be created without them, this affects nothing, and they can be linked later.
* **`departmentIds`** — the departments where the position is available. Not passed or an empty array — the position
is **common to the whole school** (shown as "All departments" in the UI). When the link is set, an employee cannot
be hired, promoted or transferred to this position in another department — `StaffPositionNotInDepartment` is
returned (see [employments](/en/exode-api/school/staff/employment)).
* **`gradeIds`** — the [grades](/en/exode-api/school/staff/grade) that can be given to an employee in this position.
Not passed or an empty array — any grade of the school fits.
The array **replaces the whole list**: to add a department, pass the full new list. `[]` removes the link, and a
field omitted on update keeps the link unchanged. Pass the internal IDs of departments and grades (not `extId`):
a department ID by its `extId` is available via
[`department/list?extIds=...`](/en/exode-api/school/staff/department). The links are not returned in the position
response — `position/list?departmentId={id}` returns the positions of a department.
If the school has an org-structure source app connected (for example, the Verifix integration), positions with an
`extId` are managed by it: creating a position with an `extId`, changing the `name` or `extId` of such a position
and deleting it via the API return the `MiniappInstallationManagedEntity` error. The links to departments and
grades (`departmentIds`, `gradeIds`) can still be changed.
## List positions
```
GET /saas/v2/staff/position/list
```
Requires authentication and the **"Staff browsing"** permission (`StaffView`).
The endpoint returns a paginated list of positions. Pagination and filter parameters are passed as
query parameters.
### Parameters
Number of records to skip (offset pagination). Minimum `0`.
Number of records to return per page. From `1` to `1000`.
Page number (an alternative to `skip`). Minimum `1`.
Filter by position IDs. Up to 250 values.
Search by position name. Maximum 50 characters. The search is fuzzy (it also finds partial matches), so to
find a specific position by name, compare the `name` of the returned records with your value case-insensitively,
or, more reliably, search by `extIds`.
Filter by a list of external IDs (`extId`). Up to 250 values, each up to 50 characters.
Positions available in the department: the ones linked to it and the ones common to the whole school (with no
department link).
Sort direction by creation date. Possible values: `ASC`, `DESC`. The `id`
and `updatedAt` parameters work the same way.
### Response
Paginated object: `items` (array of positions), `page`, `count`, `pages`, `isFirst`, `isLast`, `next`,
`prev`.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/position/list?take=10&page=1&search=Backend' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function getPositionList() {
const { data } = await axios.get(
'https://api.exode.biz/saas/v2/staff/position/list',
{
params: {
take: 10,
page: 1,
search: 'Backend',
},
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
getPositionList();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 2,
"pages": 1,
"isFirst": true,
"isLast": true,
"items": [
{
"id": 4,
"createdAt": "2026-07-02T11:15:47.216Z",
"updatedAt": "2026-07-02T11:15:47.260Z",
"archivedAt": null,
"schoolId": 198,
"name": "Staff Backend Engineer",
"extId": "e2b4a1c0-7f3d-4b6a-9c2e-1d5f8a0b3c47"
},
{
"id": 3,
"createdAt": "2026-07-02T11:15:47.207Z",
"updatedAt": "2026-07-02T11:15:47.207Z",
"archivedAt": null,
"schoolId": 198,
"name": "Backend Engineer",
"extId": null
}
],
"next": {
"skip": 0,
"take": 10,
"page": 1
},
"prev": {
"skip": 0,
"take": 10,
"page": 1
}
}
}
```
## Create a position
```
POST /saas/v2/staff/position/create
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
### Request parameters
Position name. 1 to 100 characters. Leading and trailing spaces are trimmed automatically. Must be
unique within the school.
The position's external ID from the client's system (CRM/1C). 1 to 50 characters, cannot contain `/` or
whitespace characters. Must be unique within the school among non-deleted records.
IDs of the departments where the position is available. Up to 250 values. Not passed or `[]` — the position is
common to the whole school. Every department must belong to the school, otherwise `StaffDepartmentNotFound` is
returned. See [Linking to departments and grades](#linking-to-departments-and-grades).
IDs of the [grades](/en/exode-api/school/staff/grade) that can be given to an employee in this position. Up to 100
values. Not passed or `[]` — any grade fits. Every grade must belong to the school, otherwise `StaffGradeNotFound`
is returned.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/staff/position/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Backend Engineer",
"departmentIds": [12, 15]
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function createPosition() {
const { data } = await axios.post(
'https://api.exode.biz/saas/v2/staff/position/create',
{
name: 'Backend Engineer',
departmentIds: [12, 15],
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
createPosition();
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"id": 3,
"createdAt": "2026-07-02T11:15:47.207Z",
"updatedAt": "2026-07-02T11:15:47.207Z",
"archivedAt": null,
"schoolId": 198,
"name": "Backend Engineer",
"extId": null
}
}
```
```json Error - Name Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNameIsNotUniq",
"message": "Staff position name is not uniq",
"error": "Staff position name is not uniq"
}
```
```json Error - ExtId Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionExtIdIsNotUniq",
"message": "Staff position extId is not uniq",
"error": "Staff position extId is not uniq"
}
```
```json Error - Department Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Staff department not found",
"error": "Staff department not found"
}
```
```json Error - Grade Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffGradeNotFound",
"message": "Staff grade not found",
"error": "Staff grade not found"
}
```
```json Error - Managed By Integration theme={null}
{
"code": 400,
"success": false,
"cause": "MiniappInstallationManagedEntity",
"message": "The record is managed by a miniapp",
"error": "The record is managed by a miniapp",
"data": {
"installationId": 7
}
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Update a position
```
PUT /saas/v2/staff/position/{positionId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
### Parameters
ID of the position to update within the school.
New position name. 1 to 100 characters. Leading and trailing spaces are trimmed automatically. Must be
unique within the school.
The position's external ID from the client's system (CRM/1C). 1 to 50 characters, cannot contain `/` or
whitespace characters. Must be unique within the school among non-deleted records.
New list of IDs of the departments where the position is available (up to 250). Replaces the current list as a
whole; `[]` makes the position common to the whole school; not passed — the link is kept. `StaffDepartmentNotFound`
if a department is not found in the school.
New list of grade IDs of the position (up to 100). Replaces the current list as a whole; `[]` — any grade fits;
not passed — the list is kept. `StaffGradeNotFound` if a grade is not found in the school.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/position/4/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Staff Backend Engineer",
"departmentIds": [12]
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function updatePosition() {
const { data } = await axios.put(
'https://api.exode.biz/saas/v2/staff/position/4/update',
{
name: 'Staff Backend Engineer',
departmentIds: [12],
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
updatePosition();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 4,
"createdAt": "2026-07-02T11:15:47.216Z",
"updatedAt": "2026-07-02T11:15:47.260Z",
"archivedAt": null,
"schoolId": 198,
"name": "Staff Backend Engineer",
"extId": null
}
}
```
```json Error - Name Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNameIsNotUniq",
"message": "Staff position name is not uniq",
"error": "Staff position name is not uniq"
}
```
```json Error - ExtId Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionExtIdIsNotUniq",
"message": "Staff position extId is not uniq",
"error": "Staff position extId is not uniq"
}
```
```json Error - Department Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Staff department not found",
"error": "Staff department not found"
}
```
## Update a position by extId
```
PUT /saas/v2/staff/position/ext/{extId}/update
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
Equivalent of the update-by-ID endpoint: the position is looked up by the external ID `extId` within the school.
The `extId` value in the path must be URL-encoded.
### Parameters
External ID (`extId`) of the position to update within the school. Must be URL-encoded.
New position name. 1 to 100 characters. Leading and trailing spaces are trimmed automatically. Must be
unique within the school.
New external ID of the position. 1 to 50 characters, cannot contain `/` or whitespace characters.
Must be unique within the school among non-deleted records.
New list of IDs of the departments where the position is available (up to 250). Works the same as
[update by ID](#update-a-position).
New list of grade IDs of the position (up to 100). Works the same as [update by ID](#update-a-position).
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/staff/position/ext/e2b4a1c0-7f3d-4b6a-9c2e-1d5f8a0b3c47/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"name": "Staff Backend Engineer"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function updatePositionByExtId() {
const extId = encodeURIComponent('e2b4a1c0-7f3d-4b6a-9c2e-1d5f8a0b3c47');
const { data } = await axios.put(
`https://api.exode.biz/saas/v2/staff/position/ext/${extId}/update`,
{
name: 'Staff Backend Engineer',
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
updatePositionByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"id": 4,
"createdAt": "2026-07-02T11:15:47.216Z",
"updatedAt": "2026-07-02T11:15:47.260Z",
"archivedAt": null,
"schoolId": 198,
"name": "Staff Backend Engineer",
"extId": "e2b4a1c0-7f3d-4b6a-9c2e-1d5f8a0b3c47"
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNotFound",
"message": "Staff position not found",
"error": "Staff position not found"
}
```
```json Error - Name Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNameIsNotUniq",
"message": "Staff position name is not uniq",
"error": "Staff position name is not uniq"
}
```
```json Error - ExtId Is Not Unique theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionExtIdIsNotUniq",
"message": "Staff position extId is not uniq",
"error": "Staff position extId is not uniq"
}
```
## Delete a position
```
DELETE /saas/v2/staff/position/{positionId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
A position cannot be deleted while it has active employees (including a scheduled hire with a future date):
the method returns `StaffPositionHasActiveEmployments`. First change the employees' position via
[`promote`](/en/exode-api/school/staff/employment#change-position) or terminate them.
### Parameters
ID of the position to delete within the school.
### Response
Number of deleted records (`1` on successful deletion).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/position/4/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function deletePosition() {
const { data } = await axios.delete(
'https://api.exode.biz/saas/v2/staff/position/4/delete',
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
deletePosition();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Has Active Employments theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionHasActiveEmployments",
"message": "Staff position has active employments",
"error": "Staff position has active employments"
}
```
```json Error - Forbidden theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"error": "Forbidden seller resource - permissions StaffManage",
"message": "Forbidden seller resource - permissions StaffManage"
}
```
## Delete a position by extId
```
DELETE /saas/v2/staff/position/ext/{extId}/delete
```
Requires authentication and the **"Staff Management"** permission (`StaffManage`).
A position cannot be deleted while it has active employees (including a scheduled hire with a future date):
the method returns `StaffPositionHasActiveEmployments`. First change the employees' position via
[`promote`](/en/exode-api/school/staff/employment#change-position) or terminate them.
Equivalent of the delete-by-ID endpoint: the position is looked up by the external ID `extId` within the school.
The `extId` value in the path must be URL-encoded.
### Parameters
External ID (`extId`) of the position to delete within the school. Must be URL-encoded.
### Response
Number of deleted records (`1` on successful deletion).
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/staff/position/ext/e2b4a1c0-7f3d-4b6a-9c2e-1d5f8a0b3c47/delete' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
async function deletePositionByExtId() {
const extId = encodeURIComponent('e2b4a1c0-7f3d-4b6a-9c2e-1d5f8a0b3c47');
const { data } = await axios.delete(
`https://api.exode.biz/saas/v2/staff/position/ext/${extId}/delete`,
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload);
}
deletePositionByExtId();
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"affected": 1
}
}
```
```json Error - Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNotFound",
"message": "Staff position not found",
"error": "Staff position not found"
}
```
```json Error - Has Active Employments theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionHasActiveEmployments",
"message": "Staff position has active employments",
"error": "Staff position has active employments"
}
```
***
*Updated: 2026-10-16 13:24 UTC*
# Syncing with an HR system
Source: https://docs.exode.biz/en/exode-api/school/staff/sync-guide
End-to-end scenario for syncing the org structure and employees from 1C, a CRM or another HR system into Exode
This guide describes a typical scenario for exporting employees and the org structure from an external system (1C:ZUP, a CRM,
another LMS) into Exode: the order of calls, mapping of common fields, and where to send data that has no
direct field in the API.
Key principle: all entities are linked through **external identifiers** (`extId`) — GUIDs or codes from your
system. You don't need to store internal Exode IDs: departments, positions, grades, employments, absences and
managers are addressed by `extId` (`ext/{extId}/...` routes), users — through
[`user/find`](/en/exode-api/school/user/find), `user/ext/{extId}/update` and
[`user/upsert`](/en/exode-api/school/user/upsert). This is especially important for employments: on a transfer or a
position change, the record gets a new `id`, while the `extId` is preserved.
A complete walkthrough for 1C:ZUP: BSL code for the export loop (update by extId → NotFound → create),
1-to-1 JSON field mapping, domains from logins, positions by GUID, and prerequisites.
## Relationship diagram
How the entities of the staff module relate to each other:
```mermaid theme={null}
flowchart TD
School[School] --> Department[Department]
Department -->|parentId| Department
School --> Position[Position]
School --> Grade[Grade]
Position -.->|departmentIds| Department
Position -.->|gradeIds| Grade
School --> User[User]
User --> Employment[Employment]
Department --> Employment
Position --> Employment
Grade --> Employment
Employment --> Absence[Absence]
Department --> Manager[Manager / DepartmentManager]
Employment --> Manager
```
* **Department** — a school's organizational unit; the hierarchy is built through `parentId` (`parentExtId`).
* **A position** is common to the whole school by default. It can be limited to a list of departments
(`departmentIds`) and a list of grades (`gradeIds`) — both links are optional.
* **Grade** — a level of the school's career ladder (Junior → Middle → Senior), shared by all positions.
* **Employment** — the central link: user + department + position (and, when grades are enabled,
a grade), plus the employment terms (`kind`, `type`, `rate`).
* **A department manager** is linked not to the user directly, but to their **active
employment**.
* **An absence** (vacation, sick leave, business trip) is also linked to an employment.
## Sync order
Create the org structure using the methods in the [Departments](/en/exode-api/school/staff/department) section. Pass
`extId` (the department code from your system) and `parentExtId` for the hierarchy.
The parent department must exist when the child is created — sync the tree
**top-down** (or sort the export so that parents come before children).
```json theme={null}
{ "name": "Sales department", "extId": "DEPT-001", "parentExtId": "DEPT-000" }
```
On subsequent runs, call `PUT /staff/department/ext/{extId}/update` with `name` and `parentExtId` (`null` for
root departments), and on `StaffDepartmentNotFound` — `create`. This way, renames and re-parenting in your
system reach Exode. Note that `StaffDepartmentNotFound` is also returned when the **parent** from
`parentExtId` is not found — so the top-down order matters for updates too.
Departments and positions must be created **before** syncing employees. If `departmentExtId` is not found
when creating an employee, `StaffDepartmentNotFound` is returned and **the user is not created** (the same applies to
`StaffPositionNotFound`).
If your system has a grade ladder, create it using the methods in the
[Grades](/en/exode-api/school/staff/grade) section — `name`, `extId` and, if needed, `description`. The pattern is the
same as for positions: `PUT /staff/grade/ext/{extId}/update`, and on `StaffGradeNotFound` — `POST /staff/grade/create`.
Each new grade is placed at the top of the ladder, so create them **from the most junior to the most senior**.
Create grades before positions if you limit positions to a list of grades (`gradeIds`). To assign grades
to employees, grades must be enabled in the school — otherwise passing a grade on hire or promotion
returns `StaffGradesDisabled`. If you don't use grades, you can skip this step.
Create positions using the methods in the [Positions](/en/exode-api/school/staff/position) section — `name` and `extId`.
**The correct key is the position GUID from your system (`extId`), not the name**: the name is mutable and not unique on
your side (rename a position and name-based resolution breaks). Update the name on every
sync through `PUT /staff/position/ext/{extId}/update`, and create the position if it doesn't exist
(`StaffPositionNotFound` → `POST /staff/position/create`).
A position name is **unique within a school**: you cannot create two positions with the same name but different `extId`s (for example,
from different organizations in 1C) — `StaffPositionNameIsNotUniq` is returned. Consolidate the GUIDs into one
or don't pass the GUID for the duplicate organization.
If your positions have no codes at all (the export only has `job_position_name`), use the fallback: find
the position by name through `GET /staff/position/list?search=...`, and create it if it doesn't exist. The `search`
is fuzzy, so among the results pick the record whose `name` matches your value case-insensitively.
**Linking to departments is optional.** A position without links is common to the whole school, and employees
can be hired into it in any department. If in your system a position belongs only to certain
departments, pass `departmentIds` (up to 250 IDs) to `create` or `update`:
* the array **replaces the whole list**: `[]` removes the links (the position becomes common again), an omitted
field leaves the links unchanged;
* `departmentIds` accepts only **internal Exode department IDs** (there is no `extId` variant) — take
them from the `department/create` response or from `GET /staff/department/list?extIds=...`. So sync positions
with links **after** departments; an unknown ID or an ID from another school returns `StaffDepartmentNotFound`;
* the links are not returned in the position response — you can check them with
`GET /staff/position/list?departmentId=N`: it returns the positions available in the department (linked to it
and common ones);
* the links are checked on hire, promotion and transfer: a position that is not available in the department returns
`StaffPositionNotInDepartment`.
Similarly, `gradeIds` limits the grades that can be assigned to an employee in this position (omitted or
empty — any grade of the school fits).
You can create a new employee with the `create` or `upsert` method (existing ones are updated with `update`). Which
one to choose depends on your export — primarily on whether you need to set a password: see
[Which method to use for creating an employee](#which-method-to-use-for-creating-an-employee) for details. In all cases, employments are
passed as an array in `extra.staff.employments` (each element is `positionExtId` + `departmentExtId`,
optionally `extId`, `gradeExtId`, `type`, `kind`, `startAt`, `rate`). For a corporate school, when **creating** a user
the array is required and must contain at least one employment (otherwise `StaffEmploymentInputRequired`).
If an employee belongs to **several departments**, pass several elements in
`extra.staff.employments` (from 1 to 10): the first employment with `kind: "Main"`, the rest with
`kind: "InternalSecondary"` (or `ExternalSecondary` for external part-time work).
`extra.staff.employments` can only **"make sure the assignment exists"** (if not — hire, if yes —
skip). It is **not a full list for syncing**: see the
[Employments assignment behavior](#employments-assignment-behavior) section below for important details about transfers,
terminations, and assignments missing from the array.
Assign department managers with the
[`staff/department-manager/set`](/en/exode-api/school/staff/department-manager) method: `departmentExtId` +
`employmentExtId` (the external identifier of the manager's employment that you passed when hiring).
The primary manager is marked with `isPrimary: true`. Pass `isPrimary` explicitly on every call: a repeated
`set` without it turns the primary manager into a regular one. Also pass the assignment's own `extId` —
you can use it to remove the manager through `ext/{extId}/remove` (the API has no list of managers). Only an
employment that has already started can be assigned as manager; when the employee is terminated from it, the assignment is removed automatically.
If your system specifies the manager for each employee (for example, `leader_external_ids`) rather than for
the department, assign them as the department manager and/or store the manager reference in a custom field
of the employee.
For data that has no field in the user schema (middle name, city, tags, an arbitrary CRM status,
an additional email), create custom fields in the [form layout](/en/exode-api/school/form-layout/create) and write the values
through [`custom-field/value/set-by-slug`](/en/exode-api/school/custom-field/set) after upserting the user.
Create vacations, sick leaves and business trips with the
[`staff/absence/create`](/en/exode-api/school/staff/absence) method, passing the employment's `employmentExtId` and the
`extId` of the absence record from your system; send changes through `PUT /staff/absence/ext/{extId}/update`
(on `StaffAbsenceNotFound` — create it). `startAt`/`finishAt` are points in time: for the absence to last
the whole last day, pass the end of that day (`...T23:59:59`), not its start. The platform sets the `OnLeave` status
automatically.
On termination, call [`staff/employment/terminate`](/en/exode-api/school/staff/employment#terminate-an-employee)
(by the employment's `extId`). Closing the **last active** employment automatically switches
the user to the `Terminated` status and revokes their access — you don't need to block them separately. If
the employee still has other active employments, terminating one of them is a structural change and
access is preserved. To block access **without terminating**, pass `status: "Blocked"` to
[`user/update`](/en/exode-api/school/user/update) — all active sessions are ended; see
[User status lifecycle](#user-status-lifecycle).
If your system returns the **full list** of employees (rather than changes), get the current active
employments through [`staff/employment/list`](/en/exode-api/school/staff/employment) (`activeOnly=true`),
compare them with the export by `extId`, and terminate the missing ones. Collect all pages of the list first and only then
call `terminate`: terminated records drop out of the selection, and if you terminate "on the fly", pages shift —
some records will be skipped. Exclude the user the integration runs as and the school owner
from the comparison — they cannot be terminated from their last employment.
`terminate` takes effect immediately, even if `finishAt` is in the future, so call it on the day of the actual
termination.
## Which method to use for creating an employee
Only `create` and `upsert` can create a new employee. User `update` **does not create** — it only
updates an existing one (and is part of the "update → NotFound → create" pattern). The choice between `create` and `upsert`
depends on whether you need to set a password.
* **`POST /user/create` — creates a new user.** If a user with the same login (`email`/`phone`/`domain`), `tgId`
or `extId` already exists, an error is returned (`EmailIsBusy`, `PhoneIsBusy`, `DomainIsBusy`, `TgIdIsBusy`,
`ExtIdIsBusy`). The password (`password`) is applied.
* **`PUT /user/upsert` — "create or update" in a single call.** Looks up an existing user by login / `tgId` / `extId`:
found → updates, not found → creates. Idempotent — re-exporting the same employee neither creates a duplicate nor
fails.
* **`PUT /user/ext/{extId}/update` (or `PUT /user/{userId}/update`) — only updates an existing user.** If
the user doesn't exist — `NotFound` (nothing is created). This method **does not set** the password.
**The password is applied only on creation** — in `create` and in the create branch of `upsert`. On **update** (including
when `upsert` found an existing user), `password` is ignored: the `update` method doesn't
support it.
**How to choose:**
* **You don't set the password** (we generate and send it on our side, or it isn't needed) → use **`upsert`**: one
call, idempotent, the simplest option for regular syncing.
* **You set the password** (for example, a deterministic "first name + last name + year") → create new users through **`create`** (the
password is guaranteed to apply there), and update existing ones through **`update`**. The typical pattern:
**`update` by `extId` → on `NotFound` → `create`** (everything is addressed by `extId`, no internal Exode IDs needed).
This won't work with `upsert`: if the user already exists, `upsert` goes to the update branch and the password you set
won't be applied.
## Employments assignment behavior
The `employments` array in `user/create`, `user/update` and `user/upsert` is applied **idempotently** and does exactly
one thing — "hire additionally if there is no such active assignment yet". This is the key difference from a "full sync",
and it's easy to miss:
* **Repeating the same department + position pair is a safe no-op.** You can send the same assignment on every
sync — no duplicate is created.
* **A new pair is a new assignment (part-time), not a transfer.** If the employee was transferred in 1C and you
send a new pair in `employments`, they will have **two active assignments** (the old one and the new one), not one.
* **An assignment missing from the array ≠ termination.** If you don't send an assignment, nothing happens to it — it
remains active. It can only be removed with an explicit `terminate`.
Therefore, HR changes for an existing employee are expressed **not through `extra`**, but through separate endpoints
in the [Employments](/en/exode-api/school/staff/employment) section:
| HR event | Endpoint |
| - | - |
| Transfer to another department | `POST /staff/employment/ext/{extId}/transfer` |
| Position and/or grade change (promotion) | `POST /staff/employment/ext/{extId}/promote` (`toPositionExtId`, `toGradeExtId`) |
| Both department and position changed | A single `transfer` with `toPositionId` (and `toGradeId` if needed) — get the position ID through `GET /staff/position/list?extIds=...` |
| Termination from an assignment | `POST /staff/employment/ext/{extId}/terminate` |
| New assignment / part-time | `POST /staff/employment/hire` (or an `employments` element) |
| Rehiring a terminated employee | `user/ext/{extId}/update` with `extra.staff.employments` (or `hire`) — the `Active` status is restored automatically |
A transfer without `toPositionId` keeps the current position, so it must be available in the new
department. If the position is linked to other departments, such a `transfer` returns
`StaffPositionNotInDepartment` — in that case pass the new position in the same call (`toPositionId`).
If the position and grade don't change, `toDepartmentExtId` is enough.
Practical takeaway for incremental exports: in `user/ext/{extId}/update` for an existing employee,
`extra.staff` is usually **not passed** — otherwise a transfer in 1C turns into an extra part-time assignment. Hiring additionally through
`extra` is appropriate when you deliberately add another job to the employee.
## User status lifecycle
The `user.status` field (`Active` / `OnLeave` / `Banned` / `Blocked` / `Terminated` / `Deleted`) is partially managed
automatically. `Banned`, `Blocked`, `Terminated` and `Deleted` revoke access; `Active` and `OnLeave` keep
sign-in open.
* **Termination from the last active employment** (`employment/terminate`) automatically switches the
user to `Terminated`: sign-in is denied and all sessions are ended. **Rehiring** (`hire`) an employee with
the `Terminated` status automatically restores `Active`. You cannot terminate yourself from your last assignment
(`StaffCannotTerminateSelf`) or the school owner (`StaffCannotTerminateSchoolOwner`).
* **Blocking by an admin** — `user/update { "status": "Blocked" }`: sign-in is closed, sessions are ended, new
product enrollments are prohibited; the data is kept and visible in reports. `{ "status": "Active" }` removes
the block. The school owner cannot be blocked.
* **`Active` does not restore employment.** If you pass `{ "status": "Active" }` to a terminated
(`Terminated`) user, sign-in opens, but they won't get an active employment. So don't
pass `"status": "Active"` to employees terminated in Exode, and handle rehiring through a hire — the `Active`
status is restored automatically.
* **`OnLeave` is managed by the [absences](/en/exode-api/school/staff/absence) module:** while an absence is in effect
(vacation/sick leave), the status automatically becomes `OnLeave`, and when it ends — returns to `Active`.
`OnLeave` is informational — **sign-in remains available**, you don't need to set it manually.
* **Ban and unban** — `user/update { "status": "Banned" }` revokes access. When the ban is lifted (pass `Active`),
the actual status is recalculated automatically based on employments and absences: a terminated user returns to
`Terminated`, a user on vacation — to `OnLeave`, the rest — to `Active`.
`OnLeave` sync also works **by calendar**: in addition to recalculation on API calls, once an hour the platform
automatically switches employees to `OnLeave` when an absence start date arrives and returns them to `Active`
when it ends. You no longer need to run your own periodic recalculation.
Mapping the status from the export: "Employed" → `Active` (removes a previously set block). "Terminated"
**is not passed as a status** — termination is handled through `employment/terminate`, and `Terminated` is set automatically.
## Mapping common fields
A `user/create` or `user/upsert` request body with comments — where each field comes from and what's important to know
(`// ←` shows the source field in your export):
```jsonc theme={null}
{
"email": "smith@company.com", // ← email. Sign-in field (login)
"phone": "+998901234567", // ← phone, intl. format. Sign-in field (login)
"domain": "j.smith.01011990", // ← your login. SIGN-IN IS DONE WITH IT. Latin letters/digits/_/dots as separators,
// ≤65, unique within the school. Cannot be digits only or look like id123
"extId": "j.smith.01011990", // ← GUID/external ID from your system. Sync key, NOT a login
"password": "SecurePass123", // ← if omitted and there is an email/phone — we generate and send it ourselves.
// Applied only on CREATION (see create vs upsert below)
"status": "Active", // ← "Employed" → Active. "Terminated" — not here, but through employment/terminate
"profile": {
"firstName": "John", // ← first name. ≤15 characters, letters/spaces/apostrophes
"lastName": "Smith", // ← last name. ≤15 characters, letters/spaces/apostrophes
// ← middle name — there is NO separate field → use a custom field
"bdate": "1990-01-01", // ← date of birth. ISO datetime → date only YYYY-MM-DD
"sex": "Men", // ← sex: "m" → Men, "f" → Women
"role": "Student" // ← role: Student / Tutor / Parent
},
"extra": {
"staff": {
"employments": [ // ← 1..10 employments (several = part-time)
{
"departmentExtId": "DEPT-001", // ← department code (create in advance)
"positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01", // ← position GUID (create in advance). The name is not a key
"kind": "Main", // ← Main; additional — InternalSecondary/ExternalSecondary
"startAt": "2020-03-01T00:00:00Z" // ← hire/assignment date (ISO 8601)
}
]
}
}
}
```
Fields that have **no direct counterpart** in the API:
* **Middle name, city, tags, arbitrary CRM status, additional email** → [custom fields](/en/exode-api/school/custom-field/set)
(`custom-field/value/set-by-slug` after creating/upserting the user).
* **Managers** (`leader_external_ids`) → a separate call to
[`staff/department-manager/set`](/en/exode-api/school/staff/department-manager) (`departmentExtId` + `employmentExtId`).
* **Roles/tags with no counterpart** — are ignored.
## Limitations to know in advance
* **`domain` — up to 65 characters**, Latin letters, digits and `_`; dots are allowed as segment separators
(not at the start/end and not consecutive). Logins like `j.smith.01011990` are allowed. A domain cannot consist of digits
only or look like `id123`. The server converts it to lowercase; the value is **unique within the school** — a taken one
returns `DomainIsBusy`.
* **An employee without an email or phone** can sign in only with `domain` — for them, **you must pass `password`**,
otherwise they have no way to sign in.
* **`firstName` and `lastName` — up to 15 characters**, letters (Latin/Cyrillic), spaces and apostrophes. Truncate long full names
on your side.
* **The `extId` of any entity** (user, department, position, employment, absence,
manager) — up to 50 characters, **no `/` or spaces** (it's used in `ext/{extId}` paths); dots are
allowed. `user.extId` is not a login.
* **You cannot sign in with `extId` or `tgId`** — the sign-in fields are `email`, `phone`, `domain`.
* The employments module (`staff`) is available only for **corporate schools**; for them, the
`extra.staff.employments` array (from 1 to 10 employments) is required when creating a user.
**If the school has an org-structure source app connected** (for example, the Verifix integration), it manages
the records that have an `extId`. For such records, the API returns `MiniappInstallationManagedEntity`:
* when creating a department, position or employment with an `extId`;
* when changing synced fields — the name and `extId`, and for a department also the parent (`parentId`,
`parentExtId`);
* when deleting a department or position;
* on transfer and termination of such employments, and on a promotion that changes the position (and the grade,
if the integration syncs grades).
You can still change a position's department and grade links (`departmentIds`, `gradeIds`) — send them
in a separate request, without `name` and `extId`. Records without an `extId`, as well as absences and department
managers, are not affected by the lock.
## Minimal example: an employee with an employment
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/user/upsert' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"email": "smith@company.com",
"domain": "j.smith.01011990",
"extId": "j.smith.01011990",
"status": "Active",
"profile": {
"firstName": "John",
"lastName": "Smith",
"bdate": "1990-01-01",
"sex": "Men"
},
"extra": {
"staff": {
"employments": [
{
"positionExtId": "manager",
"departmentExtId": "DEPT-001",
"kind": "Main",
"type": "FullTime",
"startAt": "2020-03-01T00:00:00Z"
}
]
}
}
}'
```
After the upsert, write non-standard attributes to custom fields:
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/form/custom-field/value/set-by-slug' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userId": 1683,
"layoutId": 12,
"values": [
{ "slug": "middle_name", "value": "Robert" },
{ "slug": "city", "value": "Tashkent" },
{ "slug": "crm_status", "value": "Employed" }
]
}'
```
***
*Updated: 2026-10-16 13:24 UTC*
# Create a user
Source: https://docs.exode.biz/en/exode-api/school/user/create
Create a new user in the school with profile data
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
POST /saas/v2/user/create
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
## Request parameters
### Sign-in fields (login)
The fields **`email`, `phone` and `domain`** serve as the login for signing in to the school. To have the sign-in
credentials sent automatically, pass at least one of them.
`extId` and `tgId` **are not logins** — you cannot sign in with them. They are used only as
identifiers: `extId` links the user to your CRM/LMS, `tgId` links the account to Telegram.
The login and password are sent to the user **automatically** (a few seconds after creation): by SMS if `phone` is
passed and an SMS provider is connected in the school for that phone's country, otherwise to `email`; additionally
to Telegram if `tgId` is passed. If no channel is available (for example, only `domain` is passed, or a phone
without a connected SMS provider), get the password from the user's settings in the account and deliver it yourself.
If you pass the `password` field, the user gets the specified password instead of a generated one, and that
password is what gets sent through the channels above. To **not send the credentials at all** (for example, you
deliver the password to the user yourself), pass `skipSendCredentials: true`.
To receive the login and password in Telegram, the user must allow the bot to message them or have an active chat
with that bot.
**`create` or `upsert`?** `create` always creates a new user and returns an error (`EmailIsBusy`,
`PhoneIsBusy`, `ExtIdIsBusy`, etc.) if the login or identifier is already taken. If, when syncing from your
system, you don't know whether the user already exists in the school, use [`user/upsert`](/en/exode-api/school/user/upsert) —
it finds the existing user and updates it.
The user's email address. Must be a valid email. An empty string is converted to
`null`.
The user's phone number. Must be in international format (for example, +9876543210). An empty string is converted to `null`.
The user's domain login. Up to 65 characters: Latin letters, digits, `_` and dots — a dot cannot be first/last and
dots cannot be consecutive (automatically converted to lower
case). Must be unique within the school. Purely numeric values and values like `id12345`
are reserved by the system and cannot be set manually (`DomainIsBusy` is returned). If not passed, it is generated
automatically in the `id12345` format. Useful when logins are generated on your side and the user has no email
or phone.
### Integration identifiers
The user's external identifier from your system (for example, a GUID from your CRM/1C). A string of up to 50
characters without `/` or spaces (it is used in the `user/ext/{extId}/update` path), or `null`. An empty string is
converted to `null`. Used to link and look up the user (`user/find`, `user/upsert`);
**not used for signing in**.
The user's Telegram ID. An integer or `null`.
### Additional parameters
Account status: `Active`, `OnLeave`, `Banned`, `Blocked` or `Terminated`. Defaults to `Active`.
* `Active` — regular access;
* `OnLeave` — "absent" (vacation, sick leave, etc.): an informational status that **does not block** sign-in or
access. Managed automatically by the [absences](/en/exode-api/school/staff/absence) module;
* `Banned` — banned: sign-in is denied and active sessions are terminated. When the ban is lifted, the effective
status is recalculated automatically (based on employments and absences);
* `Blocked` — blocked by an administrator: sign-in is denied, active sessions are terminated, and product
enrollments are blocked. The user remains visible in reports; switching back to `Active` restores access;
* `Terminated` — dismissed: access is closed the same way as for `Blocked`. Set automatically on termination of the
last active employment; rehiring returns `Active`.
The `Deleted` status is reserved by the system (account deletion) and cannot be passed.
The boolean `banned` field has been **removed** from the request — blocking is managed only through `status`
(`Banned` / `Blocked`). The `banned` and `active` fields are still present in the response, derived from `status`.
The user's password. From 6 to 100 characters. If passed, it is set as the sign-in password instead of a
generated one, and this password is what gets sent to the user (unless `skipSendCredentials: true` is passed).
An empty string is converted to `null`. Available only on creation (the field is not supported in `update` and
`upsert`).
Do not send the sign-in credentials to the user (SMS, email and Telegram). Defaults to `false` — the credentials
are sent. If `password` is not passed, a password is still generated: you can view it in the user's settings in the
account and deliver it yourself.
### Profile parameters
An object with the user's profile data.
The user's first name. Up to 15 characters: letters (Latin and Cyrillic, including ў/ғ/қ/ҳ), spaces and apostrophes
(for example, `O'Brien`). Leading and trailing spaces are trimmed automatically.
The user's last name. Up to 15 characters: letters (Latin and Cyrillic, including ў/ғ/қ/ҳ), spaces and apostrophes
(for example, `O'Brien`). Leading and trailing spaces are trimmed automatically.
Date of birth in YYYY-MM-DD format.
The user's sex. Possible values: `Ufo`, `Women`, `Men`.
The user's role. Possible values: `Student`, `Tutor`, `Parent`.
When a user is created, a linked profile with the specified data is created automatically. If no profile is passed,
an empty profile is created, and the user fills in `firstName` and `lastName` on first sign-in.
### Additional aggregates (`extra`)
A container for related data applied together with user creation. It currently contains the `staff` block
(employments); other aggregates will be added in the future.
Employment data for the employee being created. Available **only for corporate schools**, where the block is
**required** — an employee cannot exist without an employment.
An array of employments (from 1 to 10). Multiple items mean concurrent employment: for example, a main job
plus an internal secondary job in another department.
A department is **required** in each item and is specified via `departmentId` **or**
`departmentExtId`. A position is **optional**: you can set it via `positionId` **or**
`positionExtId`, or omit it entirely — then the employee is hired without a position (external
identifiers come from your system, see
[Positions](/en/exode-api/school/staff/position) and [Departments](/en/exode-api/school/staff/department)).
The employment's external identifier from your system. From 1 to 50 characters, without `/` or spaces.
Unique within the school among open employments.
The position ID in Exode. Optional. Specify it via `positionId` **or** `positionExtId` (not both at
once), or omit it entirely — then the employee is hired without a position.
The position's external identifier from your system. From 1 to 50 characters. Optional — an alternative
to `positionId` (pass at most one field of the pair).
The department ID in Exode. Required if `departmentExtId` is not passed.
The department's external identifier from your system. From 1 to 50 characters. Required if
`departmentId` is not passed.
Employment kind: `Main` (main job), `InternalSecondary` (internal secondary job),
`ExternalSecondary` (external secondary job). Defaults to `Main`.
Employment type: `FullTime` or `PartTime`. Defaults to `FullTime`.
Employment start date in ISO 8601 format. Defaults to the current moment.
Rate — a fraction of a full-time rate, from `0.01` to `1`. Defaults to `1`.
Data not covered by this schema (city, a custom status from your CRM, etc.) is passed via
[custom fields](/en/exode-api/school/custom-field/set). Departments, positions and
employments are managed after creation in the [Staff (HR)](/en/exode-api/school/staff/employment) section.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"email": "user@example.com",
"phone": "+9876543210",
"domain": "i_ivanov",
"extId": "crm_12345",
"tgId": null,
"password": "SecurePass123",
"profile": {
"firstName": "Firstname",
"lastName": "Lastname",
"bdate": "1990-01-01",
"sex": "Men",
"role": "Student"
}
}'
```
```bash cURL (Corporate + staff) theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/create' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"email": "user@example.com",
"domain": "i_ivanov",
"extId": "guid-1c-4f2a",
"status": "Active",
"profile": {
"firstName": "Firstname",
"lastName": "Lastname"
},
"extra": {
"staff": {
"employments": [
{
"extId": "emp-1c-0042",
"positionExtId": "pos_manager",
"departmentExtId": "dep_sales",
"kind": "Main",
"type": "FullTime",
"startAt": "2026-07-01T00:00:00Z",
"rate": 1
}
]
}
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const createUser = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/user/create', {
email: 'user@example.com',
phone: '+9876543210',
domain: 'i_ivanov',
extId: 'crm_12345',
tgId: null,
password: 'SecurePass123',
profile: {
firstName: 'Firstname',
lastName: 'Lastname',
bdate: '1990-01-01',
sex: 'Men',
role: 'Student'
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('User created:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
createUser();
```
```php PHP theme={null}
'user@example.com',
'phone' => '+9876543210',
'domain' => 'i_ivanov',
'extId' => 'crm_12345',
'tgId' => null,
'password' => 'SecurePass123',
'profile' => [
'firstName' => 'Firstname',
'lastName' => 'Lastname',
'bdate' => '1990-01-01',
'sex' => 'Men',
'role' => 'Student'
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 201) {
$result = json_decode($response, true);
echo "User created successfully\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/user/create'
data = {
'email': 'user@example.com',
'phone': '+9876543210',
'domain': 'i_ivanov',
'extId': 'crm_12345',
'tgId': None,
'password': 'SecurePass123',
'profile': {
'firstName': 'Firstname',
'lastName': 'Lastname',
'bdate': '1990-01-01',
'sex': 'Men',
'role': 'Student'
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('User created successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Профиль = Новый Структура;
Профиль.Вставить("firstName", "Firstname");
Профиль.Вставить("lastName", "Lastname");
Профиль.Вставить("bdate", "1990-01-01");
Профиль.Вставить("sex", "Men");
Профиль.Вставить("role", "Student");
Данные = Новый Структура;
Данные.Вставить("email", "user@example.com");
Данные.Вставить("phone", "+9876543210");
Данные.Вставить("domain", "i_ivanov");
Данные.Вставить("extId", "crm_12345");
Данные.Вставить("tgId", Неопределено);
Данные.Вставить("password", "SecurePass123");
Данные.Вставить("profile", Профиль);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/create");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("User created");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"user": {
"id": 1683,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "i_ivanov",
"email": "user@example.com",
"phone": "+9876543210",
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"currentTime": "2026-07-02T11:15:46+00:00",
"isSleepingNow": false,
"profile": {
"id": 1665,
"createdAt": "2026-07-02T11:15:46.932Z",
"updatedAt": "2026-07-02T11:15:46.932Z",
"archivedAt": null,
"userId": 1683,
"official": false,
"firstName": "Firstname",
"lastName": "Lastname",
"fullName": "Firstname Lastname",
"fullNameShort": "Firstname L.",
"bdate": "1990-01-01",
"sex": "Men",
"country": null,
"city": null,
"role": "Student",
"status": null,
"title": "",
"emojiTitle": "",
"avatar": {
"id": 1665,
"small": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/small/avatar.png",
"medium": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/medium/avatar.png",
"maximum": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/avatar.png"
},
"titleState": {
"manualTitle": null,
"manualEmojiTitle": null,
"manualNextTitle": null,
"manualNextEmojiTitle": null,
"manualExpiredAt": null,
"locationTitle": null,
"locationEmojiTitle": null,
"achievementTitle": null,
"achievementEmojiTitle": null
}
}
}
}
}
```
```json Error - Email Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "EmailIsBusy",
"message": "Email is busy",
"error": "Email is busy"
}
```
```json Error - Phone Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "PhoneIsBusy",
"message": "Phone is busy",
"error": "Phone is busy"
}
```
```json Error - Telegram ID Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "TgIdIsBusy",
"message": "Tg id is busy",
"error": "Tg id is busy"
}
```
```json Error - Ext ID Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "ExtIdIsBusy",
"message": "Ext id is busy",
"error": "Ext id is busy"
}
```
```json Error - Domain Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "DomainIsBusy",
"message": "This domain is already taken",
"error": "This domain is already taken"
}
```
```json Error - Domain Is Invalid theme={null}
{
"code": 400,
"success": false,
"cause": "DomainIsInvalid",
"message": "Domain is invalid",
"error": "Domain is invalid"
}
```
```json Error - Invalid Phone theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"phone must be a valid phone number"
],
"error": "Bad Request"
}
```
```json Error - Invalid First Name theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"profile.firstName must be shorter than or equal to 15 characters"
],
"error": "Bad Request"
}
```
```json Error - Staff Is Required (Corporate) theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentInputRequired",
"message": "Staff employment input is required for Corporate school user",
"error": "Staff employment input is required for Corporate school user"
}
```
```json Error - Staff Only For Corporate School theme={null}
{
"code": 400,
"success": false,
"cause": "StaffOnlyForCorporateSchool",
"message": "Staff is available only for Corporate school",
"error": "Staff is available only for Corporate school"
}
```
```json Error - Staff Department Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffDepartmentNotFound",
"message": "Department not found",
"error": "Department not found"
}
```
```json Error - Staff Position Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "StaffPositionNotFound",
"message": "Position not found",
"error": "Position not found"
}
```
## Permission requirements
Creating a user requires the **"School User Management"** permission (`SchoolManageUsers`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-10-05 12:43 UTC*
# Delete users
Source: https://docs.exode.biz/en/exode-api/school/user/delete-many
Bulk delete school users by their IDs
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
This operation deletes users **permanently**. The school owner cannot be deleted: such IDs
are skipped and returned in the `skipped` field.
A deleted user gets the `Deleted` status: credentials are wiped and sign-in is impossible, but the data
is kept for reports. In [`user/list`](/en/exode-api/school/user/list), you can find such users with the
`statuses=Deleted` filter.
Deletion clears only `email`, `phone` and `tgId`, so you can reuse them for a new user right away.
`extId` and `domain` stay with the deleted record: [`user/find`](/en/exode-api/school/user/find) by `extId`
returns this user with the `Deleted` status, and creating a new user with the same `extId` or `domain`
fails with `ExtIdIsBusy` / `DomainIsBusy`. If you need to reuse the identifier, reset it
via [`user/update`](/en/exode-api/school/user/update) (`"extId": null`) **before** deletion.
```
DELETE /saas/v2/user/delete-many
```
## Request parameters
The maximum number of users per request is **250**. To delete more,
make several requests.
Array of numeric Exode user IDs (the `id` field from [`user/find`](/en/exode-api/school/user/find) or
[`user/list`](/en/exode-api/school/user/list)). Up to 250 per request. Calling again with the same IDs
is safe: already deleted users end up in `skipped`.
Reason for deletion. Up to 256 characters. Stored in the system for auditing.
## Response fields
IDs of the users that were successfully deleted.
IDs that were skipped. Skipped are: the school owner, manager employees (those who
have management access to the seller), already deleted users, and IDs that were not found or
belong to another school.
```bash cURL theme={null}
curl --location --request DELETE 'https://api.exode.biz/saas/v2/user/delete-many' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userIds": [123, 124, 125],
"reason": "Access closed on request"
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const deleteUsers = async () => {
const { data } = await axios.delete('https://api.exode.biz/saas/v2/user/delete-many', {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
data: {
userIds: [123, 124, 125],
reason: 'Access closed on request',
},
});
console.log(data.payload);
};
deleteUsers();
```
```php PHP theme={null}
[123, 124, 125],
'reason' => 'Access closed on request',
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
?>
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/user/delete-many'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
data = {
'userIds': [123, 124, 125],
'reason': 'Access closed on request'
}
response = requests.delete(url, json=data, headers=headers)
print(response.json())
```
```bsl 1С theme={null}
ИдентификаторыПользователей = Новый Массив;
ИдентификаторыПользователей.Добавить(123);
ИдентификаторыПользователей.Добавить(124);
ИдентификаторыПользователей.Добавить(125);
Данные = Новый Структура;
Данные.Вставить("userIds", ИдентификаторыПользователей);
Данные.Вставить("reason", "Access closed on request");
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/delete-many");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("DELETE", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Users deleted");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"deleted": [
123,
124
],
"skipped": [
125
]
}
}
```
## Permission requirements
Requires token authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
***
*Updated: 2026-09-25 14:33 UTC*
# Find by identifier
Source: https://docs.exode.biz/en/exode-api/school/user/find
Find a user in the school by login, Telegram ID or external extId
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/user/find
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
To find several users at once, use
[Find by a list of identifiers](/en/exode-api/school/user/find-many).
## Query parameters
##### To find a user, pass one of the parameters listed below
The search is performed by login (email/phone/domain), then by Telegram ID, then by the external identifier
`extId`. If several parameters are passed, they are checked in order `login` → `tgId` → `extId`, and
the first user found is returned: for example, if no one matches `login`, the search continues by `tgId`.
If no user is found by any parameter, `user: null` is returned (this is not an error; the response code is `200`).
User login, 2 to 50 characters. It can be an email address, a phone number in international format
or a domain login (`domain`) — by default it looks like `id12345`, but a custom one can be set when
creating/updating the user. The value is passed in the query string, so encode it
(`encodeURIComponent`): without encoding, the `+` sign in a phone number turns into a space — pass `%2B9876543210`.
The user's Telegram ID. An integer.
The user's external identifier from your system (1 to 50 characters) — the value passed in `extId` when
[creating](/en/exode-api/school/user/create) or [updating](/en/exode-api/school/user/update) the user.
A user's **login** can be:
* An email address (for example: `user@example.com`)
* A phone number in international format (for example: `+9876543210`)
* The user's domain (for example, `id12345`)
You must pass **at least one** of the parameters. Check order: `login` → `tgId` → `extId` — the first
user found is returned.
```bash cURL theme={null}
curl --location --request GET 'https://api.exode.biz/saas/v2/user/find?extId=crm_12345' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const findUser = async () => {
try {
const response = await axios.get('https://api.exode.biz/saas/v2/user/find', {
params: { extId: 'crm_12345' },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('User found:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
findUser();
```
```php PHP theme={null}
'crm_12345' ];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url . '?' . http_build_query($params));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$result = json_decode($response, true);
if ($result['payload']) {
echo "User found successfully\n";
print_r($result['payload']);
} else {
echo "User not found\n";
}
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/user/find'
params = { 'extId': 'crm_12345' }
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.get(url, params=params, headers=headers)
response.raise_for_status()
result = response.json()
if result['payload']:
print('User found successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
else:
print('User not found')
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/find?extId=crm_12345");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("User found");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success - User Found theme={null}
{
"success": true,
"code": 200,
"payload": {
"user": {
"id": 1683,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1683",
"email": "user@example.com",
"phone": "+9876543210",
"tgId": 987654321,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": "Uz",
"timezone": 5,
"lastOnlineAt": "2026-07-02T10:36:11.446Z",
"starsBalance": 0,
"currentTime": "2026-07-02T11:15:46+00:00",
"isSleepingNow": false,
"profile": {
"id": 1665,
"createdAt": "2026-07-02T11:15:46.932Z",
"updatedAt": "2026-07-02T11:15:46.932Z",
"archivedAt": null,
"userId": 1683,
"official": false,
"firstName": "Firstname",
"lastName": "Lastname",
"fullName": "Firstname Lastname",
"fullNameShort": "Firstname L.",
"bdate": null,
"sex": "Ufo",
"country": null,
"city": null,
"role": "Student",
"status": null,
"title": "",
"emojiTitle": "",
"avatar": {
"id": 1665,
"small": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/small/avatar.png",
"medium": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/medium/avatar.png",
"maximum": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/avatar.png"
},
"titleState": {
"manualTitle": null,
"manualEmojiTitle": null,
"manualNextTitle": null,
"manualNextEmojiTitle": null,
"manualExpiredAt": null,
"locationTitle": null,
"locationEmojiTitle": null,
"achievementTitle": null,
"achievementEmojiTitle": null
}
}
}
}
}
```
```json Success - User Not Found theme={null}
{
"success": true,
"code": 200,
"payload": {
"user": null
}
}
```
```json Error - Invalid Login theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"login must be longer than or equal to 2 characters"
],
"error": "Bad Request"
}
```
```json Error - Invalid Telegram ID theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"tgId must be an integer number"
],
"error": "Bad Request"
}
```
## Permission requirements
Finding a user requires the **"School User Management"** permission (`SchoolManageUsers`).
The service user must be authenticated by token and have the appropriate access permissions for the specified
school.
The search is performed only within the specified school. Users from other schools will not be found.
***
*Updated: 2026-09-25 14:33 UTC*
# Find by a list of identifiers
Source: https://docs.exode.biz/en/exode-api/school/user/find-many
Find multiple school users by lists of logins, Telegram IDs or external extIds
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
POST /saas/v2/user/find-many
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
To find a single user, use
[Find by identifier](/en/exode-api/school/user/find).
## Request parameters
##### Pass at least one of the identifier lists
The method returns an array of found users. Users that could not be found are not included in the response —
match the result to the request by `email`/`phone`/`domain`/`tgId`/`extId` on your side. If several lists are
passed, the conditions are combined with "OR": the response includes every user matching at least one
value from any list — each user at most once. The order of items in the response does not
match the order in the request.
A list of user logins: email, phone in international format, or domain login (`domain`, for example
`i_ivanov`; a value like `id12345` finds the user with ID `12345`). From 2 to 50 characters each, up to
250 items.
A list of users' Telegram IDs. Up to 250 items.
A list of external identifiers from your system. From 1 to 50 characters each, up to 250 items.
You must pass **at least one non-empty** list. Each list is limited to 250 items.
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/find-many' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"logins": ["user@example.com", "+9876543210"]
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const findManyUsers = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/user/find-many', {
logins: ['user@example.com', '+9876543210']
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Users found:', response.data.payload.users);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
findManyUsers();
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/user/find-many'
data = {
'logins': ['user@example.com', '+9876543210']
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print(json.dumps(result['payload']['users'], indent=2, ensure_ascii=False))
```
```json Success theme={null}
{
"success": true,
"code": 201,
"payload": {
"users": [
{
"id": 1683,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1683",
"email": "user@example.com",
"phone": null,
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"currentTime": "2026-07-02T11:15:46+00:00",
"isSleepingNow": false,
"profile": {
"id": 1665,
"createdAt": "2026-07-02T11:15:46.932Z",
"updatedAt": "2026-07-02T11:15:46.932Z",
"archivedAt": null,
"userId": 1683,
"official": false,
"firstName": "Firstname",
"lastName": "Lastname",
"fullName": "Firstname Lastname",
"fullNameShort": "Firstname L.",
"bdate": null,
"sex": "Ufo",
"country": null,
"city": null,
"role": "Student",
"status": null,
"title": "",
"emojiTitle": "",
"avatar": {
"id": 1665,
"small": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/small/avatar.png",
"medium": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/medium/avatar.png",
"maximum": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/avatar.png"
},
"titleState": {
"manualTitle": null,
"manualEmojiTitle": null,
"manualNextTitle": null,
"manualNextEmojiTitle": null,
"manualExpiredAt": null,
"locationTitle": null,
"locationEmojiTitle": null,
"achievementTitle": null,
"achievementEmojiTitle": null
}
}
}
]
}
}
```
```json Success - No Users Found theme={null}
{
"success": true,
"code": 201,
"payload": {
"users": []
}
}
```
```json Error - Empty Request theme={null}
{
"cause": "validation",
"code": 400,
"success": false,
"error": "Bad Request",
"message": [
"logins should not be empty"
]
}
```
## Permission requirements
Finding users requires the **"School User Management"** permission (`SchoolManageUsers`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified
school.
***
*Updated: 2026-09-25 14:33 UTC*
# List users
Source: https://docs.exode.biz/en/exode-api/school/user/list
Paginated list of school users with filtering by status, dates and search
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
GET /saas/v2/user/list
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
All filter parameters are optional. Without filters, the method returns all school users, subject to
pagination. Each response item is a
[`userWithProfile`](/en/exode-api/objects/entities/user) object (a user with a nested profile).
**Which method to choose.** If you need a single user by exact login, `tgId` or `extId`, use
[`user/find`](/en/exode-api/school/user/find). If you need to match a batch of IDs from your system (up to 250), use
[`user/find-many`](/en/exode-api/school/user/find-many). `user/list` is suited for paginated exports and selections
by filters (statuses, dates, `search` — substring search, not an exact match).
Pass array parameters by repeating the parameter: `statuses=Active&statuses=OnLeave`. Pass date ranges as
nested fields: `createdAtDateRange[from]=2026-01-01&createdAtDateRange[to]=2026-02-01`.
## Request parameters
### Pagination
Number of records per page. From `1` to `1000`. Defaults to `100`.
Page number (starting from `1`). An alternative to `skip`.
Number of records to skip. Defaults to `0`.
### Filtering
Search by first name, last name, email, phone and domain login. Maximum 50 characters.
Look for `search` in one field only: `Id` (exact match, `id123` also works), `Phone` (the digits of the number,
anywhere in it), `Email` (any part of the address), `Name` (first and last name in either order), `Login` (domain
login), `TgId` (exact match). Not passed — the search covers every field at once.
Filter by account status: `Active`, `OnLeave`, `Banned`, `Blocked`, `Terminated`, `Deleted`.
Statuses are described in the [`user`](/en/exode-api/objects/entities/user) reference.
Filter by the confirmed sign-in flag (`true`/`false`).
Filter by archive state: `true` — archived only, `false` — non-archived only.
Filter by user IDs.
Filter by external IDs from your system. 1 to 50 characters each, maximum 250 items.
Creation date range: `createdAtDateRange[from]`, `createdAtDateRange[to]` (ISO 8601).
Last activity range: `lastOnlineAtDateRange[from]`, `lastOnlineAtDateRange[to]` (ISO 8601).
Filter by sign-up UTM tags. The `value` field is an array of `{ key, value }` pairs (non-empty strings).
Archiving (deletion) date range: `archivedAtDateRange[from]`, `archivedAtDateRange[to]` (ISO 8601).
Filter by IDs of the users who performed the archiving (deletion).
Only employees with an active employment in the given [departments](/en/exode-api/school/staff/department) or in
any of their child departments. For `Corporate` schools.
Only employees with an active employment in the given [positions](/en/exode-api/school/staff/position). For
`Corporate` schools.
Range of the number of active product accesses: `productAccessesCountRange[from]`,
`productAccessesCountRange[to]` (numbers, bounds inclusive; one bound is enough).
Same as `productAccessesCountRange`, but only paid accesses are counted:
`productAccessesPurchasedCountRange[from]`, `productAccessesPurchasedCountRange[to]`.
Nested profile filter.
Search by profile data (up to 50 characters).
Whether an avatar has been uploaded.
### Sorting
Sort by ID: `ASC` or `DESC`.
Sort by creation date: `ASC` or `DESC`.
Sort by last activity: `ASC` or `DESC`.
Sort by update date: `ASC` or `DESC`.
Sort by "stars" balance: `ASC` or `DESC`.
Sort by number of product accesses: `ASC` or `DESC`.
Sort by archiving date: `ASC` or `DESC`.
Sort by internal order: `ASC` or `DESC`.
## Response fields
Paginated list of users. The item structure is described in the
[`user`](/en/exode-api/objects/entities/user) reference.
Array of `userWithProfile` objects.
Current page.
Total number of records.
Total number of pages.
Whether this is the first page.
Whether this is the last page.
Next page parameters (`skip`, `take`, `page`).
Previous page parameters (`skip`, `take`, `page`).
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/list?take=50&statuses=Active&statuses=OnLeave&createdAt=DESC' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js theme={null}
const axios = require('axios');
const listUsers = async () => {
const { data } = await axios.get('https://api.exode.biz/saas/v2/user/list', {
params: { take: 50, statuses: ['Active', 'OnLeave'], createdAt: 'DESC' },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
});
console.log(data.payload.items);
};
listUsers();
```
```python Python theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/user/list'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={
'take': 50,
'statuses': ['Active', 'OnLeave'],
'createdAt': 'DESC',
}, headers=headers)
print(response.json()['payload']['items'])
```
```bsl 1С theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/list?take=50&statuses=Active&statuses=OnLeave&createdAt=DESC");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("User list received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"page": 1,
"count": 245,
"pages": 5,
"isFirst": true,
"isLast": false,
"items": [
{
"id": 1683,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1683",
"email": "user@example.com",
"phone": null,
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"profile": {
"id": 1665,
"userId": 1683,
"official": false,
"firstName": "Firstname",
"lastName": "Lastname",
"fullName": "Firstname Lastname",
"sex": "Ufo",
"role": "Student"
}
}
],
"next": { "skip": 50, "take": 50, "page": 2 },
"prev": { "skip": 0, "take": 50, "page": 1 }
}
}
```
## Permission requirements
Requires token authentication and the **"School User Management"** permission (`SchoolManageUsers`).
***
*Updated: 2026-10-16 13:24 UTC*
# Create a session token
Source: https://docs.exode.biz/en/exode-api/school/user/session/auth-token
Create or get an active session token for a school user
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
POST /saas/v2/user/session/auth-token
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
## Request parameters
The user's numeric ID in Exode (the `id` field from [`user/find`](/en/exode-api/school/user/find),
[`user/create`](/en/exode-api/school/user/create) or [`user/upsert`](/en/exode-api/school/user/upsert)).
The user must belong to the school from the `School-Id` header; otherwise you get `401` with `cause: "Forbidden"`
and the message `seller not entity owner`.
Force creation of a new session. If `true`, a new session is always created, even if the user already has an active one.
If `false` or omitted, the existing active session is returned (or a new one is created if none exists).
The method creates a new session for the user or returns the existing active session. The session token is used to authenticate the user in the system. The `isCreated` flag in the response indicates whether a new session was created or an existing one was returned.
For users who have at least one permission in the admin panel (administrators, managers, employees
with management access), this method does not issue a token: it returns a `Forbidden` error with the message
`User is school admin or manager`. The method is intended for students.
The token grants full sign-in to the user's account and is valid for a long time (see `expireAt`). Do not store it in logs,
transmit it only over HTTPS, and do not publish links containing it. `forceCreate: true` creates a
**new** session on every call without ending the previous ones, so do not use it on every sign-in unless necessary.
```bash cURL theme={null}
curl --location --request POST 'https://api.exode.biz/saas/v2/user/session/auth-token' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"userId": 123,
"forceCreate": false
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const createSessionToken = async () => {
try {
const response = await axios.post('https://api.exode.biz/saas/v2/user/session/auth-token', {
userId: 123,
forceCreate: false
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('Session token created:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
createSessionToken();
```
```php PHP theme={null}
123,
'forceCreate' => false
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 201) {
$result = json_decode($response, true);
echo "Session token created successfully\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/user/session/auth-token'
data = {
'userId': 123,
'forceCreate': False
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.post(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('Session token created successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Данные = Новый Структура;
Данные.Вставить("userId", 123);
Данные.Вставить("forceCreate", Ложь);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/session/auth-token");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("POST", Запрос);
Если Ответ.КодСостояния = 201 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("Auth token received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success - New Session Created theme={null}
{
"success": true,
"code": 201,
"payload": {
"isCreated": true,
"session": {
"id": 3537,
"createdAt": "2025-07-29T10:26:19.037Z",
"updatedAt": "2025-07-29T10:26:19.917Z",
"archivedAt": null,
"userId": 123,
"uuid": "a65aa524-f822-4f89-9608-4fb5d4425877",
"deviceUuid": "d1f2c3b4-a5e6-47f8-9012-3456789abcde",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"alive": true,
"isOnline": false,
"appLocation": null,
"appLocationParams": {},
"language": null,
"timezone": null,
"appVersion": null,
"launcher": "Web",
"lastActivityAt": "2025-07-29T10:26:19.037Z",
"expireAt": "2027-07-29T10:26:18.823Z"
}
}
}
```
```json Success - Existing Session Returned theme={null}
{
"success": true,
"code": 201,
"payload": {
"isCreated": false,
"session": {
"id": 3536,
"createdAt": "2025-07-29T09:15:30.123Z",
"updatedAt": "2025-07-29T10:20:45.789Z",
"archivedAt": null,
"userId": 123,
"uuid": "b45bb635-f933-5a90-8719-5ac6e5536988",
"deviceUuid": "e2a3b4c5-d6f7-48a9-b012-3456789abcde",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"alive": true,
"isOnline": true,
"appLocation": "/education",
"appLocationParams": {},
"language": "Uz",
"timezone": 5,
"appVersion": "1.180.0",
"launcher": "Web",
"lastActivityAt": "2025-07-29T10:20:45.789Z",
"expireAt": "2027-07-29T09:15:29.823Z"
}
}
}
```
```json Error - User Not Found / Other School theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
```json Error - User Is Admin theme={null}
{
"code": 400,
"success": false,
"cause": "Forbidden",
"message": "User is school admin or manager",
"error": "User is school admin or manager"
}
```
```json Error - Invalid User ID theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"userId must be an integer number"
],
"error": "Bad Request"
}
```
## Response parameters
The user session object; the full structure is described in the
[`session`](/en/exode-api/objects/entities/session) reference.
Unique session identifier.
Session UUID.
Token for authenticating the user.
Whether the session is active.
Session expiration time (ISO 8601).
Indicates whether a new session was created. `true`: a session was created; `false`: the existing active session was returned.
## Permission requirements
Creating a user session token requires the **"School User Management"** permission (`SchoolManageUsers`).
The service user must be authenticated with a token and have the appropriate access permissions for the specified school.
## Using the token
The session token has a limited validity period (usually 2 years). After it expires, you need to create a new session for the user.
**Automatic user sign-in:**
To sign the user in to the app automatically, use the `___uat` GET parameter in the URL:
```
https://my-school.com/education?___uat=TOKEN_FROM_SESSION
```
Where `TOKEN_FROM_SESSION` is the value of the `token` field from this API method's response.
The user will be signed in automatically when following such a link.
***
*Updated: 2026-09-25 14:33 UTC*
# User states
Source: https://docs.exode.biz/en/exode-api/school/user/state
REST endpoints for reading and writing user state by key
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
User states are key-value records in the database linked to a specific user.
Reading and writing are available only for a limited list of keys (whitelist). Some keys are
masked on read.
The endpoints below work in the school context and require the school user management permission.
Setting a value via PUT fully replaces the key's previous value.
There are no partial updates (merge).
## Supported keys
* Type: object.
* GET/SET: yes/yes.
* Masking: no.
* Purpose: UTM/referral parameters of the first visit/sign-up.
* UtmSignupParams is stored as an object of arbitrary string key-value pairs (for example, utm\_source, utm\_medium, fbclid).
```json Success (GET UtmSignupParams) theme={null}
{
"success": true,
"code": 200,
"payload": {
"value": {
"fbclid": "EAA12xQ9YzGpKJ7tMoNybfBcGgGSsSixioGWZRcjvdAUCzvIjyGEXStvsIJC1wKj8ZpJ3kTn5Q2sL0mH7u9vW",
"utm_term": "9876543210123456789",
"utm_medium": "paid",
"utm_source": "google",
"utm_content": "creative_42_variant_b",
"utm_campaign": "back_to_school_2025_ru"
}
}
}
```
```json Body (SET UtmSignupParams) theme={null}
{
"value": {
"utm_source": "google",
"utm_medium": "paid",
"utm_campaign": "back_to_school_2025_ru",
"utm_content": "creative_42_variant_b",
"utm_term": "9876543210123456789",
"fbclid": "EAA12xQ9YzGpKJ7tMoNybfBcGgGSsSixioGWZRcjvdAUCzvIjyGEXStvsIJC1wKj8ZpJ3kTn5Q2sL0mH7u9vW"
}
}
```
* Type: boolean.
* GET/SET: yes/yes.
* Masking: no.
* Purpose: flag indicating that personal information has been filled in.
* PersonalInfoFilled is a boolean true / false value indicating whether personal information has been filled in.
```json Success (GET PersonalInfoFilled) theme={null}
{
"success": true,
"code": 200,
"payload": {
"value": true
}
}
```
```json Body (SET PersonalInfoFilled) theme={null}
{
"value": true
}
```
* Type: object.
* GET/SET: yes/yes.
* Masking: no.
* Purpose: the user's onboarding progress.
```json Body (SET OnBoardingProgress) theme={null}
{
"value": { "step": 3, "completed": false }
}
```
* Type: array.
* GET/SET: yes/yes.
* Masking: no.
* Purpose: content categories selected by the user (an array of IDs).
```json Body (SET ContentCategoryIds) theme={null}
{
"value": [12, 45, 78]
}
```
Writing (`SET`) is allowed only for the keys `UtmSignupParams`, `PersonalInfoFilled`, `OnBoardingProgress`,
`ContentCategoryIds`. Reading (`GET`) is additionally available for `VkToken` (returned **masked**).
A request for a key outside this list returns a `400` error with `cause: "Unauthorized"` and the message
`Not allowed key ` — this is a key restriction, not a token problem. A non-existent key name
is rejected by validation (`400`).
## Get a state by key
GET /saas/v2/user/:userId/state/get?key=PersonalInfoFilled
### Parameters
The user's numeric ID in Exode (the `id` field from [`user/find`](/en/exode-api/school/user/find) or
[`user/list`](/en/exode-api/school/user/list)). The user must belong to the school from the `School-Id` header.
State key — one of the values from the [list above](#supported-keys), case-sensitive.
### Response
The state value. If no value has been written for the key yet, `null`.
```bash cURL (GET) theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/123/state/get?key=PersonalInfoFilled' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Authorization: Bearer YOUR_TOKEN'
```
```javascript Node.js (GET) theme={null}
const axios = require('axios');
async function getState() {
const { data } = await axios.get(
'https://api.exode.biz/saas/v2/user/123/state/get',
{
params: { key: 'PersonalInfoFilled' },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data);
}
getState();
```
```php PHP (GET) theme={null}
```
```python Python (GET) theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/user/123/state/get'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.get(url, params={ 'key': 'PersonalInfoFilled' }, headers=headers)
print(response.json())
```
```bsl 1С (GET) theme={null}
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/123/state/get?key=PersonalInfoFilled");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Ответ = Соединение.ВызватьHTTPМетод("GET", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("State value received");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```bash cURL (SET) theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/user/123/state/set?key=PersonalInfoFilled' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"value": true
}'
```
```javascript Node.js (SET) theme={null}
const axios = require('axios');
async function setState() {
const { data } = await axios.put(
'https://api.exode.biz/saas/v2/user/123/state/set',
{ value: true },
{
params: { key: 'PersonalInfoFilled' },
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data);
}
setState();
```
```php PHP (SET) theme={null}
true ]);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo $response;
?>
```
```python Python (SET) theme={null}
import requests
url = 'https://api.exode.biz/saas/v2/user/123/state/set'
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
response = requests.put(url, json={ 'value': { 'step': 3 } }, params={ 'key': 'PersonalInfoFilled' }, headers=headers)
print(response.json())
```
```bsl 1С (SET) theme={null}
Данные = Новый Структура;
Данные.Вставить("value", Истина);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/123/state/set?key=PersonalInfoFilled");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("PUT", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("State value saved");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success (GET) theme={null}
{
"success": true,
"code": 200,
"payload": {
"value": true
}
}
```
```json Error (GET) - Not allowed key theme={null}
{
"code": 400,
"success": false,
"cause": "Unauthorized",
"message": "Not allowed key KeyName",
"error": "Not allowed key KeyName"
}
```
```json Success (SET) theme={null}
{
"success": true,
"code": 200,
"payload": {
"set": true
}
}
```
```json Error (SET) - Not allowed key theme={null}
{
"code": 400,
"success": false,
"cause": "Unauthorized",
"message": "Not allowed key KeyName",
"error": "Not allowed key KeyName"
}
```
## Set a state by key
PUT /saas/v2/user/:userId/state/set?key=PersonalInfoFilled
### Parameters
The user's numeric ID in Exode (the `id` field from [`user/find`](/en/exode-api/school/user/find) or
[`user/list`](/en/exode-api/school/user/list)). The user must belong to the school from the `School-Id` header.
State key — one of the values from the [list above](#supported-keys), case-sensitive.
The value to write to the state. Its type depends on the key.
### Response
Flag indicating that the state was written successfully.
## Permission requirements
Requires token authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
***
*Updated: 2026-09-25 14:33 UTC*
# Update a user
Source: https://docs.exode.biz/en/exode-api/school/user/update
Update an existing school user's data, including profile data
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
PUT /saas/v2/user/:userId/update
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
## Request parameters
##### All fields are optional on update. To clear a field that can be empty, pass `null`
`userId` is the numeric ID of the user in Exode (the `id` field in the [`user/create`](/en/exode-api/school/user/create),
[`user/find`](/en/exode-api/school/user/find) or [`user/list`](/en/exode-api/school/user/list) response). If you only
store your own identifier, use the
[external ID](#update-a-user-by-external-id) `extId` variant.
This method cannot change the password. If there is no user with this `userId` in the school from the `School-Id` header,
the method returns `401` with `cause: "Forbidden"` and the message `seller not entity owner`.
The user's email address. Must be a valid email. An empty string is converted to
`null`.
The user's phone number. Must be in international format (for example, +9876543210). An empty string is converted to `null`.
The user's domain login. Up to 65 characters: Latin letters, digits, `_` and dots. A dot cannot be the first or last character and cannot
be repeated consecutively (automatically converted to lower
case). Must be unique within the school. Used for sign-in alongside `email` and `phone`.
The user's Telegram ID. An integer or `null`. It is not a login: it links the account to Telegram.
The user's external ID from your system. A string of up to 50 characters or `null` (an empty string resets the
value to `null`). It is not a login: it is used only to link the user to your CRM/LMS and to find the user.
Account status: `Active`, `OnLeave`, `Banned`, `Blocked` or `Terminated`.
* `Active`: regular access;
* `OnLeave`: "on leave", an informational status that does **not block** access. It is managed automatically by the
[absences](/en/exode-api/school/staff/absence) module; setting it manually is not recommended;
* `Banned`: banned. Sign-in is denied and active sessions are terminated. To lift the ban, pass `Active`:
the actual status is recalculated automatically from employments and absences (`Terminated` / `OnLeave` /
`Active`), so an employee terminated before the ban does not regain access by mistake;
* `Blocked`: blocked. Sign-in is denied, active sessions are terminated and product enrollments are blocked.
Returning to `Active` restores access;
* `Terminated`: terminated. Access is closed the same way as for `Blocked`. It is managed automatically by the
[employments](/en/exode-api/school/staff/employment) module (termination of the last active employment);
to terminate an employee, use `staff/employment/terminate` instead of setting the status directly.
The `Deleted` status is reserved by the system (account deletion) and cannot be passed.
The boolean `banned` request field has been **removed**: blocking is managed only via `status` (`Banned` /
`Blocked`). The response still includes the `banned` and `active` fields, derived from `status`.
The school owner is protected: another administrator cannot change the owner's data, and moving the owner to a blocking
status (`Banned`/`Blocked`/`Terminated`) is forbidden even for the owner themselves. The method returns the
`ForbiddenModifySchoolOwner` error.
Container for related data, the same as in
[`user/create`](/en/exode-api/school/user/create#additional-aggregates-extra): the `staff.employments` block
(array of 1–10). Corporate schools only; unlike creation, it is **optional**. Employments
are applied idempotently: passing the same active department + position pair again does not create a duplicate.
Via `extra` in `update` you can only **add employments** to an employee. Transfers, position changes and termination
are handled by the endpoints in the [Staff (HR)](/en/exode-api/school/staff/employment) section.
### Profile parameters
Object with the user's profile data to update.
The user's first name. 2 to 15 characters: letters (Latin and Cyrillic, including ў/ғ/қ/ҳ), spaces and apostrophes
(for example, `O'Brien`). Leading and trailing spaces are trimmed automatically.
The user's last name. 2 to 15 characters: letters (Latin and Cyrillic, including ў/ғ/қ/ҳ), spaces and apostrophes
(for example, `O'Brien`). Leading and trailing spaces are trimmed automatically.
Date of birth in YYYY-MM-DD format.
The user's sex. Possible values: `Ufo`, `Women`, `Men`.
The user's role. Possible values: `Student`, `Tutor`, `Parent`.
When updating a user, you can change both the main data and the profile data. If the profile is not specified,
it remains unchanged.
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/user/123/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"email": "updated@example.com",
"phone": "+9876543210",
"extId": "crm_12345",
"tgId": null,
"status": "Active",
"profile": {
"firstName": "Newfirstname",
"lastName": "Newlastname",
"bdate": "1990-01-01",
"sex": "Men",
"role": "Student"
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const updateUser = async (userId) => {
try {
const response = await axios.put(`https://api.exode.biz/saas/v2/user/${userId}/update`, {
email: 'updated@example.com',
phone: '+9876543210',
extId: 'crm_12345',
tgId: null,
status: 'Active',
profile: {
firstName: 'Newfirstname',
lastName: 'Newlastname',
bdate: '1990-01-01',
sex: 'Men',
role: 'Student'
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('User updated:', response.data.payload);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
updateUser(123);
```
```php PHP theme={null}
'updated@example.com',
'phone' => '+9876543210',
'extId' => 'crm_12345',
'tgId' => null,
'status' => 'Active',
'profile' => [
'firstName' => 'Newfirstname',
'lastName' => 'Newlastname',
'bdate' => '1990-01-01',
'sex' => 'Men',
'role' => 'Student'
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$result = json_decode($response, true);
echo "User updated successfully\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
user_id = 123
url = f'https://api.exode.biz/saas/v2/user/{user_id}/update'
data = {
'email': 'updated@example.com',
'phone': '+9876543210',
'extId': 'crm_12345',
'tgId': None,
'status': 'Active',
'profile': {
'firstName': 'Newfirstname',
'lastName': 'Newlastname',
'bdate': '1990-01-01',
'sex': 'Men',
'role': 'Student'
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.put(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('User updated successfully:')
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Профиль = Новый Структура;
Профиль.Вставить("firstName", "Newfirstname");
Профиль.Вставить("lastName", "Newlastname");
Профиль.Вставить("bdate", "1990-01-01");
Профиль.Вставить("sex", "Men");
Профиль.Вставить("role", "Student");
Данные = Новый Структура;
Данные.Вставить("email", "updated@example.com");
Данные.Вставить("phone", "+9876543210");
Данные.Вставить("extId", "crm_12345");
Данные.Вставить("tgId", Неопределено);
Данные.Вставить("status", "Active");
Данные.Вставить("profile", Профиль);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/123/update");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("PUT", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("User updated");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success theme={null}
{
"success": true,
"code": 200,
"payload": {
"user": {
"id": 1683,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.980Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1683",
"email": "updated@example.com",
"phone": "+9876543210",
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"currentTime": "2026-07-02T11:15:46+00:00",
"isSleepingNow": false,
"profile": {
"id": 1665,
"createdAt": "2026-07-02T11:15:46.932Z",
"updatedAt": "2026-07-02T11:15:46.976Z",
"archivedAt": null,
"userId": 1683,
"official": false,
"firstName": "Newfirstname",
"lastName": "Newlastname",
"fullName": "Newfirstname Newlastname",
"fullNameShort": "Newfirstname N.",
"bdate": "1990-01-01",
"sex": "Men",
"country": null,
"city": null,
"role": "Student",
"status": null,
"title": "",
"emojiTitle": "",
"avatar": {
"id": 1665,
"small": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/small/avatar.png",
"medium": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/medium/avatar.png",
"maximum": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/avatar.png"
},
"titleState": {
"manualTitle": null,
"manualEmojiTitle": null,
"manualNextTitle": null,
"manualNextEmojiTitle": null,
"manualExpiredAt": null,
"locationTitle": null,
"locationEmojiTitle": null,
"achievementTitle": null,
"achievementEmojiTitle": null
}
}
}
}
}
```
```json Error - User Not Found / Other School theme={null}
{
"code": 401,
"success": false,
"cause": "Forbidden",
"message": "Forbidden seller resource - seller not entity owner",
"error": "Forbidden seller resource - seller not entity owner"
}
```
```json Error - Email Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "EmailIsBusy",
"message": "Email is busy",
"error": "Email is busy"
}
```
```json Error - Phone Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "PhoneIsBusy",
"message": "Phone is busy",
"error": "Phone is busy"
}
```
```json Error - Telegram ID Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "TgIdIsBusy",
"message": "Tg id is busy",
"error": "Tg id is busy"
}
```
```json Error - Domain Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "DomainIsBusy",
"message": "This domain is already taken",
"error": "This domain is already taken"
}
```
```json Error - Domain Is Invalid theme={null}
{
"code": 400,
"success": false,
"cause": "DomainIsInvalid",
"message": "Domain is invalid",
"error": "Domain is invalid"
}
```
```json Error - Invalid Email theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"email must be an email"
],
"error": "Bad Request"
}
```
```json Error - Invalid Phone theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"phone must be a valid phone number"
],
"error": "Bad Request"
}
```
```json Error - Invalid First Name theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"profile.firstName must be shorter than or equal to 15 characters"
],
"error": "Bad Request"
}
```
```json Error - Forbidden Modify School Owner theme={null}
{
"code": 400,
"success": false,
"cause": "ForbiddenModifySchoolOwner",
"message": "Not allowed to modify school owner",
"error": "Not allowed to modify school owner"
}
```
## Update a user by external ID
```
PUT /saas/v2/user/ext/{extId}/update
```
Requires authentication and the **"School User Management"** permission (`SchoolManageUsers`).
Same as updating by `userId`, but the user is addressed by the external ID `extId`
within the school. This is convenient for integrations (CRM/1C) that do not store internal Exode IDs. The request body, response,
school owner protection and `extra` support are identical to the regular `update`.
### Parameters
The user's external ID within the school. Cannot contain `/` or whitespace characters;
non-ASCII values (Cyrillic, etc.) in the path must be **URL-encoded** (`encodeURIComponent`).
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/user/ext/i.ivanov.01011990/update' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"email": "updated@example.com",
"profile": {
"firstName": "Newfirstname"
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const updateUserByExtId = async (extId) => {
const { data } = await axios.put(
`https://api.exode.biz/saas/v2/user/ext/${encodeURIComponent(extId)}/update`,
{
email: 'updated@example.com',
profile: {
firstName: 'Newfirstname',
},
},
{
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
}
);
console.log(data.payload.user);
};
updateUserByExtId('i.ivanov.01011990');
```
```json Error - User Not Found theme={null}
{
"code": 400,
"success": false,
"cause": "NotFound",
"message": "User not found",
"error": "User not found"
}
```
## Permission requirements
Updating a user requires the **"School User Management"** permission (`SchoolManageUsers`).
The service user must be authenticated with a token and have the appropriate access rights to the specified
school.
When a blocking status (`Banned`/`Blocked`/`Terminated`) is set, all of the user's active sessions
are **terminated automatically**. This is
done for security reasons.
***
*Updated: 2026-09-25 14:33 UTC*
# Upsert a user
Source: https://docs.exode.biz/en/exode-api/school/user/upsert
Create a new user or update an existing one in a school, including profile data
**Upsert** is a "create or update" operation. If a user with the given login (`phone`, `email` or
`domain`, in that order of priority), Telegram ID (`tgId`) or external ID (`extId`) already
exists, their data is updated. If there is no such user, one is created.
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
```
PUT /saas/v2/user/upsert
```
Requires authentication and the [**"School User Management"**](/en/exode-api/permissions) permission (`SchoolManageUsers`).
## Request parameters
##### To identify an existing user, pass at least one of the parameters below; otherwise a new user is created on every request.
If the **user is found** by the fields below, the user is only updated.
**If a user is created**, the login and password are sent to the new user **automatically** (delivery
channels are the same as in [`user/create`](/en/exode-api/school/user/create)). You can turn the delivery off by
passing `skipSendCredentials: true`. You cannot set your own password via upsert: the `password` field is
supported only in `user/create`.
Only `email`, `phone` and `domain` are sign-in fields (logins). `extId` and `tgId` are used to look up
an existing user, but **you cannot sign in with them**.
**How an existing user is looked up.** Only **one** of the logins is used for the lookup: the first non-empty one in
the order `phone` → `email` → `domain`. If no user is found by it, the lookup continues by `tgId`, then by `extId`; the first
match is the one updated. For example, if you pass `phone` and `email`, and the school only has a user with that
`email` (with a different or empty phone), that user is not found by `email`: the upsert tries to create a new
user and returns the `EmailIsBusy` error. The `EmailIsBusy` / `PhoneIsBusy` / `TgIdIsBusy` / `ExtIdIsBusy` errors on
update also mean that the value you passed already belongs to **another** user of the school. For reliable
synchronization, pass `extId`: a permanent identifier from your system.
User's email address. Must be a valid email. An empty string is converted to `null`.
User's phone number. Must be in international format (for example, +9876543210). An empty string
is converted to `null`.
User's domain login. Up to 65 characters: Latin letters, digits, `_` and dots; a dot cannot be first/last and cannot
be repeated consecutively (automatically converted to lower
case). Must be unique within the school. If not passed on creation, it is generated automatically in
the `id12345` format. Used for sign-in along with `email` and `phone`.
User's Telegram ID. An integer or `null`. Not a login.
External user ID from your system (for example, a GUID from a CRM/1C). A string of up to 50 characters
without `/` or whitespace, or `null`. Not a login: used only for linking and lookup.
### Additional parameters
Account status: `Active`, `OnLeave`, `Banned`, `Blocked` or `Terminated`.
`OnLeave` is informational (does not block access; managed by the
[absences](/en/exode-api/school/staff/absence) module); `Banned`, `Blocked` and `Terminated` revoke access: sign-in
is denied, active sessions are terminated, enrollments are blocked. `Terminated` is managed automatically by the
[employments](/en/exode-api/school/staff/employment) module; when a ban is lifted (`Banned` → `Active`), the actual
status is recalculated automatically. The `Deleted` status is reserved by the system. See
[`user/create`](/en/exode-api/school/user/create) and [`user/update`](/en/exode-api/school/user/update) for details.
The boolean `banned` request field has been **removed**: blocking is managed only via `status`. The response still
includes the `banned` and `active` fields, derived from `status`.
The school owner is protected: if the upsert finds the owner, setting a blocking status
(`Banned`/`Blocked`/`Terminated`) is forbidden and returns the `ForbiddenModifySchoolOwner` error.
Do not send the sign-in credentials (SMS, email and Telegram) when the upsert **creates** a user. Defaults to
`false` — the credentials are sent. A password is still generated: you can view it in the user's settings in the
account and deliver it yourself. Ignored when an existing user is updated.
### Profile parameters
Object with the user's profile data.
User's first name. 2 to 15 characters: letters (Latin and Cyrillic, including ў/ғ/қ/ҳ), spaces and apostrophes
(for example, `O'Brien`). Leading and trailing spaces are trimmed automatically.
User's last name. 2 to 15 characters: letters (Latin and Cyrillic, including ў/ғ/қ/ҳ), spaces and apostrophes
(for example, `O'Brien`). Leading and trailing spaces are trimmed automatically.
Date of birth in YYYY-MM-DD format.
User's sex. Possible values: `Ufo`, `Women`, `Men`.
User's role. Possible values: `Student`, `Tutor`, `Parent`.
When a user is created, a linked profile with the given data is created automatically. If no profile is specified,
an empty profile is created, and the user fills in `firstName` and `lastName` on first sign-in.
### Additional aggregates (`extra`)
Container for related data applied together with the upsert. Currently it contains the `staff.employments` block:
an array of employments (1 to 10) with department, position, employment kind and type. Available **only for
corporate schools**. If the upsert creates a user, the block is **required** (an employee cannot
exist without an employment). For an existing user, employments are applied idempotently:
passing the same active department + position pair again does not create a duplicate.
The fields of an `employments` item are the same as in
[`user/create`](/en/exode-api/school/user/create#additional-aggregates-extra):
`extId`, `positionId`/`positionExtId`, `departmentId`/`departmentExtId`, `kind`, `type`, `startAt`, `rate`.
Data that is not part of this schema (city, a custom status from your CRM, etc.) is passed via
[custom fields](/en/exode-api/school/custom-field/set).
```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/user/upsert' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--data-raw '{
"email": "user@example.com",
"phone": "+9876543210",
"extId": "crm_12345",
"tgId": null,
"status": "Active",
"profile": {
"firstName": "Firstname",
"lastName": "Lastname",
"bdate": "1990-01-01",
"sex": "Men",
"role": "Student"
}
}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const upsertUser = async () => {
try {
const response = await axios.put('https://api.exode.biz/saas/v2/user/upsert', {
email: 'user@example.com',
phone: '+9876543210',
extId: 'crm_12345',
tgId: null,
status: 'Active',
profile: {
firstName: 'Firstname',
lastName: 'Lastname',
bdate: '1990-01-01',
sex: 'Men',
role: 'Student'
}
}, {
headers: {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
});
console.log('User upserted:', response.data.payload.user);
console.log('Is created:', response.data.payload.isCreated);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
};
upsertUser();
```
```php PHP theme={null}
'user@example.com',
'phone' => '+9876543210',
'extId' => 'crm_12345',
'tgId' => null,
'status' => 'Active',
'profile' => [
'firstName' => 'Firstname',
'lastName' => 'Lastname',
'bdate' => '1990-01-01',
'sex' => 'Men',
'role' => 'Student'
]
];
$headers = [
'Seller-Id: {{ sellerId }}',
'School-Id: {{ schoolId }}',
'Content-Type: application/json',
'Authorization: Bearer YOUR_TOKEN'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$result = json_decode($response, true);
echo "User upserted successfully\n";
echo "Is created: " . ($result['payload']['isCreated'] ? 'true' : 'false') . "\n";
print_r($result['payload']);
} else {
echo "Error: HTTP $httpCode\n";
echo $response;
}
?>
```
```python Python theme={null}
import requests
import json
url = 'https://api.exode.biz/saas/v2/user/upsert'
data = {
'email': 'user@example.com',
'phone': '+9876543210',
'extId': 'crm_12345',
'tgId': None,
'status': 'Active',
'profile': {
'firstName': 'Firstname',
'lastName': 'Lastname',
'bdate': '1990-01-01',
'sex': 'Men',
'role': 'Student'
}
}
headers = {
'Seller-Id': '{{ sellerId }}',
'School-Id': '{{ schoolId }}',
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN'
}
try:
response = requests.put(url, json=data, headers=headers)
response.raise_for_status()
result = response.json()
print('User upserted successfully:')
print(f"Is created: {result['payload']['isCreated']}")
print(json.dumps(result['payload'], indent=2, ensure_ascii=False))
except requests.exceptions.RequestException as e:
print(f'Error: {e}')
if hasattr(e, 'response') and e.response is not None:
print(f'Response: {e.response.text}')
```
```bsl 1С theme={null}
Профиль = Новый Структура;
Профиль.Вставить("firstName", "Firstname");
Профиль.Вставить("lastName", "Lastname");
Профиль.Вставить("bdate", "1990-01-01");
Профиль.Вставить("sex", "Men");
Профиль.Вставить("role", "Student");
Данные = Новый Структура;
Данные.Вставить("email", "user@example.com");
Данные.Вставить("phone", "+9876543210");
Данные.Вставить("extId", "crm_12345");
Данные.Вставить("tgId", Неопределено);
Данные.Вставить("status", "Active");
Данные.Вставить("profile", Профиль);
// Serialize body to JSON
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, Данные);
ТелоЗапроса = ЗаписьJSON.Закрыть();
Соединение = Новый HTTPСоединение("api.exode.biz", 443, , , , 30, Новый OpenSSLSecureConnection);
Запрос = Новый HTTPЗапрос("/saas/v2/user/upsert");
Запрос.Заголовки.Вставить("Seller-Id", "{{ sellerId }}");
Запрос.Заголовки.Вставить("School-Id", "{{ schoolId }}");
Запрос.Заголовки.Вставить("Content-Type", "application/json");
Запрос.Заголовки.Вставить("Authorization", "Bearer YOUR_TOKEN");
Запрос.УстановитьТелоИзСтроки(ТелоЗапроса);
Ответ = Соединение.ВызватьHTTPМетод("PUT", Запрос);
Если Ответ.КодСостояния = 200 Тогда
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(Ответ.ПолучитьТелоКакСтроку());
Результат = ПрочитатьJSON(ЧтениеJSON);
Сообщить("User created or updated");
Иначе
Сообщить("Error: HTTP " + Ответ.КодСостояния);
Сообщить(Ответ.ПолучитьТелоКакСтроку());
КонецЕсли;
```
```json Success - User Created theme={null}
{
"success": true,
"code": 200,
"payload": {
"isCreated": true,
"user": {
"id": 1684,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1684",
"email": "user@example.com",
"phone": "+9876543210",
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"currentTime": "2026-07-02T11:15:46+00:00",
"isSleepingNow": false,
"profile": {
"id": 1666,
"createdAt": "2026-07-02T11:15:46.932Z",
"updatedAt": "2026-07-02T11:15:46.932Z",
"archivedAt": null,
"userId": 1684,
"official": false,
"firstName": "Firstname",
"lastName": "Lastname",
"fullName": "Firstname Lastname",
"fullNameShort": "Firstname L.",
"bdate": null,
"sex": "Ufo",
"country": null,
"city": null,
"role": "Student",
"status": null,
"title": "",
"emojiTitle": "",
"avatar": {
"id": 1666,
"small": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/small/avatar.png",
"medium": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/medium/avatar.png",
"maximum": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/avatar.png"
},
"titleState": {
"manualTitle": null,
"manualEmojiTitle": null,
"manualNextTitle": null,
"manualNextEmojiTitle": null,
"manualExpiredAt": null,
"locationTitle": null,
"locationEmojiTitle": null,
"achievementTitle": null,
"achievementEmojiTitle": null
}
}
}
}
}
```
```json Success - User Updated theme={null}
{
"success": true,
"code": 200,
"payload": {
"isCreated": false,
"user": {
"id": 1684,
"createdAt": "2026-07-02T11:15:46.896Z",
"updatedAt": "2026-07-02T11:15:46.940Z",
"archivedAt": null,
"uuid": "e-cjTT0CWMCB",
"active": true,
"activated": true,
"banned": false,
"status": "Active",
"alive": true,
"domain": "id1684",
"email": "user@example.com",
"phone": "+9876543210",
"tgId": null,
"vkId": null,
"appleId": null,
"extId": "crm_12345",
"schoolId": 198,
"language": null,
"timezone": null,
"lastOnlineAt": null,
"starsBalance": 0,
"currentTime": "2026-07-02T11:15:46+00:00",
"isSleepingNow": false,
"profile": {
"id": 1666,
"createdAt": "2026-07-02T11:15:46.932Z",
"updatedAt": "2026-07-02T11:15:46.932Z",
"archivedAt": null,
"userId": 1684,
"official": false,
"firstName": "Newfirstname",
"lastName": "Newlastname",
"fullName": "Newfirstname Newlastname",
"fullNameShort": "Newfirstname N.",
"bdate": null,
"sex": "Ufo",
"country": null,
"city": null,
"role": "Student",
"status": null,
"title": "",
"emojiTitle": "",
"avatar": {
"id": 1666,
"small": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/small/avatar.png",
"medium": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/medium/avatar.png",
"maximum": "https://storage.exode.biz/production/user/1683/xK2mVwNib9b0/avatar.png"
},
"titleState": {
"manualTitle": null,
"manualEmojiTitle": null,
"manualNextTitle": null,
"manualNextEmojiTitle": null,
"manualExpiredAt": null,
"locationTitle": null,
"locationEmojiTitle": null,
"achievementTitle": null,
"achievementEmojiTitle": null
}
}
}
}
}
```
```json Error - Email Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "EmailIsBusy",
"message": "Email is busy",
"error": "Email is busy"
}
```
```json Error - Phone Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "PhoneIsBusy",
"message": "Phone is busy",
"error": "Phone is busy"
}
```
```json Error - Telegram ID Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "TgIdIsBusy",
"message": "Tg id is busy",
"error": "Tg id is busy"
}
```
```json Error - Domain Is Busy theme={null}
{
"code": 400,
"success": false,
"cause": "DomainIsBusy",
"message": "This domain is already taken",
"error": "This domain is already taken"
}
```
```json Error - Domain Is Invalid theme={null}
{
"code": 400,
"success": false,
"cause": "DomainIsInvalid",
"message": "Domain is invalid",
"error": "Domain is invalid"
}
```
```json Error - Forbidden Modify School Owner theme={null}
{
"code": 400,
"success": false,
"cause": "ForbiddenModifySchoolOwner",
"message": "Not allowed to modify school owner",
"error": "Not allowed to modify school owner"
}
```
```json Error - Staff Is Required (Corporate) theme={null}
{
"code": 400,
"success": false,
"cause": "StaffEmploymentInputRequired",
"message": "Staff employment input is required for Corporate school user",
"error": "Staff employment input is required for Corporate school user"
}
```
```json Error - Staff Only For Corporate School theme={null}
{
"code": 400,
"success": false,
"cause": "StaffOnlyForCorporateSchool",
"message": "Staff is available only for Corporate school",
"error": "Staff is available only for Corporate school"
}
```
```json Error - Invalid Email theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"email must be an email"
],
"error": "Bad Request"
}
```
```json Error - Invalid Phone theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"phone must be a valid phone number"
],
"error": "Bad Request"
}
```
```json Error - Invalid First Name theme={null}
{
"code": 400,
"success": false,
"cause": "validation",
"message": [
"profile.firstName must be shorter than or equal to 15 characters"
],
"error": "Bad Request"
}
```
## Permission requirements
Creating or updating a user requires the **"School User Management"** permission
(`SchoolManageUsers`).
The service user must be authenticated with a token and have the appropriate access rights to the specified
school.
When a blocking status (`Banned`/`Blocked`/`Terminated`) is set, all of the user's active sessions are
**terminated automatically**. This is
done for security reasons.
***
*Updated: 2026-10-05 12:43 UTC*
# Working with the API
Source: https://docs.exode.biz/en/exode-api/setup
Base URL, authentication, response and error format, rate limits and pagination
The Exode SaaS API is a REST API over HTTPS. All endpoints are under the `saas/v2/` prefix.
## Base URL
```text theme={null}
https://api.exode.biz
```
The full method address is `https://api.exode.biz/saas/v2//` — for example, `https://api.exode.biz/saas/v2/user/create`.
## Authentication
Requests are made on behalf of a **service user** (API client) — a school employee with a configured
set of permissions and their own API token.
* Authenticate with the `Authorization: Bearer ` header.
* The token does not expire, and you can revoke it at any time.
* The token must belong to a user with the **API client** flag (a service user created on the
API keys page gets it) — otherwise SaaS endpoints return `403` `Forbidden`, even if all other
permissions are in place.
### How to get the token and IDs
The school owner opens **Manage → School → For developers → API keys** (`/manage/school/api-keys`) and creates a key —
this also creates a service user and issues a token. A school can have at most **5** keys.
The same page lists the keys with a token preview and issue date, and lets you rotate (reissue) the token.
Copy the token right away — it is shown in full only once (see below). This is the value of the
`Authorization: Bearer ` header.
On the same page, the **"Integration data → Identifiers"** card shows both IDs as
`Seller-Id: 123; School-Id: 456` — click the line to copy it. These are numbers; they do not change when the token is rotated.
If you prefer, contact [support](https://t.me/exode_support_biz) and we will help you set things up.
The token is shown in full **only at creation or rotation** — save it right away. The key list
shows only a preview (the first and last 4 characters) and the issue date. Rotation issues a new token and
immediately revokes the old one.
A new key immediately receives all the permissions SaaS API methods need, so your first request works without setup.
The set depends on the school segment (`school.segment`: `Commerce` — a commercial online school, `Corporate` —
corporate employee training): commercial schools additionally get "School Sales" and "School Refunds",
corporate schools get "Staff Management" and "Staff browsing" (employee sync from HR/1C works out of
the box). You can change the set on the key's page; for details, see
[API key permissions](/en/exode-api/permissions#default-permissions).
Never store the token in code or a repository. Use environment variables. The token grants access to
school data — do not share it with third parties.
## Required headers
## Request headers
The service user's API token in the `Bearer YOUR_TOKEN` format. The school owner issues the token in the admin panel:
**Manage → School → For developers → API keys** — see the ["Authentication"](/en/exode-api/setup#authentication) section for details.
The numeric ID of the seller — the account the school belongs to. Copy it on the **API keys** page, in
the **Integration data → Identifiers** card. The token's permissions are checked against this ID.
The numeric ID of the school, found in the same place as `Seller-Id`. The value must match the seller's school — otherwise
a `400` error with `cause: "ForbiddenSchoolMismatch"` is returned.
A missing or invalid `Authorization` header results in `401` `Unauthorized`. Without `Seller-Id` (or with the ID
of a seller the token does not belong to), methods return `401` `Forbidden`. The API determines the school from the seller
and checks `School-Id` against it: if you pass the ID of a different school, you get `400` `ForbiddenSchoolMismatch`.
In the examples on method pages, header values are written as `{{ sellerId }}` and `{{ schoolId }}` —
these are variables of the [Postman collection](https://www.postman.com/exode-team/exode-biz/collection/18268987-c847eaab-7143-41fd-86d0-80f1f222a784).
When running cURL from a terminal, replace them with the numeric IDs, and replace `YOUR_TOKEN` with your token.
## Response format
Every successful response is wrapped in a single structure:
`true` for successful responses (HTTP codes `200`–`206`).
HTTP response code (`200`, `201`, etc.).
The method's payload. Its structure is described on the page of the specific method and strictly matches the
response schema (extra fields are not returned).
```json Successful response theme={null}
{
"success": true,
"code": 200,
"payload": { /* method data */ }
}
```
## Error format
Errors are returned in the same envelope plus the `cause`, `message`, `error` fields and an optional `data` field:
Always `false` for errors.
HTTP error code: `400`, `401`, `403`, `404`, `429`, `500`.
Machine-readable cause code. Use it to build your error handling. Examples: `validation`, `Unauthorized`,
`Forbidden`, `EmailIsBusy`, `Rate`.
Human-readable error message. With `cause: "validation"`, it is an array of strings, one for each
violated rule (for example, `["extId must be shorter than or equal to 50 characters"]`).
Technical description (matches `message` by default).
Additional data (for example, `retryAfter` for `429`). Optional.
```json Error example theme={null}
{
"success": false,
"code": 400,
"cause": "EmailIsBusy",
"message": "Email is busy",
"error": "Email is busy"
}
```
### Common cause codes (`cause`)
| HTTP | `cause` | When it occurs |
| - | - | - |
| 400 | `validation` | The body/parameters failed validation (type, length, format, enum). What exactly is wrong is in `message` |
| 400 | `ForbiddenSchoolMismatch` | `School-Id` does not match the school of the seller from `Seller-Id` |
| 401 | `Unauthorized` | The token is missing/invalid (`Invalid token credentials`) |
| 401 | `Blocked` | The token's user is inactive or banned (`User is not active or banned`) |
| 401 | `Forbidden` | No access to the seller's data. The `message` shows the reason: `Forbidden seller resource - permissions ` — the key does not have the required permission enabled (see [API key permissions](/en/exode-api/permissions#permission-reference)) or the `Seller-Id` is missing/belongs to someone else; `seller not entity owner` — the object (user, group, course…) belongs to another school; `Allowed only for school` / `Allowed only for Corporate school` — the method is available only to schools or only to corporate schools |
| 403 | `Forbidden` | The token belongs to a user without the API client flag (`Access to the resource is restricted`) — use a token from the API keys page |
| 402 | `SaasLimitReached` | The school plan limit is reached — for example, the number of active products on [`course/create`](/en/exode-api/school/course/create). `data` holds `{ feature, current, max }` |
| 429 | `Rate` | Rate limit exceeded (see below) |
The specific `cause` codes for each method are listed on its page (in the "Error" responses). For example,
creating a user can return `UserAlreadyExist`, `EmailIsBusy`, `PhoneIsBusy`, `TgIdIsBusy`.
## Access permissions (RBAC)
Each method checks the API key's permissions — the checkboxes on the key's page in the account. If a method lists several
permissions, **any one** of them is enough. Without the required permission, the method returns `401` with the message
`Forbidden seller resource - permissions `.
Which checkboxes to enable for your integration task, which methods each permission unlocks and which permissions a
new key receives.
## Rate limits
Some methods are rate-limited. When the limit is exceeded, the API returns **HTTP `429`** with `cause: "Rate"`:
```json Rate limit exceeded theme={null}
{
"success": false,
"code": 429,
"cause": "Rate",
"message": "Rate limit exceeded",
"error": "Rate limit exceeded",
"data": { "retryAfter": "2025-01-18T12:45:10.000Z" }
}
```
* The limit is counted per service user (token).
* The `data.retryAfter` field tells you when you can retry the request.
* Limits are listed on method pages. For example, generating exports is limited to **100 requests per hour**.
Implement retries that respect `retryAfter`, with exponential backoff for transient errors (`429`, `5xx`).
## Pagination
List methods (`.../list/raw`, course progress, etc.) accept pagination parameters in the query:
Page size. From `1` to `1000`. Defaults to `100`.
Page number (starting from `1`). An alternative to `skip`: used only if `skip` is not passed.
Offset (number of records to skip). If both `skip` and `page` are passed, `skip` takes effect.
A list method's response is a single envelope with a page:
```json Page structure theme={null}
{
"success": true,
"code": 200,
"payload": {
"items": [ /* items */ ],
"page": 1,
"count": 245,
"pages": 3,
"isFirst": true,
"isLast": false,
"next": { "skip": 100, "take": 100, "page": 2 },
"prev": { "skip": 0, "take": 100, "page": 1 }
}
}
```
Pass array parameters by repeating the key: `userIds=1&userIds=2&userIds=3`. Pass ranges
as nested fields, for example `createdAtDateRange[from]` and `createdAtDateRange[to]`.
## Request example
```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/user/find?extId=crm_12345' \
--header 'Authorization: Bearer YOUR_TOKEN' \
--header 'Seller-Id: {{ sellerId }}' \
--header 'School-Id: {{ schoolId }}'
```
```javascript Node.js theme={null}
const axios = require('axios');
const client = axios.create({
baseURL: 'https://api.exode.biz/saas/v2',
headers: {
Authorization: `Bearer ${process.env.EXODE_TOKEN}`,
'Seller-Id': process.env.SELLER_ID,
'School-Id': process.env.SCHOOL_ID,
},
});
const { data } = await client.get('/user/find', { params: { extId: 'crm_12345' } });
console.log(data.payload.user);
```
```php PHP theme={null}
Always check `success`/`code` and handle the error `cause` — this speeds up integration troubleshooting.
***
*Updated: 2026-09-28 05:04 UTC*
# Webhooks
Source: https://docs.exode.biz/en/exode-api/webhooks/about
Receive Exode user, course, access and payment events in your own services in real time
Exode webhooks are **outgoing** HTTP notifications: when an event occurs on the platform (sign-up, payment, course progress, etc.), Exode sends a `POST` request with the event data to your URL.
Delivery is near real-time through a queue with retries. Your receiver must be
**idempotent** and respond quickly with `200`, `201` or `202` (response timeout: **15 seconds**).
## How it works
The Exode core generates a domain event (for example, `PaymentCompleted`) with a timestamp and the initiator parameters.
Exode finds your seller's active endpoints subscribed to this event.
You can create **no more than 5 endpoints** per seller.
The related entities (user, payment, access, etc.) are collected for the event, and `data`,
a single `idempotencyKey` and `timestamp` are built. Extra fields are stripped according to a strict schema (allow-list).
The body is sent as a `POST` request with a `signature` header (HMAC-SHA256). If the receiver does not respond with
`200/201/202`, delivery is retried with an increasing delay, up to **5 attempts** in total.
To stop delivery, set the endpoint to `active = false` or delete it.
## Message format
The request body is a JSON object with the following structure:
Event name. One of the values listed in [Supported events](#supported-events).
The moment the event occurred, in ISO 8601 format (UTC).
A single event key. The same key is used for all endpoints and **is preserved across
retries**. Use it for deduplication.
Payload with domain entities. Its contents depend on the event; see below.
```json Body structure theme={null}
{
"event": "PaymentCompleted",
"timestamp": "2025-01-18T12:44:55.812Z",
"idempotencyKey": "7qqQL3bE0o8R8d3X",
"data": { /* depends on the event */ }
}
```
The signature header is literally named `signature` (lowercase), not `X-Signature`.
The signature is computed over the **entire request body**, including `event`, `timestamp` and `idempotencyKey`.
## Signature verification
Every request carries a `signature` header: an `HMAC-SHA256` (hex) of the **raw request body**,
signed with your endpoint's secret key.
`secretKey` is passed to HMAC **as is**, as a 64-character ASCII/UTF-8 string. Despite its
appearance, you do not need to decode it from hex or base64. The resulting signature is a 64-character
lowercase hex string with no `sha256=` prefix.
```javascript Node.js (Express) theme={null}
import crypto from 'crypto';
import express from 'express';
const app = express();
// Important: use the raw body, not a re-serialized object
app.post('/webhooks/exode', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const received = req.header('signature') || '';
const expected = crypto
.createHmac('sha256', process.env.EXODE_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) {
return res.status(401).send('invalid signature');
}
const payload = JSON.parse(rawBody);
// TODO: deduplicate by payload.idempotencyKey and handle payload.data
res.sendStatus(200);
});
```
```php PHP theme={null}
Compute the HMAC over the **raw request body**. Re-serializing the JSON (with a different field order,
whitespace or Unicode escaping) produces a different signature.
## Delivery and retries
| Parameter | Value |
| - | - |
| HTTP method | `POST` |
| Content-Type | `application/json` |
| Signature header | `signature` (HMAC-SHA256, hex) |
| Response timeout | 15 seconds |
| Success codes | `200`, `201`, `202` |
| Number of attempts | up to 5 (initial delivery + up to 4 retries) |
| Delays between attempts | ≈ 11 → 33 → 77 → 165 minutes (the last attempt comes roughly 4 h 45 min after the first) |
| Delivery order | not guaranteed |
| Deduplication | by `idempotencyKey` on your side |
Any response other than `200/201/202`, as well as a timeout or network error, counts as a failure and triggers
a retry. After 5 failed attempts the event is marked as failed and is no longer sent.
**Endpoint auto-disable.** If an endpoint does not accept events for **14 consecutive days** (no
successful delivery during that time and it was not re-enabled manually), Exode sets it to `active = false` and notifies
the seller's owner. No new events are sent to such an endpoint until it is re-enabled in the account.
## Managing endpoints
Webhook endpoints are configured in the account: **Manage → School → Webhooks** (`/manage/school/webhooks`).
You need the **School Settings Management** permission (`SchoolManageSettings`). Endpoints cannot be managed through the SaaS API.
For each endpoint you set:
* `url` — the receiver address (up to 255 characters); use HTTPS;
* `events` — the list of events it is subscribed to;
* `active` — the activity flag (`true` by default);
* `note` — a free-form note (up to 255 characters);
* `secretKey` — the secret for signature verification, **generated automatically** when the endpoint is created.
**Where to get the secret for signature verification:** `secretKey` is created together with the endpoint. In the account
you copy it with the **"Copy signature"** command, available in the endpoint row menu or next to the URL field in its form
(despite the name, it copies the secret itself, not a ready-made signature). Store it in your service's environment
variables (`EXODE_WEBHOOK_SECRET` in the examples above). If the secret is not displayed, request it from
[support](https://t.me/exode_support_biz).
Use separate endpoints for independent systems: this isolates failures and lets you manage
different sets of events. Each seller can have at most 5 endpoints.
### Test delivery
A test delivery from the form of a **saved** endpoint is signed with that endpoint's own `secretKey`. The body format,
algorithm and header are exactly the same as for production events, so your receiver does not need a separate
verification branch for test webhooks.
A new endpoint that has not been saved yet does not have a permanent `secretKey`. Its test delivery
is signed with a temporary key, so you cannot verify that signature with the key that appears after creation.
To test the full verification cycle, save the endpoint first, then send a test from its
form.
## Supported events
Below are the events that are actually delivered, along with the structure of their `data` field. Nested entities
(`user`, `profile`, `course`, `payment`, etc.) are described in the [object reference](/en/exode-api/objects/entities/index).
Trigger: the user completed sign-up on their own.
`data`:
* `user` — the [user](/en/exode-api/objects/entities/user) object.
* `profile` — the profile card (`null` if not filled in).
* `states.utmSignupParams` — an object with the UTM tags of the first visit (optional).
Trigger: the user completed onboarding. The `data` structure is identical to `UserSignedUp`
(`user`, `profile`, `states.utmSignupParams`).
Trigger: the user linked a Telegram account.
`data`:
* `user` — the user object (with the current `tgId`).
* `profile` — the profile card (optional).
* `prevTgId` — the previous `tgId`, for tracking re-linking (`number | null`).
Trigger: a school employee created a new user — in the admin panel (one by one or via bulk import) or through
the API [`user/create`](/en/exode-api/school/user/create) and [`user/upsert`](/en/exode-api/school/user/upsert)
(only when `upsert` creates the user rather than updating an existing one). A user who signs up on their own
arrives as a separate `UserSignedUp` event.
`data`:
* `user` — the [user](/en/exode-api/objects/entities/user) object.
* `profile` — the profile card (optional).
Trigger: the status of a lesson changed for the user.
`data`:
* `user` — the [user](/en/exode-api/objects/entities/user) with profile.
* `course` — the [course](/en/exode-api/objects/entities/course) object.
* `product` — the course [product](/en/exode-api/objects/entities/product) (optional).
* `access` — the user's [access](/en/exode-api/objects/entities/product#productaccess) to the course product (optional).
* `states.utmEnrollParams`, `states.utmSignupParams` — enrollment and sign-up UTM tags; see [UTM attribution](#utm-attribution).
* `groups` — an array of the user's [groups](/en/exode-api/objects/entities/group) in the course (optional).
* `status` — the new lesson status from the progress record (optional): `NotStarted`, `OnTheory`, `OnPractice`,
`OnReview`, `OnCorrection` or `Completed`; see [`courseProgress`](/en/exode-api/objects/entities/course#courseprogress) for details.
* `lessonId` — the lesson ID (optional).
Trigger: the user completed the course.
`data`:
* `user` — the user with profile.
* `course` — the course object.
* `product` — the course product (optional).
* `access` — the user's [access](/en/exode-api/objects/entities/product#productaccess) (optional).
* `states.utmEnrollParams`, `states.utmSignupParams` — enrollment and sign-up UTM tags; see [UTM attribution](#utm-attribution).
* `groups` — an array of groups (optional).
Trigger: the user completed a lesson's practical assignment.
`data`:
* `user` — the user with profile.
* `course` — the course (optional).
* `lesson` — the lesson (optional).
* `practice` — the practice parameters (optional).
* `attempt` — the attempt, with score and status (optional).
* `variantId` — the variant ID (optional).
Trigger: a payment completed successfully.
`data.payment` — the [payment](/en/exode-api/objects/entities/payment) object with the full tree: `invoice`
(the invoice), `invoice.user` (the buyer with profile and school), `invoice.products` (line items with product/course,
price and discount), `acquiring` (the acquiring and its provider, without secrets). Monetary fields are numbers.
The event is sent only when **money is actually charged**: initializing a recurring payment (card binding,
a zero payment with the `BindingCompleted` status) does not trigger the webhook.
Trigger: the user was granted free access to a product.
`data`:
* `user` — the [user](/en/exode-api/objects/entities/user).
* `profile` — the profile card (optional).
* `access` — the [access](/en/exode-api/objects/entities/product#productaccess) object (optional).
* `states.utmEnrollParams`, `states.utmSignupParams` — enrollment and sign-up UTM tags; see [UTM attribution](#utm-attribution).
* `product` — the [product](/en/exode-api/objects/entities/product) (optional).
* `course` — the [course](/en/exode-api/objects/entities/course) (optional).
Trigger: product access was granted by an administrator/manager through the LMS or the API.
The `data` structure is identical to `ProductEnrolledToFree` (`user`, `profile`, `access`, `product`, `course`, `states`).
Trigger: product access was granted after a successful payment.
The `data` structure is identical to `ProductEnrolledToFree` (`user`, `profile`, `access`, `product`, `course`, `states`).
Trigger: the user enrolled in a course on their own via an invite link.
`data`: the same fields as `ProductEnrolledToFree` (`user`, `profile`, `access`, `product`, `course`, `states`),
plus:
* `inviteLinkId` — the ID of the link the user came through (duplicated in `access.meta.inviteLinkId`).
Enrollment via a link sends **two** events: `ProductEnrolledToFree` and
`ProductEnrolledByInviteLink`. The link grants access for free, so the general free-enrollment
flow fires as well; keep this in mind when counting enrollments. You can tell an invite-link enrollment
apart by the presence of `inviteLinkId`.
Trigger: the user was issued a certificate for completing a course. The event is sent once per
"user + course" pair: completing the course again does not create a new certificate.
`data`:
* `user` — the [user](/en/exode-api/objects/entities/user) with profile.
* `course` — the [course](/en/exode-api/objects/entities/course) object.
* `product` — the course [product](/en/exode-api/objects/entities/product) (optional).
* `certificate` — the [certificate](/en/exode-api/objects/entities/certificate) object with a public
`link` (opens without authorization), a validity period and a snapshot of the data at the time of issue.
You can subscribe to `UserSignedIn`, `UserLoggedOut`, `UserJoinedByReferral`,
`CourseLessonPracticeDetailedSent`, `CourseLessonPracticeAutoVerifySent`, `ProductRefundCompleted`,
`ProductAccessSubscriptionEnding7Days` and `ProductAccessSubscriptionEnding1Day`, but
the formal `data` contract for them is not fixed yet, so the payload contents are not guaranteed.
Check with [support](https://t.me/exode_support_biz) before using them.
### UTM attribution
UTM tags arrive in three places, depending on the event:
| Event | Where to find UTM |
| - | - |
| `UserSignedUp`, `UserAcquainted` | `states.utmSignupParams` — tags of the visit from which the user signed up |
| `ProductEnrolledToFree`, `ProductEnrolledViaLms`, `ProductEnrolledViaPayment`, `ProductEnrolledByInviteLink`, `CourseProgressChanged`, `CourseCompleted` | `states.utmEnrollParams` — tags of the visit from which the user enrolled in the product (duplicated in `access.meta.utmParams`); next to it, `states.utmSignupParams` — the sign-up tags |
| `PaymentCompleted` | `payment.invoice.meta.utmParams` — tags of the visit from which the invoice was created |
UTM object keys: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `gclid`, `fbclid`, `yclid`,
`referrer`, `aff_id`, `sub_id`, `track_id` (only the ones that were passed are present). If the user arrived without tags, the object
is absent or empty.
The `access.metaHistoryLogs[0].meta.utmParams` path used by early integrations is no longer returned in the
contract. Switch your scenarios to `states.utmEnrollParams` or `access.meta.utmParams`.
### Example: PaymentCompleted
The body below is a real test-delivery example (synthetic values, exact field set):
```json theme={null}
{
"event": "PaymentCompleted",
"timestamp": "2026-07-02T11:16:25.172Z",
"idempotencyKey": "g9lFWzmF-4BkJaxvFvIEX",
"data": {
"payment": {
"id": 13001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"uuid": "samplePaymentUuid",
"type": "OneTime",
"status": "Completed",
"checkoutPaymentId": "sampleCheckoutPaymentId",
"checkoutUrl": null,
"released": true,
"paidAt": "2026-01-01T00:00:00.000Z",
"expireAt": null,
"isCompleted": true,
"isCanceled": false,
"meta": {},
"webhookLogs": [],
"chargeLogs": [],
"statusHistoryLogs": [],
"invoice": {
"id": 12001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"uuid": "sampleInvoiceUuid",
"humanId": 1001,
"type": "Regular",
"status": "Active",
"totalAmount": 100,
"discountAmount": 0,
"currency": "Usd",
"expireAt": null,
"isActive": true,
"user": {
"id": 1001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"uuid": "sampleUserUuid",
"status": "Active",
"active": true,
"activated": true,
"banned": false,
"alive": true,
"domain": "id1001",
"email": "user@example.com",
"phone": null,
"tgId": null,
"vkId": null,
"appleId": null,
"extId": null,
"schoolId": 1,
"language": "En",
"timezone": 0,
"lastOnlineAt": "2026-01-01T00:00:00.000Z",
"starsBalance": 0,
"currentTime": "2026-01-01T00:00:00.000Z",
"isSleepingNow": false,
"profile": {
"id": 1001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"userId": 1001,
"official": false,
"firstName": "John",
"lastName": "Doe",
"fullName": "John Doe",
"fullNameShort": "John D.",
"bdate": null,
"sex": "Ufo",
"country": null,
"city": null,
"role": "Student",
"status": null,
"title": null,
"emojiTitle": null,
"avatar": {
"id": 1001,
"small": "https://storage.example.exode.biz/production/user/1001/sampleAvatar/small/avatar.png",
"medium": "https://storage.example.exode.biz/production/user/1001/sampleAvatar/medium/avatar.png",
"maximum": "https://storage.example.exode.biz/production/user/1001/sampleAvatar/avatar.png"
},
"titleState": {
"manualTitle": null,
"manualEmojiTitle": null,
"manualNextTitle": null,
"manualNextEmojiTitle": null,
"manualExpiredAt": null,
"locationTitle": null,
"locationEmojiTitle": null,
"achievementTitle": null,
"achievementEmojiTitle": null
}
},
"school": {
"id": 1,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"segment": "Commerce",
"baseDomain": "school",
"customDomain": null,
"accessType": "Public",
"domainType": "Base",
"name": "Sample School",
"description": null,
"iconUrl": null,
"active": true,
"domain": "school",
"fqdn": "example.exode.biz",
"publicUrl": "https://example.exode.biz",
"baseFqdn": "example.exode.biz",
"isPublic": true,
"isPrivate": false
}
},
"products": [
{
"id": 11001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"originalPrice": 100,
"totalPrice": 100,
"discountAmount": 0,
"discount": null,
"price": {
"id": 10001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"mode": "SelfDefinition",
"type": "OneTime",
"title": "Sample Price",
"description": null,
"amount": 100,
"previousAmount": null,
"accessDays": null,
"infinityAccess": true,
"active": true,
"hidden": false,
"activeFrom": null,
"activeTo": null,
"installmentConfig": null,
"subscriptionConfig": null,
"isDemo": false,
"isRecurrent": false,
"isInstallment": false,
"isSubscription": false,
"meta": {}
},
"product": {
"id": 7001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"sellerId": 1,
"type": "Course",
"status": "Published",
"showInCatalog": true,
"currency": "Usd",
"publishedAt": "2026-01-01T00:00:00.000Z",
"saleStartAt": null,
"saleFinishAt": null,
"isFree": false,
"isPublished": true,
"approves": [],
"domains": [],
"course": {
"id": 2001,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"archivedAt": null,
"type": "VideoCourse",
"name": "Sample Course",
"description": "Sample course description",
"alias": null,
"promoVideo": null,
"order": 0,
"isBundle": false,
"tags": [
"sample"
],
"seoTags": [],
"image": {
"main": ""
},
"settings": {}
}
}
}
]
},
"acquiring": {
"id": 1,
"active": true,
"uuid": "sampleAcquiringUuid",
"name": "Sample Acquiring",
"description": null,
"hasProviderCommission": false,
"provider": {
"id": 1,
"type": "Card",
"active": true
}
}
}
}
}
```
## Troubleshooting
Every attempt uses the same payload and `idempotencyKey`. Delays are counted from the previous
failed attempt (≈ 11 / 33 / 77 / 165 minutes). Return a success code to stop the retries.
Set `active = false` to pause delivery without losing the configuration and the secret key.
Retries of events that were queued before disabling stop after the next failed attempt.
Log `event`, `timestamp`, `idempotencyKey` and the signature verification result: this speeds up reconciliation with
our side when investigating incidents.
***
*Updated: 2026-09-28 05:04 UTC*
# API Client
Source: https://docs.exode.biz/en/exode-sdk/api-client
ExodeAPI server-side client for the SaaS API
`ExodeAPI` is a typed HTTP client covering the `/saas/v2/*` endpoints. It is intended for the server (Node.js ≥ 18,
uses the built-in `fetch`) and handles authentication, serialization of query parameters and the request body, unwrapping
the response (`payload`) and error handling.
The full REST specification (request/response, error codes) is in the [Exode API](/en/exode-api/setup) section.
The SDK is a typed wrapper over the same contract; its types are inferred from the same server-side zod schemas.
## Initialization
To connect, you need three values: an API token, `sellerId` and `schoolId`. All three are issued in the school account on
the API keys page; the step-by-step process is described in [Working with the API](/en/exode-api/setup). The token's
permissions are configured there as well: each method requires its own permission (listed on the method's page in the Exode API section).
```ts theme={null}
import { ExodeAPI } from '@exode-team/sdk/api'
const exodeApi = new ExodeAPI({
sellerId: Number(process.env.EXODE_SELLER_ID),
schoolId: Number(process.env.EXODE_SCHOOL_ID),
token: process.env.EXODE_TOKEN!,
baseUrl: 'https://api.exode.biz/saas/v2', // default
timeout: 30_000, // default 30s
})
```
### Configuration parameters
Seller ID. Sent in the `Seller-Id` header.
School ID. Sent in the `School-Id` header.
API token of the service user. Sent in `Authorization: Bearer `.
Base API URL. Defaults to `https://api.exode.biz/saas/v2`.
Request timeout in milliseconds. Defaults to `30000`. When exceeded, an `ExodeAPIError` with `cause: "Timeout"` (code 408) is thrown.
Keep the token in environment variables. Never commit tokens or pass them to the client side: `ExodeAPI`
works on the server only.
## Available resources
The client exposes a `school` namespace with nine resources:
User CRUD, search, state, deletion, auth tokens.
Org structure (HR): departments, positions, employments, managers, absences.
Lists of groups and members, bulk adding/removing members.
List of courses and members' course progress.
List of issued certificates.
List of product accesses.
List of invoices.
Form layouts and custom field values.
Generating exports and polling their results.
## Users (`school.user`)
```ts theme={null}
import { ProfileRole, UserStateKey, UserStatus } from '@exode-team/sdk/api'
// Create — returns the user object (or null)
const user = await exodeApi.school.user.create({
email: 'student@example.com',
profile: { firstName: 'John', role: ProfileRole.Student },
})
if (!user) throw new Error('User was not created')
// Update — returns the user
await exodeApi.school.user.update(user.id, { profile: { lastName: 'Smith' } })
// Update by external id (extId from your CRM/LMS)
await exodeApi.school.user.updateByExtId('crm_12345', { profile: { lastName: 'Smith' } })
// Upsert (create or update by email/phone/tgId/extId)
const { user: u, isCreated } = await exodeApi.school.user.upsert({
email: 'student@example.com',
profile: { firstName: 'John' },
})
// Find — exactly one of login | tgId | extId; returns the user or null
const found = await exodeApi.school.user.find({ login: 'student@example.com' })
// Find many — lists of logins / tgIds / extIds (≤250 each); not found ones are omitted
const users = await exodeApi.school.user.findMany({ extIds: ['crm_12345', 'crm_67890'] })
// List — filter (statuses/search/extIds/date ranges) + sort + pagination
const { items } = await exodeApi.school.user.list({
statuses: [UserStatus.Active, UserStatus.OnLeave],
take: 50,
})
// Ban / unban — via status (the `banned` flag was removed from the API)
await exodeApi.school.user.update(user.id, { status: UserStatus.Banned })
await exodeApi.school.user.update(user.id, { status: UserStatus.Active }) // the actual status is recalculated automatically
// Delete (≤250 at a time) — { deleted, skipped }
const { deleted, skipped } = await exodeApi.school.user.deleteMany([1, 2, 3], 'Access revocation')
// State — key from the UserStateKey enum
await exodeApi.school.user.setState(user.id, UserStateKey.OnBoardingProgress, { step: 2 }) // → boolean
const value = await exodeApi.school.user.getState(user.id, UserStateKey.OnBoardingProgress) // → unknown
// Auto-auth token — { session, isCreated }
const { session } = await exodeApi.school.user.createAuthToken({ userId: user.id })
// session.token → put into ?___uat= for auto-login
```
For more on auto-login via `___uat`, see
[Telegram Mini App integration](/en/customization/tg-mini-app).
In TypeScript, pass enum parameters (`UserStatus`, `ProfileRole`, `CourseType`, etc.) using the
exported enums: they are string enums, and a string literal such as `'Active'` will not pass type checking.
In JavaScript you can pass strings directly: the values match the names (`UserStatus.Active === 'Active'`).
## Staff (`school.staff`)
The org structure for HR integrations (1C, HRM): departments, positions, employments, department managers
and absences. Most methods have `...ByExtId` variants for syncing by your external identifier.
The methods are available only to schools in the corporate segment (`Corporate`).
```ts theme={null}
import { AbsenceType, EmploymentType } from '@exode-team/sdk/api'
const tree = await exodeApi.school.staff.department.tree()
const department = await exodeApi.school.staff.department.create({ name: 'Engineering', extId: 'dep_engineering' })
const position = await exodeApi.school.staff.position.create({ name: 'Backend Developer' })
const employment = await exodeApi.school.staff.employment.hire({
userId: 123,
departmentId: department.id,
positionId: position.id,
type: EmploymentType.FullTime,
})
await exodeApi.school.staff.departmentManager.set({ departmentId: department.id, employmentId: employment.id })
await exodeApi.school.staff.absence.create({
employmentId: employment.id,
type: AbsenceType.Vacation,
startAt: '2026-08-01',
})
```
The full list of methods and fields is in the [Staff](/en/exode-api/school/staff/department) section of the Exode API.
## Groups (`school.group`)
```ts theme={null}
// List groups (filter + pagination)
const { items } = await exodeApi.school.group.list({ courseIds: [10], take: 50 })
// List group members (filter + pagination)
const members = await exodeApi.school.group.listMembers({ groupIds: [42], active: true, take: 100 })
// Add members (≤250) — { exist, created }
await exodeApi.school.group.addMembers(groupId, [1, 2, 3])
// Remove members (≤250) — { affected }
await exodeApi.school.group.removeMembers(groupId, [1])
```
## Courses (`school.course`)
```ts theme={null}
import { CourseType } from '@exode-team/sdk/api'
// List courses
const { items } = await exodeApi.school.course.list({ types: [CourseType.VideoCourse], take: 20 })
// Course members' progress (pagination)
const progress = await exodeApi.school.course.progresses(courseId, { take: 100 })
```
## Certificates (`school.certificate`)
```ts theme={null}
const { items } = await exodeApi.school.certificate.list({
courseIds: [10],
issuedAtDateRange: { from: '2026-01-01' },
take: 50,
})
```
## Product accesses (`school.productAccess`)
```ts theme={null}
const { items } = await exodeApi.school.productAccess.list({ active: true, take: 50 })
```
## Invoices (`school.invoice`)
```ts theme={null}
import { InvoiceType } from '@exode-team/sdk/api'
const { items } = await exodeApi.school.invoice.list({ types: [InvoiceType.Regular], take: 50 })
```
## Forms (`school.form`)
```ts theme={null}
import { FormLayoutMode, FormLayoutStatus } from '@exode-team/sdk/api'
// Form layouts
const { items: layouts } = await exodeApi.school.form.layoutList({ modes: [FormLayoutMode.Signup], take: 20 })
const layout = await exodeApi.school.form.layoutCreate({ mode: FormLayoutMode.Custom, name: 'Survey', internalName: 'survey' })
await exodeApi.school.form.layoutUpdate(layout.id, { status: FormLayoutStatus.Published })
await exodeApi.school.form.layoutDelete(layout.id) // → { affected }
// Custom field values
const { items } = await exodeApi.school.form.customFieldValueGet({ userIds: [27], take: 50 })
// Set values by slug (type is inferred automatically)
await exodeApi.school.form.customFieldValueSetBySlug({
userId: 27,
layoutId: layout.id,
values: [{ slug: 'city', value: 'Tashkent' }, { slug: 'age', value: 25 }],
})
// Set values by fieldId (typed)
await exodeApi.school.form.customFieldValueSet({
userId: 27,
layoutId: layout.id,
values: [{ fieldId: 10, text: 'Tashkent' }],
})
```
## Exports (`school.queryExport`)
Exports run asynchronously: generation starts a workflow, and you poll for the result by UUID.
```ts theme={null}
import { QueryExportType, QueryExportFormat, WorkflowExecutionStatus } from '@exode-team/sdk/api'
// 1. Start — returns { uuid, status, ... }
const { uuid } = await exodeApi.school.queryExport.generate({
type: QueryExportType.GroupMemberFindMany,
format: QueryExportFormat.Csv,
variables: { filter: { groupIds: [42] } },
})
// 2. Poll for result (repeat until status becomes Completed/Failed; null — not found yet)
const result = await exodeApi.school.queryExport.getResult(uuid)
if (result?.status === WorkflowExecutionStatus.Completed) {
// `result.result` is typed as unknown — cast it to the documented shape
const file = result.result as { fileUrl: string; fileName: string; fileSize: number }
console.log('File:', file.fileUrl)
}
```
### Execution statuses
| Status | Meaning |
| - | - |
| `Waiting` | The task is queued |
| `Processing` | In progress |
| `Completed` | Done; `result` contains the file (`fileUrl`, `fileName`, `fileSize`) |
| `Failed` | Error |
| `Canceled` | Canceled |
`generate` is limited to **100 requests per hour**. Report types and variables are described in
[Reports and exports](/en/exode-api/school/query-export/generate).
## Response typing
The types of all responses are inferred from the same server-side zod schemas (via `z.infer`) and can be imported
(`User`, `Profile`, `Session`, `Group`, `GroupMember`, `CourseProgress`, `FormLayout`, `FormFieldValue`,
as well as the `*Output` types). There is **no** runtime validation on the client side: contracts are already checked on the backend
(`@ZodResponse`), so zod does not end up in the runtime bundle; the response is returned as is, strictly typed.
```ts theme={null}
import type { User, ListGroupOutput } from '@exode-team/sdk/api'
```
## Error handling
All errors are wrapped in `ExodeAPIError`:
```ts theme={null}
import { ExodeAPI, ExodeAPIError } from '@exode-team/sdk/api'
try {
await exodeApi.school.user.create({ email: 'busy@example.com' })
} catch (error) {
if (error instanceof ExodeAPIError) {
console.error(error.code) // 400, 401, 403, 408, 429, 0 (network)...
console.error(error.errorCause) // 'EmailIsBusy', 'Unauthorized', 'Timeout', 'NetworkError'...
console.error(error.details) // technical description (optional)
}
}
```
| `errorCause` | When |
| - | - |
| domain codes (`EmailIsBusy`, `Unauthorized`, `Forbidden`, `Rate`, …) | the API returned an error |
| `Timeout` | `timeout` exceeded (code 408) |
| `NetworkError` | network error (code 0) |
| `ParseError` | the response could not be parsed as JSON |
The full list of domain `cause` codes is in [Working with the API](/en/exode-api/setup#error-format).
***
*Updated: 2026-09-25 14:33 UTC*
# Installation
Source: https://docs.exode.biz/en/exode-sdk/installation
Add the Exode SDK via npm or CDN
## Requirements
≥ 18.0.0
≥ 5.7 (optional, types included)
≥ 17 (only for `miniapp/react`)
## Installing via npm
```bash npm theme={null}
npm install @exode-team/sdk
```
```bash yarn theme={null}
yarn add @exode-team/sdk
```
```bash pnpm theme={null}
pnpm add @exode-team/sdk
```
React is an optional peer dependency. If your project doesn't use React, the `miniapp/react` module won't pull it in and won't end up in the bundle.
## Imports
The package has four subpath exports — import only the one you need:
```ts theme={null}
import { ExodeAPI } from '@exode-team/sdk/api' // server (Node.js)
import { ExodeMiniApp } from '@exode-team/sdk/miniapp' // browser, inside an iframe
import { useExodeUser } from '@exode-team/sdk/miniapp/react' // browser + React
import { verifyInitData } from '@exode-team/sdk/miniapp/server' // server (Node.js)
```
`@exode-team/sdk/api` and `@exode-team/sdk/miniapp/server` are for the server only: the first works
with the school's API token, the second with the page secret and uses the Node.js `crypto` module. Do not import them
into the client bundle.
Do not import from the root (`@exode-team/sdk`) — the root export is not defined, to avoid accidentally loading server and client code in the same bundle.
## Using a CDN
For static pages without a build step, an IIFE bundle of the MiniApp module (`ExodeMiniApp`, `retrieveInitData`) is available.
React hooks and server functions are not shipped via the CDN.
```html Specific version (recommended) theme={null}
```
```html Latest version theme={null}
```
Once loaded, the global `ExodeMiniAppSDK` object becomes available:
```html theme={null}
```
In production, always pin the version (`v0.3.0`, `v0.3.1`, etc.). The `latest` tag updates automatically and may introduce breaking changes.
`retrieveInitData` is available in the CDN bundle starting from `v0.3.1`. Version numbers on the CDN may lag behind npm — if the version
you need is not on the CDN, install via npm.
## TypeScript
Types ship with the package; no additional `@types/...` package is required.
```ts theme={null}
import type { ExodeAPIConfig, User } from '@exode-team/sdk/api'
import type {
ExodeMiniAppConfig,
MiniAppContext,
MiniAppUser,
} from '@exode-team/sdk/miniapp'
import type { InitDataPayload } from '@exode-team/sdk/miniapp/server'
// The Platform type is not exported separately — take it from the context:
type Platform = MiniAppContext['platform'] // 'web' | 'native'
```
***
*Updated: 2026-09-25 14:33 UTC*
# ExodeMiniApp client
Source: https://docs.exode.biz/en/exode-sdk/miniapp/client
Vanilla JS API: init, route, ui, events
`ExodeMiniApp` is the main client class. Use one instance per app. All methods are typed and return a `Promise`.
## Creating an instance
```ts theme={null}
import { ExodeMiniApp } from '@exode-team/sdk/miniapp'
const app = new ExodeMiniApp({
appId: 'my-app', // required
targetOrigin: 'https://my-school.exode.biz', // origin of the school; default '*'
timeout: 10_000, // ms, default 10000
})
```
### Configuration parameters
The mini app identifier, passed to the host during the handshake. For [custom pages](/en/customization/custom-pages),
you can choose any value; it is not registered anywhere: the host trusts the app based on the origin of the address in the
**App URL (iframe)** field.
The origin of the school page the app is opened in, for example `https://my-school.exode.biz` or your
school's own domain. The SDK sends messages only to this origin and accepts responses only from it.
Defaults to `*` (any origin); in this case the SDK writes a warning to the console. In production, specify
the origin explicitly.
Timeout for the handshake and all commands (ms). Defaults to `10000`.
The client must run **inside an iframe**. Calling `init()` in a top-level window throws the error `ExodeMiniApp must be used inside an iframe`.
## Initialization
```ts theme={null}
const ctx = await app.init()
console.log(ctx.user.firstName)
console.log(ctx.theme.scheme)
```
The method performs the handshake with the host and returns a [`MiniAppContext`](/en/exode-sdk/miniapp/context). Calling `init()` again throws an error.
If the page is opened without signing in (the **Available without login** option), the handshake still succeeds, but `ctx.user.id`
is `0` and the personal fields are empty, so check `ctx.user.id > 0` before showing user data.
The context from `init()` is not signed and is suitable for display only. To reliably identify the user on
your backend, use [Init Data](/en/exode-sdk/miniapp/init-data).
If the handshake does not complete within `timeout` ms, the Promise is rejected. Make sure the iframe is opened inside Exode and `targetOrigin` is correct.
## Navigation (app.route)
`path` is a platform route template (not a ready-made URL); values are substituted from `params`:
```ts theme={null}
// Open the course page: route template + params
await app.route.navigate('/course/:courseId([0-9_A-Za-z]+)', { courseId: '123' })
// Back through host history
await app.route.back()
```
The route templates are the same as in [mobile app deep links](/en/customization/mobile-app#other-screens)
(`pageId`). The host's current route arrives in the same format in the `route:changed` event.
When the app is hidden (the user went to another page or minimized the window), the host ignores the
`navigate`, `navigate:back` and `setTabbarVisible` commands.
## UI commands (app.ui)
```ts theme={null}
// Snackbar notification
await app.ui.showSnackbar({
message: 'Saved!',
type: 'success', // 'success' | 'error' | 'info'
})
// Hide / show host bottom navigation
await app.ui.setTabbarVisible(false)
// Hide / show host header (see the note below)
await app.ui.setHeaderVisible(false)
// Minimize the window (window-type pages only)
await app.ui.minimize()
// Close the mini app
await app.ui.close()
```
`minimize()` works only for pages with the **Floating window**, **Side panel** or **Fullscreen** window type
(see [Custom pages](/en/customization/custom-pages)). A minimized window is not unloaded:
the app receives the `visibility:changed` event with `visible: false` and keeps running.
On the school's custom pages, the host currently does not handle the `setHeaderVisible` command, and it has no effect.
A command's Promise resolves when the host confirms receipt. The host also confirms commands that
do nothing in the current context, so a resolved Promise does not guarantee a visible effect.
## Events (app.on)
Subscribe to host events. Returns an unsubscribe function.
```ts theme={null}
const unsubscribe = app.on('theme:changed', (theme) => {
document.body.className = theme.scheme
})
app.on('user:updated', (user) => {
console.log('User changed:', user.firstName)
})
app.on('route:changed', ({ path, params }) => {
console.log('Host navigated to:', path)
})
// Unsubscribe
unsubscribe()
```
The full list of events and their payloads is in the [Context and types](/en/exode-sdk/miniapp/context) section.
## Getting the current context
`init()` returns the context once. For later access, use `getContext()`: it returns the
context from the handshake, to which the bridge applies only `context:updated` events.
The `theme:changed`, `user:updated`, `school:updated` and `config:updated` events do **not** update the object from
`getContext()`. If you need the current theme, user or config, subscribe to these events via
`app.on(...)` and store the values yourself, or use the [React hooks](/en/exode-sdk/miniapp/react), which
do this automatically.
```ts theme={null}
const ctx = app.getContext()
if (ctx) {
console.log(ctx.user.firstName)
}
```
## Shutting down
```ts theme={null}
app.destroy()
```
Closes the bridge, removes the `postMessage` listeners and clears event handlers. Call it when the app unmounts.
## Full example
```ts theme={null}
import { ExodeMiniApp } from '@exode-team/sdk/miniapp'
const app = new ExodeMiniApp({
appId: 'my-app',
targetOrigin: 'https://my-school.exode.biz',
})
async function start() {
const ctx = await app.init()
document.body.className = ctx.theme.scheme
document.getElementById('hello')!.textContent = ctx.user.id > 0
? `Hello, ${ctx.user.firstName ?? ''}`
: 'Hello, guest'
app.on('theme:changed', (theme) => {
document.body.className = theme.scheme
})
document.getElementById('btn-save')!.addEventListener('click', async () => {
await app.ui.showSnackbar({ message: 'Saved!', type: 'success' })
})
}
start().catch(console.error)
window.addEventListener('beforeunload', () => app.destroy())
```
***
*Updated: 2026-09-28 05:04 UTC*
# Context and types
Source: https://docs.exode.biz/en/exode-sdk/miniapp/context
The MiniAppContext structure, host events and MiniApp commands
## MiniAppContext
The full context the host passes to the MiniApp during the handshake. It is returned from `app.init()` and available via `app.getContext()`.
```ts theme={null}
interface MiniAppContext {
user: MiniAppUser
school: Record
theme: MiniAppTheme
platform: Platform
config: MiniAppConfig
}
```
### MiniAppUser
The user's internal ID in Exode. For an unauthenticated visitor (a page with the
"Available without login" option) it is `0`, and the other fields are empty.
The user's public UUID.
The user's first name.
The user's last name.
Avatar: `id` and links in three sizes, `small`, `medium`, `maximum` (each `string | null`).
The user's email.
The user's phone number.
Role: `Student`, `Tutor`, `Parent`, etc.
Interface language: `en`, `ru`, `uz`, `qa`.
### MiniAppTheme
```ts theme={null}
interface MiniAppTheme {
scheme: 'light' | 'dark'
}
```
### MiniAppConfig
```ts theme={null}
interface MiniAppConfig {
isDesktop: boolean
isMobile: boolean
language: string
}
```
### Platform
```ts theme={null}
type Platform = 'web' | 'native'
```
`native` means Exode is running inside a native shell (for example, an iOS/Android app). Otherwise it is `web`.
### school
The `school` field contains the school's public data used by the Exode app itself (for example, `name`, `domain`,
`preferenceSettings`). The SDK types it as `Record`: the set of fields is not fixed by the contract,
so check that the field you need is present before using it.
## Host events (MiniAppEventMap)
Sent by the host when data changes. The MiniApp subscribes via `app.on(event, handler)`.
| Event | Payload | When it fires |
| - | - | - |
| `theme:changed` | `MiniAppTheme` | The user switched the theme |
| `user:updated` | `MiniAppUser` | The profile changed or the account was switched |
| `school:updated` | `Record` | The school data changed |
| `config:updated` | `MiniAppConfig` | Window size or language changed |
| `route:changed` | `{ path, params }` | The host changed the route; `path` is the route template, for example `/course/:courseId([0-9_A-Za-z]+)` |
| `context:updated` | `Partial` | Partial context update (reserved: the current host version does not send this event) |
| `visibility:changed` | `{ visible: boolean }` | The iframe became visible/hidden |
The `context:updated` event is applied to the internal cache automatically. Other events do not update the
`app.getContext()` cache; see [Getting the current context](/en/exode-sdk/miniapp/client#getting-the-current-context).
## MiniApp → host commands (MiniAppCommandMap)
Called via the `app.route` and `app.ui` namespaces.
| Command | Payload | SDK method |
| - | - | - |
| `navigate` | `{ path, params? }` — `path` is the route template | `app.route.navigate()` |
| `navigate:back` | — | `app.route.back()` |
| `showSnackbar` | `{ message, type? }` | `app.ui.showSnackbar()` |
| `setTabbarVisible` | `{ visible }` | `app.ui.setTabbarVisible()` |
| `setHeaderVisible` | `{ visible }` | `app.ui.setHeaderVisible()` (no effect on custom pages yet) |
| `minimize` | — | `app.ui.minimize()` |
| `close` | — | `app.ui.close()` |
`minimize` collapses the app window and works only for pages with the
"Floating window", "Side panel" or "Fullscreen" window type (see [Custom pages](/en/customization/custom-pages)).
For the "Page" type the command does nothing. A collapsed window is not unloaded: the iframe stays alive,
its state is preserved, and the app receives `visibility:changed` with `visible: false`.
## BridgeMessage
The low-level `postMessage` message format. You usually do not need it, but it is useful for debugging.
```ts theme={null}
interface BridgeMessage {
type: string
requestId?: string
payload?: T
source: 'exode-host' | 'exode-miniapp'
}
```
Do not send `postMessage` manually. All valid interactions go through the `ExodeMiniApp` methods: only they guarantee a correct handshake, requestId and origin validation.
## Importing types
```ts theme={null}
import type {
MiniAppContext,
MiniAppUser,
MiniAppTheme,
MiniAppConfig,
MiniAppEventMap,
MiniAppCommandMap,
BridgeMessage,
} from '@exode-team/sdk/miniapp'
// Platform is not exported separately:
type Platform = MiniAppContext['platform']
```
***
*Updated: 2026-09-28 05:04 UTC*
# Create with AI
Source: https://docs.exode.biz/en/exode-sdk/miniapp/create-miniapp
A ready-made skill for an AI agent: an Exode mini app with initData verification, from an empty folder to production
We have prepared a **ready-made skill for an AI agent** (Claude Code, Cursor, ChatGPT, etc.):
the agent installs the tools, creates the app, verifies the
`exodeInitData` signature on the server, deploys to Vercel or Cloudflare and connects
the app to your school on its own.
**No programming skills are required**: the agent performs all technical steps itself.
## How to use it
Give the agent the link to the skill and ask it to follow it:
```
https://docs.exode.biz/skills/create-miniapp.md
```
For example, write to the agent: "Download [https://docs.exode.biz/skills/create-miniapp.md](https://docs.exode.biz/skills/create-miniapp.md)
and do everything according to these instructions. I want an app that …".
You only need to provide three things:
1. A hosting account (the agent will tell you where to sign up: Vercel or Cloudflare).
2. The page secret from the school admin panel (**School → Customization → Apps & pages → ⋯ → Show secret**).
3. Paste the app's final https link into the admin panel (this requires the **"Apps & Pages Management"** permission (`SchoolManagePages`)).
The skill is written in English, since agents work most accurately that way. You can talk to the agent
in your own language: its instructions explicitly tell it to reply in the user's language.
The same skill is automatically available to agents via Agent Skills Discovery:
`https://exode.biz/.well-known/agent-skills/exode-create-miniapp/SKILL.md`.
***
*Updated: 2026-09-25 14:33 UTC*
# Init Data
Source: https://docs.exode.biz/en/exode-sdk/miniapp/init-data
Signed user data: retrieving it in the iframe and verifying it on your server
When a mini app opens on a custom school page, Exode passes it **initData** — a string with user and school
data signed with HMAC-SHA256 (similar to `initData` in Telegram Mini Apps).
Once you verify the signature **on your server**, you can trust this data and authorize the user
without your own registration.
Data from the `postMessage` context (`ctx.user`) is not signed and is suitable for display only.
Any authorization on your backend must rely exclusively on verified initData.
## How it works
1. Exode opens your page in an iframe at `https://your-domain/…#exodeInitData=`.
2. The mini app reads the string from the fragment (`retrieveInitData`) and sends it to its backend.
3. The backend verifies the signature with the page secret (`verifyInitData`) and gets the user data.
The page secret is issued in the school admin panel (**School** → **Customization** → **Apps & pages**,
page menu **⋯** → **Show secret**) and is stored **only on your server**. Each page has its own secret.
`retrieveInitData`, `useExodeInitData` and `verifyInitData` are available in `@exode-team/sdk` starting from version `0.3.1`.
## In the mini app (browser)
```ts theme={null}
import { retrieveInitData } from '@exode-team/sdk/miniapp'
const initData = retrieveInitData()
// a string like "page_id=…&school_id=…&auth_date=…&user=…&session_uuid=…&hash=…"
// null if the page was not opened from Exode (no exodeInitData in the fragment)
await fetch('/api/session', {
method: 'POST',
headers: { 'X-Exode-Init-Data': initData ?? '' },
})
```
`retrieveInitData` reads the fragment once, removes it from the address bar
(so that the signed data does not leak when the link is copied) and caches the value.
In React, use the [`useExodeInitData`](/en/exode-sdk/miniapp/react#useexodeinitdata) hook — it works the same way and does not require a provider.
The `user` and `session_uuid` fields are present only for an authorized user. The set of fields may grow — when
verifying the signature, include all fields except `hash`.
## On your server (Node.js)
```ts theme={null}
import { verifyInitData } from '@exode-team/sdk/miniapp/server'
// initData is the string sent by the browser (in the example above, the X-Exode-Init-Data header)
const payload = verifyInitData(initData, {
secret: process.env.EXODE_PAGE_SECRET!,
maxAgeSec: 86_400, // auth_date validity window, 24 hours by default
})
payload.pageId // page id
payload.schoolId // school id
payload.authDate // issue time, unix seconds
payload.user // { id, firstName, lastName, avatar, language } | null for a guest; avatar is a URL or null
```
If the signature is invalid, the secret belongs to another page, or `auth_date` has expired, the function throws an exception (`Error`) —
catch it and respond with `401`.
## Signature format
If you do not use Node.js, you can verify the signature manually in any language:
```
data_check_string = key-sorted "key=value" pairs of all fields
except hash, joined with "\n" (URL-decoded values)
secret_key = HMAC_SHA256(key = "ExodeMiniApp", message = page_secret)
hash = hex( HMAC_SHA256(key = secret_key, message = data_check_string) )
```
The initData string is encoded as `application/x-www-form-urlencoded`: when decoding, `+` means a space.
Use your language's standard query string parser rather than a manual `split`. The page secret is used
as is — it is a string, and you do not need to decode it (hex/base64). After verifying the signature, compare `auth_date`
(unix time in seconds) with the current time to discard stale data.
Node.js example without the SDK:
```js theme={null}
const crypto = require('crypto')
function verify(initData, secret, maxAgeSec = 86400) {
const params = new URLSearchParams(initData)
const hash = params.get('hash') ?? ''
params.delete('hash')
const dcs = [...params.keys()].sort()
.map((k) => `${k}=${params.get(k)}`)
.join('\n')
const secretKey = crypto.createHmac('sha256', 'ExodeMiniApp').update(secret).digest()
const check = crypto.createHmac('sha256', secretKey).update(dcs).digest('hex')
const valid = hash.length === check.length
&& crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(check))
const age = Math.floor(Date.now() / 1000) - Number(params.get('auth_date'))
return valid && age <= maxAgeSec
}
```
## Security recommendations
* Limit the `auth_date` window (`maxAgeSec`) to 24 hours or less.
* Serve the mini app pages with the header
`Content-Security-Policy: frame-ancestors https://` —
this ensures the app is embedded specifically in Exode.
* Pass `targetOrigin` to the `ExodeMiniApp` constructor — the origin of the school page.
* Never verify the signature in the browser: the secret must not leave your server.
* If the secret is compromised, regenerate it in the admin panel (page menu **⋯** → **Regenerate secret**) — old
initData will stop passing verification. Remember to update the secret on your server.
***
*Updated: 2026-09-25 14:33 UTC*
# Introduction to MiniApp
Source: https://docs.exode.biz/en/exode-sdk/miniapp/introduction
Concept and architecture of embedded mini apps in Exode
A MiniApp is an external web application that opens in an `iframe` inside the main Exode app. A school connects
it through a [custom page](/en/customization/custom-pages). The SDK provides two-way communication between the
mini app and the host via `postMessage` and helps you verify the signature of user data on your server.
## Architecture
```
Exode App (host) ◄── postMessage ──► MiniApp (iframe)
• sends context • receives user, school, theme
• listens for commands • sends commands (navigate, snackbar)
• pushes updates • subscribes to events
```
The host side of the bridge is implemented inside the main Exode app. The guest side (what the MiniApp developer includes) is `@exode-team/sdk/miniapp`.
## Lifecycle
The main app opens the MiniApp by URL in an `iframe` and appends signed user data to the address —
`#exodeInitData=...` (see [Init Data](/en/exode-sdk/miniapp/init-data)).
The MiniApp calls `app.init()` and sends its `appId` to the host. The host checks the message origin (for custom
pages, the origin of the address from "App URL (iframe)"; `appId` can be anything in this case) and returns the context
(user, school, theme, config).
The MiniApp sends commands (`navigate`, `showSnackbar`, ...). The host pushes events (`theme:changed`, `user:updated`, ...).
On unmount, the MiniApp calls `app.destroy()` — the bridge closes its listeners and releases resources.
## What is available to a MiniApp
* User profile (name, avatar, email, role, language)
* School object
* Current theme (`light` / `dark`)
* Platform (`web` / `native`) and device (`isDesktop` / `isMobile`)
* Navigation within the main app
* Showing snackbar notifications
* Controlling tabbar / header visibility
* Closing the mini app
* Theme, user, school and config changes
* Host route changes
* iframe visibility changes
## What's next
Vanilla JS client: init, route, ui, events.
MiniAppContext structure, event and command types.
Provider and reactive hooks for React apps.
Signed user data and how to verify it on your server.
Auto sign-in via the `___uat` parameter.
***
*Updated: 2026-09-25 14:33 UTC*
# React hooks
Source: https://docs.exode.biz/en/exode-sdk/miniapp/react
Provider and reactive hooks for a MiniApp in React
The `@exode-team/sdk/miniapp/react` module provides a declarative layer over `ExodeMiniApp` for React apps. All hooks are reactive: components re-render automatically on host events.
React is an optional peer dependency. If your project doesn't use React, you don't need this module — use the [vanilla client](/en/exode-sdk/miniapp/client).
## Provider
Wraps the app, creates `ExodeMiniApp`, performs the handshake and propagates event subscriptions:
```jsx theme={null}
import { ExodeMiniAppProvider } from '@exode-team/sdk/miniapp/react'
function App() {
return (
)
}
```
### Parameters
Client configuration: `appId`, `targetOrigin?`, `timeout?` (see [`ExodeMiniApp` parameters](/en/exode-sdk/miniapp/client#configuration-parameters)) plus `renderMode?`.
Children rendering mode:
* `'defer'` (default) — children are mounted only after the handshake completes (successfully or with an error);
until then, the provider renders `null`.
* `'immediate'` — children are mounted from the first render (including during SSR); until ready, hooks return
default values (`scheme: 'light'`, `user: null`, etc.).
## useExodeApp
Connection state and low-level access to the `ExodeMiniApp` instance.
```jsx theme={null}
function Status() {
const { app, isReady, error } = useExodeApp()
if (error) return Connection error: {error.message}
if (!isReady) return Connecting to Exode...
return Connected
}
```
| Field | Type | Description |
| - | - | - |
| `app` | `ExodeMiniApp` | Client instance (created on the provider's first render, before the handshake) |
| `isReady` | `boolean` | Handshake completed |
| `error` | `Error \| null` | Initialization error (for example, the app is not opened in an iframe or the host didn't respond within `timeout`) |
In `renderMode: 'defer'` mode (default), the provider's children are not mounted while the handshake is in progress, so
the "Connecting to Exode..." branch from the example above is never rendered. To show a loading state, use
`renderMode: 'immediate'`.
## useExodeUser
The current user. Updated on the `user:updated` event. `isLoggedIn` is `true` if `user.id > 0`;
for an unauthenticated page visitor, the host passes `user.id === 0`.
```jsx theme={null}
function Greeting() {
const { user, isLoggedIn } = useExodeUser()
if (!isLoggedIn) return Not signed in
return Hello, {user?.firstName}
}
```
## useExodeTheme
The current theme. Updated on the `theme:changed` event. Also returns `isReady` — whether the theme has been received from the host.
```jsx theme={null}
function ThemedBox() {
const { scheme, isDark } = useExodeTheme()
return (
Scheme: {scheme}
)
}
```
## useExodeSchool
School data. Updated on the `school:updated` event.
```jsx theme={null}
function SchoolInfo() {
const { school } = useExodeSchool()
return {String(school?.name ?? '')}
}
```
## useExodeConfig
Environment configuration. Updated on the `config:updated` event.
```jsx theme={null}
function Layout({ children }) {
const { isDesktop, isMobile, language, platform } = useExodeConfig()
return (
{children}
)
}
```
## useExodeNavigation
Navigation commands for the main app. `navigate(path, params)` takes a route template and parameters —
see the "Navigation" section in [ExodeMiniApp client](/en/exode-sdk/miniapp/client).
```jsx theme={null}
function NavButtons() {
const { navigate, back } = useExodeNavigation()
return (
<>
>
)
}
```
## useExodeUI
UI commands to the host.
```jsx theme={null}
function Controls() {
const { showSnackbar, setTabbarVisible, setHeaderVisible, minimize, close } = useExodeUI()
return (
<>
>
)
}
```
`minimize` works only for pages with the **"Floating window"**, **"Side panel"** or **"Fullscreen"** window type
(see [Custom pages](/en/customization/custom-pages)). You can track minimizing with
the `useExodeVisibility` hook. `setHeaderVisible` currently has no effect on custom pages.
## useExodeVisibility
Whether the app is visible to the user right now. The host doesn't unload the iframe when the user navigates to another
page or minimizes the window — instead, it sends `visibility:changed`. Use
the hook to pause polling, videos and animations in the background.
```jsx theme={null}
import { useEffect } from 'react'
function Poller() {
const { visible } = useExodeVisibility()
useEffect(() => {
if (!visible) return
const timer = setInterval(() => refetch(), 5000)
return () => clearInterval(timer)
}, [ visible ])
return
}
```
## useExodeInitData
The signed [Init Data](/en/exode-sdk/miniapp/init-data) string from the iframe URL (`#exodeInitData=...`).
The value is read once, removed from the address bar and cached. This hook doesn't need the provider.
Send the string to your backend and verify it there with the `verifyInitData` function — it must not be trusted in the browser.
```jsx theme={null}
import { useEffect } from 'react'
import { useExodeInitData } from '@exode-team/sdk/miniapp/react'
function Session() {
const { initData } = useExodeInitData() // string | null — null when opened outside Exode
useEffect(() => {
if (!initData) return
fetch('/api/session', { method: 'POST', headers: { 'X-Exode-Init-Data': initData } })
}, [ initData ])
return null
}
```
## Requirements
All hooks except `useExodeInitData` work only inside ``. Calling them outside of it
throws an error.
## Full example
```jsx theme={null}
import {
ExodeMiniAppProvider,
useExodeApp,
useExodeUser,
useExodeTheme,
useExodeUI,
} from '@exode-team/sdk/miniapp/react'
function Content() {
const { isReady, error } = useExodeApp()
const { user } = useExodeUser()
const { isDark } = useExodeTheme()
const { showSnackbar } = useExodeUI()
if (error) return Error: {error.message}
if (!isReady) return Loading...
return (
Hello, {user?.firstName ?? 'guest'}
)
}
export default function App() {
return (
)
}
```
***
*Updated: 2026-09-28 05:04 UTC*
# Overview
Source: https://docs.exode.biz/en/exode-sdk/overview
Exode SDK — a package for integrating with the Exode platform
`@exode-team/sdk` is the official Exode npm package that combines two independent modules: a server-side client for the School API and a bridge for embedded mini apps (MiniApp) — a client part for the iframe and a server part for verifying signatures.
A typed client for the `/saas/v2/*` REST endpoints. Used on the server (Node.js) and covers
users, employees (HR), groups, courses, certificates, accesses, invoices, forms and exports.
Client SDK for iframe apps: handshake with the host, navigation, UI commands, events, React hooks.
## Package modules
| Import | Purpose | Environment |
| - | - | - |
| `@exode-team/sdk/api` | Server-side SaaS API client | Node.js ≥18 |
| `@exode-team/sdk/miniapp` | Bridge for iframe mini apps | Browser |
| `@exode-team/sdk/miniapp/react` | React hooks on top of the bridge | Browser + React ≥17 |
| `@exode-team/sdk/miniapp/server` | Signature verification for [Init Data](/en/exode-sdk/miniapp/init-data) and webhooks (`verifyInitData`, `verifyWebhookSignature`) | Node.js ≥18 |
The package is split into independent subpath exports. Import only the module you need — tree-shaking removes the rest from the bundle.
## Current version
The latest published version. The package is under active development — follow the CHANGELOG.
## Context for AI assistants
A single file with the complete SDK reference: public API, signatures, types, enums, examples,
common mistakes. Give it to ChatGPT, Claude or Copilot, and the AI can immediately write
correct integration code without additional context.
The file's contents are automatically kept in sync with the sources of the
`@exode-team/sdk` package. It works both as a system prompt and as a chat attachment.
## What's next
Go to [Installation](/en/exode-sdk/installation) to add the package via npm or CDN.
* Server-side integration → [API Client](/en/exode-sdk/api-client). Get the token, `Seller-Id` and `School-Id`
in the school's account — see [Working with the API](/en/exode-api/setup)
* An app embedded inside Exode → [MiniApp](/en/exode-sdk/miniapp/introduction)
If you have any questions, contact [technical support](https://t.me/exode_support_biz).
***
*Updated: 2026-09-25 14:33 UTC*
# Introduction
Source: https://docs.exode.biz/en/welcome
Exode API documentation for integrating with the platform
Exode is an EdTech SaaS platform for online schools and production centers. The Exode API lets you manage
users, groups, courses, accesses, forms and exports, and receive platform events through
webhooks.
## Getting started
Your first request in a few minutes: token, headers, finding and creating a user.
The data model: seller, school, users, courses, products, accesses and payments.
Base URL, authentication, response and error format, rate limits, pagination.
React to sign-ups, payments, progress and access grants in real time.
## Official SDK
The official npm package: a typed server client for `/saas/v2/*` and a bridge for embedded
mini apps (with React hooks). Installation: `npm install @exode-team/sdk`.
Also available on [npm](https://www.npmjs.com/package/@exode-team/sdk).
## Postman collection
A ready-made collection with all `/saas/v2/*` endpoints and sample responses. Fork it into your
workspace and fill in the `apiToken`, `sellerId` and `schoolId` variables — all three values are in the admin panel
on the **API keys** page (see "Working with the API" for details).
## API capabilities
* 👥 Managing school users and groups
* 📚 Courses, student progress and export of practice attempts
* 🛒 Product accesses, invoices and payments
* 📝 Forms and user custom fields
* 📊 Asynchronous reports and exports (XLSX/CSV/JSON)
* 🔔 Webhooks for key platform events
## Support
We'll help with integration and setup.
***
*Updated: 2026-09-25 14:33 UTC*