# Other platforms Source: https://docs.exode.biz/en/analytics/custom Connect any ad platform by subscribing to the platform's conversion events For a platform without built-in support, subscribe to [conversion events](/en/analytics/events) with your own code in the school's [custom code](/en/customization/js-config) (**For Developers → Custom code**, HTML tab) and call the platform's JS API: ```html theme={null} ``` If you need built-in support for your platform, contact our [support team](https://t.me/exode_support_biz) and we will add it on the platform side. *** *Updated: 2026-09-25 14:33 UTC* # Conversion events Source: https://docs.exode.biz/en/analytics/events Platform events that are automatically sent to connected ad platforms Paste the platform's snippet into the school's [custom code](/en/customization/js-config) (**For developers → Custom code**, HTML tab), and the platform starts sending conversion events to it automatically. No additional code is needed. Changes take effect after a page reload; the code in the field is limited to 3000 characters. ## Accuracy and transparency A purchase is sent once per invoice: an on-device guard plus `eventID` / `transaction_id` for deduplication on the Meta and GA4 side. Amounts and currencies come from the invoice (ISO 4217); internal points do not distort revenue and ROAS. Events are regular `document` events: you can see them in the console and intercept them with your own code. ## Event list Each event is dispatched as a `CustomEvent` on `document` (data is in `event.detail`), so you can intercept it with your own code for [any other platform](/en/analytics/custom). ### Sign-up Registration of a new account via email or phone. The event is not sent when signing in via social networks (VK, Telegram) or OTP, because these methods do not yet distinguish a new user from a returning sign-in. Registration method: `email`, `phone`. ### Lead (onboarding form) The user filled in the onboarding form (name and personal data). No `event.detail`. ### Course view Course page view. Course ID. ### Demo lesson opened A demo lesson is opened from the course page. Course ID. Demo lesson ID. ### Free course enrollment Enrollment in a free course. Course product ID. ### Checkout initiated The user proceeds to checkout (a pricing plan is selected). Product ID. Selected pricing plan ID. ### Purchase Successful invoice payment. Sent **once per invoice**: resending on the same device is blocked for 90 days by the invoice uuid, and if the page is opened late (another device, a link from an email, more than 5 minutes after payment), the event is not sent at all. Invoice amount. Invoice currency: `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`, `Exes`, `Free`. Invoice uuid: a unique identifier for deduplication. IDs of the paid products. ### Course completion The user completed a course. Course product ID. *** *Updated: 2026-09-25 14:33 UTC* # Google Analytics (GA4) Source: https://docs.exode.biz/en/analytics/google-analytics Automatically send platform conversion events to Google Analytics ## Setup Paste the standard Google tag (gtag.js) snippet with your Measurement ID into the school's [custom code](/en/customization/js-config) (**For developers** → **Custom code**, HTML tab). The platform detects `window.gtag` and starts sending events automatically. ## Events sent Where possible, [Google's recommended events](https://support.google.com/analytics/answer/9267735) are used; the rest are custom: ### Sign-up A new account is registered. The `method` parameter is `email` or `phone`. ### Lead (onboarding questionnaire) The onboarding questionnaire is completed. ### Course view A course page is viewed. The `items` parameter contains the course `item_id`. ### Demo lesson opened A demo lesson is opened. Parameters: `item_id`, `lesson_id`. A custom event. ### Free course enrollment Enrollment in a free course. The `item_id` parameter. A custom event. ### Checkout started The user proceeds to payment. The `items` parameter contains the product `item_id`. ### Purchase A successful payment. Parameters: `value`, `currency` (ISO 4217), `items`, `transaction_id` (the invoice uuid; GA4 itself ignores repeat purchases with the same `transaction_id`). The event is not sent for payments in internal currencies (points). ### Course completion A course is completed. The `item_id` parameter. A custom event. *** *Updated: 2026-09-25 14:33 UTC* # Meta Pixel (Facebook) Source: https://docs.exode.biz/en/analytics/meta-pixel Automatic sending of platform conversion events to Meta Pixel ## Setup Paste the standard Meta Pixel snippet with your Pixel ID into the school's [custom code](/en/customization/js-config) (**For developers → Custom code**, HTML tab). The platform detects `window.fbq` and starts sending events automatically. ## Events sent [Conversion events](/en/analytics/events) are mapped to [Meta standard events](https://www.facebook.com/business/help/402791146561655) (which support automatic ad optimization) and two custom events: ### Sign-up Registration of a new account. The `registration_method` parameter is `email` or `phone`. ### Lead (onboarding form) The onboarding form is filled in. ### Course view Course page view. Parameters: `content_type: 'product'`, `content_ids`. ### Demo lesson opened A demo lesson is opened. Parameters: `content_ids`, `lesson_id`. A custom event: you can set up a custom conversion for it in Events Manager. ### Free course enrollment Enrollment in a free course. Parameter: `content_ids`. ### Checkout initiated Proceeding to checkout. Parameters: `num_items`, `content_ids`. ### Purchase Successful payment. Parameters: `value`, `currency` (ISO 4217), `content_type`, `content_ids`. The invoice uuid is passed as `eventID`: Meta itself merges duplicates with the same `eventID` within 48 hours. The event is not sent for payments in internal currencies (points), because Meta requires a real currency. ### Course completion Course completion. Parameter: `content_ids`. A custom event. *** *Updated: 2026-09-25 14:33 UTC* # VK Ads Source: https://docs.exode.biz/en/analytics/vk-ads Automatic delivery of platform conversion events to VK Ads pixel goals (top.mail.ru) ## Setup Paste the standard VK Ads pixel snippet into the school's [custom code](/en/customization/js-config) ("For developers" → "Custom code", HTML tab) and additionally specify its ID in `window.VK_PIXEL_ID`: ```html theme={null} ``` ## Goals In your VK Ads account, create goals with the following IDs (the same as for [Yandex Metrica](/en/analytics/yandex-metrika)): ### Sign-up A new account is registered. ### Lead (onboarding questionnaire) The onboarding questionnaire is completed. ### Course view A course page is viewed. ### Demo lesson opened A demo lesson is opened. ### Free course enrollment Enrollment in a free course. ### Checkout started The user proceeds to checkout. ### Purchase Successful payment. `value` is passed — the payment amount (not passed for internal currencies). ### Course completion A course is completed. *** *Updated: 2026-09-25 14:33 UTC* # Yandex Metrica Source: https://docs.exode.biz/en/analytics/yandex-metrika Automatically send platform target events to Yandex Metrica goals ## Setup Paste the standard counter snippet into the school's [custom code](/en/customization/js-config) (**For developers** → **Custom code**, HTML tab) and also specify the counter number in `window.YM_ID` — the platform needs it to call `reachGoal`: ```html theme={null} ``` ## Goals In the counter settings, create [JS goals](https://yandex.ru/support/metrica/general/goal-js-event.html) with the following identifiers: ### Sign-up Registration of a new account. ### Lead (onboarding questionnaire) The onboarding questionnaire has been completed. ### Course view A course page view. ### Demo lesson opened A demo lesson has been opened. ### Free course enrollment Enrollment in a free course. ### Checkout started Proceeding to payment. ### Purchase Successful payment. Goal parameters: `order_price`, `currency` (not passed for internal currencies). ### Course completion Completion of a course. *** *Updated: 2026-09-25 14:33 UTC* # Custom pages Source: https://docs.exode.biz/en/customization/custom-pages Your school's own pages with mini apps at your own URL A school can create its own pages: each page gets its own address (`https://your-school.exode.biz/your-slug`) and embeds a **mini app** in an iframe: a website or web application hosted anywhere (Vercel, your own server, etc.). The mini app receives signed user data from the platform ([Init Data](/en/exode-sdk/miniapp/init-data)), can verify it on its backend and build any logic, from a landing page to a full-fledged service with its own database. ## Creating a page 1. Open **Control Panel** → **School** → **Customization** → **Apps & pages**. 2. Click **Create app** and fill in: * **Title**: shown in the header and the menu (in the school's languages); * **App address (slug)**: lowercase Latin letters, digits and hyphens, 2–64 characters. The page opens at `/`. The platform's system addresses are reserved; * **App URL (iframe)**: the https address of your mini app (it opens in an iframe); * **Window type**: how the app is shown to the user (see below); * **Layout**: only for the **Page** type: **Full width** or **Island** (a card with margins); * **Available without login**: whether to show the page to unauthenticated visitors. Such a visitor also receives signed Init Data, but without user data (`user` = `null`). 3. After creating the page, open the page menu "⋯" → **Show secret**. Your server needs this secret to [verify Init Data](/en/exode-sdk/miniapp/init-data). Keep it on the server only. The same menu has **Regenerate secret**: the old secret stops working immediately. Pages can be created and edited by a manager with the **"Apps & Pages Management"** permission (`SchoolManagePages`). The mini app's website must allow embedding in an iframe: do not send the `X-Frame-Options` header, and if you use `Content-Security-Policy: frame-ancestors`, include the school's domain in it. Add a menu item for the new page in **Settings** → **Left site menu** with the link `/`: navigation will be internal, without a reload. You can also open the app with a button from lesson content. ## Window type | Type | What it looks like | When it fits | | - | - | - | | **Page** | A regular platform page at `/` | A landing page, a reference, a full-fledged section | | **Floating window** | A window on top of the interface that minimizes into a stack | A chat, a calculator, an assistant: things used without interrupting learning | | **Side panel** | A side panel next to the content | Notes, hints, tools next to a lesson | | **Fullscreen** | A window covering the whole screen on top of the interface; minimizes into the stack like the other windows | Wizards and editors that need all the space, e.g. a course builder | At most one window can be expanded at a time: opening a second one minimizes the first. Minimized windows collect in a tray and expand with a single click. On a phone, every window type opens as a regular page. ## Page parameters The page address parameters are passed to the iframe as a query string: the link `/?courseId=5` opens the app at `https:///…?courseId=5#exodeInitData=…`. If the app URL already has its own query, the parameters are appended to it, and Init Data still arrives in the hash. The platform's service parameters (`modal`, `popup`, `appId`) are not passed to the iframe. This works for every window type. If the app is already open and gets opened with different parameters (for example, to continue working on another course), the iframe reloads with the new address; with the same parameters the window just expands. Parameters come from an address anyone can craft. Use them only as a hint about what to open, and check access to that entity on your server or with an API request made on behalf of the user. A window restored after the platform reloads opens without parameters. ## School home page In the page list, you can set a **main** page: the selected page (system or custom) opens at `/`. A custom page set as the main page cannot be deleted or turned off; set another main page first. ## iframe lifecycle An open app page is not unloaded when navigating around the platform: the iframe loads once and stays in memory, so reopening it is instant. The same goes for a minimized window: minimizing does not reload the app, and its state is preserved. Two commands are available from the mini app itself ([MiniApp SDK](/en/exode-sdk/miniapp/client)): * `app.ui.minimize()`: minimize the window (does nothing for the **Page** type, since there is nothing to minimize); * `app.ui.close()`: close the app and unload the iframe. The platform keeps a limited number of apps in memory and unloads the oldest inactive ones. Do not rely on state surviving a close; save important data on your server. ## What's next * [Introduction to MiniApp SDK](/en/exode-sdk/miniapp/introduction): the bridge, context, commands and events; * [Init Data](/en/exode-sdk/miniapp/init-data): verifying signed data on your server. *** *Updated: 2026-09-28 05:04 UTC* # Custom JS code Source: https://docs.exode.biz/en/customization/js-config Configure platform behavior with custom code in the school admin panel In the school admin panel, open **"For developers"** → **"Custom code"**, the **HTML** tab (the **"Custom HTML"** field). Code from this field is added to the `` of every school page before the app loads, so ` ``` ## Hiding interface elements The `excludeElements` parameter lets you hide individual platform interface elements for your school. Pass an array of element keys: ```html theme={null} ``` ### Available keys Video watch progress: the bar of watched segments under the video player in a lesson and the video watch progress in the student card (student management section) ## All parameters Hides the app download banner Array of keys of interface elements to hide (e.g. `videoWatchProgress`) *** *Updated: 2026-09-25 14:33 UTC* # Mobile app Source: https://docs.exode.biz/en/customization/mobile-app How to open a specific lesson (or another screen) from your app directly in the ExodeBiz app via a deep link, with optional token-based sign-in From your native app, you can open the ExodeBiz app directly on the screen you need — select the school, sign the user in with a token if needed, and open a lesson. All it takes is opening the scheme link the standard way (`Intent.ACTION_VIEW` on Android / `UIApplication.open` on iOS). ## Link format The deep link uses the app's URL scheme `exodebizapp://`. All parameters go into a single `data` parameter — a URL-encoded query string: ``` exodebizapp://?data= ``` Parameters inside `data`: Always `open-page`. The school's domain (FQDN), for example `my-school.com` or `my-school.exode.biz`. Screen identifier. For a lesson, see the "Open a lesson" section. JSON with the screen parameters, for example `{"page":"1","courseId":"42","lessonId":"123"}`. A list of additional actions (for example, sign-in). See the "extra" section. Parameter values contain special characters and require double encoding: first build the query string `action=...&domain=...` (with URL-encoded values), then encode it as a whole and put it into `data`. Don't build it manually — use standard builders (`URLSearchParams`, `Uri.Builder`, `URLComponents`), see the code examples below. ## Open a lesson | Field | Value | | - | - | | `pageId` | `/courses/:page([0-9]+)/:courseId([0-9]+)/study/:lessonId([0-9]+)` | | `params` | `{"page":"1","courseId":"","lessonId":""}` | * `page` — the page in the course list; to go to a lesson, specify `"1"`. * `courseId`, `lessonId` — the course and lesson IDs in your school. A ready-made link — substitute `DOMAIN`, `COURSE_ID`, `LESSON_ID`: ``` exodebizapp://?data=action%3Dopen-page%26domain%3DDOMAIN%26pageId%3D%252Fcourses%252F%253Apage%2528%255B0-9%255D%252B%2529%252F%253AcourseId%2528%255B0-9%255D%252B%2529%252Fstudy%252F%253AlessonId%2528%255B0-9%255D%252B%2529%26params%3D%257B%2522page%2522%253A%25221%2522%252C%2522courseId%2522%253A%2522COURSE_ID%2522%252C%2522lessonId%2522%253A%2522LESSON_ID%2522%257D ``` ### Other screens By changing `pageId` and `params`, you can open any screen: | Screen | `pageId` | `params` | | - | - | - | | Lesson | `/courses/:page([0-9]+)/:courseId([0-9]+)/study/:lessonId([0-9]+)` | `{"page":"1","courseId":"42","lessonId":"123"}` | | Course | `/course/:courseId([0-9_A-Za-z]+)` | `{"courseId":"42"}` | Need another screen? Contact support and we'll tell you the correct `pageId` and parameters. ## extra — nested actions (sign-in) The `extra` parameter lets you pass a list of actions to the app that run along with opening it — for example, token-based sign-in. `extra` is `base64url(JSON.stringify({ actions: [...] }))`. JSON structure: ```json theme={null} { "actions": [ { "type": "login-by-token", "token": "USER_TOKEN" } ] } ``` Supported actions: | `type` | Fields | Description | | - | - | - | | `login-by-token` | `token` | Sign the user in with a token | | `open-page` | `pageId`, `params` | Navigate to a screen | The execution order is guaranteed: first the actions from `extra` (in array order), then the navigation from `open-page`. So an `open-page` link + `extra:[{login-by-token}]` does exactly this: open the app → sign in → open the page. The token is created with the [Create a session token](/en/exode-api/school/user/session/auth-token) method (`POST /saas/v2/user/session/auth-token`). Take the value of the `token` field from the response. `extra = base64url(JSON.stringify({ actions: [{ type: 'login-by-token', token }] }))`. Example (with the placeholder token `USER_TOKEN`): ``` eyJhY3Rpb25zIjpbeyJ0eXBlIjoibG9naW4tYnktdG9rZW4iLCJ0b2tlbiI6IlVTRVJfVE9LRU4ifV19 ``` Add `extra` to the query string inside `data` — the app switches the school, signs the user in and opens the lesson. A link with `login-by-token` signs in as a specific user — anyone who has the link will be signed in as that user. Such links must be short-lived / personal and must not be published. Use base64url specifically (`-`/`_` instead of `+`/`/`, no `=` padding) — regular base64 may not pass through a query string correctly. ## Building and opening the link in code ```js JavaScript theme={null} const SCHEME = 'exodebizapp'; const LESSON = '/courses/:page([0-9]+)/:courseId([0-9]+)/study/:lessonId([0-9]+)'; const b64url = (obj) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(obj)))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); function buildLessonLink({ domain, courseId, lessonId, page = 1, token }) { const inner = new URLSearchParams({ action: 'open-page', domain, pageId: LESSON, params: JSON.stringify({ page: String(page), courseId: String(courseId), lessonId: String(lessonId), }), }); // optional: auto sign-in if (token) { inner.set('extra', b64url({ actions: [{ type: 'login-by-token', token }] })); } return `${SCHEME}://?data=${encodeURIComponent(inner.toString())}`; } ``` ```kotlin Android theme={null} fun buildLessonLink(domain: String, courseId: Long, lessonId: Long, page: Int = 1): String { val pageId = "/courses/:page([0-9]+)/:courseId([0-9]+)/study/:lessonId([0-9]+)" val paramsJson = """{"page":"$page","courseId":"$courseId","lessonId":"$lessonId"}""" val inner = Uri.Builder() .appendQueryParameter("action", "open-page") .appendQueryParameter("domain", domain) .appendQueryParameter("pageId", pageId) .appendQueryParameter("params", paramsJson) .build().encodedQuery ?: "" return "exodebizapp://?data=" + Uri.encode(inner) } // open with a fallback try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) } catch (e: ActivityNotFoundException) { // app not installed → open the store / website } ``` ```swift iOS theme={null} func buildLessonLink(domain: String, courseId: Int, lessonId: Int, page: Int = 1) -> URL { let pageId = "/courses/:page([0-9]+)/:courseId([0-9]+)/study/:lessonId([0-9]+)" let paramsJson = "{\"page\":\"\(page)\",\"courseId\":\"\(courseId)\",\"lessonId\":\"\(lessonId)\"}" var inner = URLComponents() inner.queryItems = [ .init(name: "action", value: "open-page"), .init(name: "domain", value: domain), .init(name: "pageId", value: pageId), .init(name: "params", value: paramsJson), ] let data = (inner.percentEncodedQuery ?? "") .addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "" return URL(string: "exodebizapp://?data=\(data)")! } // open with a fallback UIApplication.shared.open(link, options: [:]) { success in if !success { /* app not installed → open the store / website */ } } ``` ## Behavior * The deep link works only if the app is installed. If it isn't, the OS opens nothing: handle this yourself and show a fallback (the app store or the school's website). On Android, catch `ActivityNotFoundException`; on iOS, check `success == false` in the `open` completion handler. * If the school hasn't been added to the app yet, it is added automatically and becomes active. * Navigation follows the usual authorization rules: without an `extra` sign-in, an unauthenticated user sees the sign-in screen. ## Troubleshooting | Symptom | Cause / solution | | - | - | | Nothing happens when opening the link | The app is not installed, or the context doesn't recognize custom schemes. Implement a fallback. | | The wrong lesson opened | Check `pageId` (character for character) and the values in `params`; make sure `data` is built correctly (double encoding). | | "School not found" | Incorrect `domain`. Specify the school's exact FQDN. | | The sign-in screen opens | The user is not signed in / has no access. For auto sign-in, use `extra` with `login-by-token`. | *** *Updated: 2026-09-25 14:33 UTC* # Telegram Mini App integration Source: https://docs.exode.biz/en/customization/tg-mini-app How to create a user, get their token and sign them in automatically in a mini app via a bot The Mini App opens inside Telegram WebApp and accepts the user's token via the \_\_\_uat GET parameter. With a valid token, the user is signed in automatically without login screens. #### To implement automatic sign-in at the API level: 1. Call [POST /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. 1. Call [POST /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. No token is issued for users with permissions in the admin panel (administrators, managers) — the method is intended for students. 1. Build the school URL with the \_\_\_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 ``` The most convenient way is to open the Mini App with a bot button: in the Bot API, pass the school URL with \_\_\_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. You can also pass the token in the fragment: [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. If the token is valid and has not expired, the Mini App does not show the login screen — the user goes straight to the school interface. ## Common errors and how to avoid them * Authorization error in the Mini App: make sure the token is passed in \_\_\_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\://. Do not share the token in public chats or groups. Use personal links. *** *Updated: 2026-09-25 14:33 UTC* # Request an API method Source: https://docs.exode.biz/en/exode-api/common/request-new-method We add a new API method within a week ### How to request a new API method You can request a new API method, and we will implement it within 1 week after the requirements are agreed. Describe the task: the business goal, resource/entity, HTTP method and route, query/body parameters, expected response schema and examples. Contact our team: we will respond quickly and clarify the details. We will clarify the requirements, confirm the timeline and start development. The finished method, documentation and usage examples will be available within 1 week. To speed up development, attach example requests/responses and any business validation rules. *** *Updated: 2026-09-25 14:33 UTC* # Key concepts Source: https://docs.exode.biz/en/exode-api/concepts The Exode data model: how the seller, school, users, courses, products, access and payments relate to each other Before you integrate, it helps to understand how the Exode data model is structured and how its entities relate to each other. ## Relationship diagram ```mermaid theme={null} flowchart TD Seller[Seller] --> School[School] School --> User[User] School --> Group[Group] Seller --> Product[Product] Product --> Course[Course] Course --> Lesson[Lesson / CourseLesson] Lesson --> Practice[Practice / CourseLessonPractice] Practice --> Attempt[Attempt] Group --> GroupMember[Member / GroupMember] User --> GroupMember Product --> Access[Access / ProductAccess] User --> Access User --> Invoice[Invoice] Invoice --> Payment[Payment] Invoice --> Product ``` ## Entities The business owner in Exode: a tutor, a school, a producer or a university. A seller has a balance, bank details and a school — one per seller. The SaaS API works only with sellers that have a school. API requests are always made in the context of a seller (the `Seller-Id` header holds its numeric ID, see ["How to get the token and IDs"](/en/exode-api/setup#how-to-get-the-token-and-ids)). See [`seller`](/en/exode-api/objects/entities/school#seller). The seller's learning platform with its own domain, users, courses and settings. The school context is set by the `School-Id` header; it must match the seller's school from `Seller-Id`. See [`school`](/en/exode-api/objects/entities/school). The **school segment** (`segment`) determines the available features: `Commerce` — a commercial online school (sales, invoices, payments), `Corporate` — corporate employee training (the `staff/*` org structure methods work only in such schools). An account in the school (a student, curator, parent, as well as service integration users). The `extId` field links the user to a record in your CRM/LMS: it is your own identifier — a string of up to 50 characters without `/` or whitespace, unique within the school. See [`user`](/en/exode-api/objects/entities/user). A technical school user on whose behalf the integration works. It is created together with an API key on the **Manage → School → For developers → API keys** page; its token is passed in `Authorization`, and its set of permissions is configured with checkboxes on the key page (see ["Access permissions"](/en/exode-api/permissions)). A group brings users together around a course/product and defines access rules and a schedule. `GroupMember` is the link between a user and a group. See [`group`](/en/exode-api/objects/entities/group). A unit of sale: a course, access to the school or a digital good. A product has prices (`productPrice`) and discounts (`discount`). See [`product`](/en/exode-api/objects/entities/product). The product's learning course: lessons (`courseLesson`), practices (`courseLessonPractice`), attempts and each student's progress (`courseProgress`). See [`course`](/en/exode-api/objects/entities/course). A user's access to a product: activity, expiration date, billing (subscription/installments). It is access that opens a course to a student. See [`product`](/en/exode-api/objects/entities/product). An invoice records a user's purchase of products; a payment records that the invoice was paid through acquiring. See [`payment`](/en/exode-api/objects/entities/payment). The seller's form layouts (questionnaires, custom fields at registration) and the field values filled in by users. See [`form`](/en/exode-api/objects/entities/form). ## How this relates to the API * Every request is made in the context of a **seller** (`Seller-Id`) and a **school** (`School-Id`). * The entities in method responses correspond to the [object reference](/en/exode-api/objects/entities/index). * You can receive changes to these entities (registration, payment, progress, access grants) through [webhooks](/en/exode-api/webhooks/about). Ready for your first request? Go to the [quickstart](/en/exode-api/quickstart). *** *Updated: 2026-09-25 14:33 UTC* # Certificate Source: https://docs.exode.biz/en/exode-api/objects/entities/certificate Structure of the certificate object in the Exode API The `certificate` object describes a personal certificate issued to a user for completing a course. It is returned by the [`certificate/list`](/en/exode-api/school/certificate/list) method and arrives in the [`CertificateIssued`](/en/exode-api/webhooks/about#supported-events) webhook. A certificate is issued automatically when a course is completed, if issuing is enabled in the course settings and a template is selected. Certificates are not reissued: each "user + course" pair has at most one certificate. ## `certificate` fields ### Main Certificate ID. The certificate's public identifier (12 characters, Latin letters excluding the visually similar `I`, `O`, `l`, `o`). Used in the public link and for authenticity verification. Public link to the certificate in the form `https://verify.exode.biz/certificate/{uuid}`. The page opens without authentication, and you can print it to PDF from there. ID of the recipient user. ID of the course the certificate was issued for. ID of the design template. ### Validity Issue date (ISO 8601). Expiration date (ISO 8601), or `null` if the certificate does not expire. ### Data snapshot A snapshot of the data at the time of issue: the recipient's full name, course name, number of lessons, issuer name and design. It is stored separately, so renaming the course or profile later does not change an already issued certificate. ### System audit fields Record creation date (ISO 8601). Update date (ISO 8601). Archive date or `null`. ## Example `certificate` object ```json theme={null} { "id": 10001, "uuid": "AbCdEfGhJkLm", "link": "https://verify.exode.biz/certificate/AbCdEfGhJkLm", "userId": 1001, "courseId": 2001, "templateId": 1, "issuedAt": "2026-01-01T00:00:00.000Z", "expireAt": null, "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:00:00.000Z", "archivedAt": null, "snapshot": { "courseName": "Programming Fundamentals", "recipientName": "John Smith", "lessonsCount": 24, "issuerName": "Exode School", "curatorName": "Mary Johnson", "curatorRole": "Course curator" } } ``` ## Related objects * `userId` references the [`User`](./user) object. * `courseId` references the [`Course`](./course) object. *** *Updated: 2026-09-25 14:33 UTC* # Course and learning Source: https://docs.exode.biz/en/exode-api/objects/entities/course Structures of the course, progress, lesson and practice objects, based on the shared zod schemas This page covers the public structures of learning-related objects: `course`, `courseProgress`, `courseLesson`, `courseLessonPractice`, `courseLessonPracticeAttempt`. They are returned by the course methods and in webhooks (`CourseCompleted`, `CourseProgressChanged`, `CourseLessonPracticeCompleted`). The [`course/list/raw`](/en/exode-api/school/course/list) method returns a compact projection (`courseId`, `productId`, `name`, `type`, `groupIds`). The full public entity schemas are below. ## `course` Returned by [`course/create`](/en/exode-api/school/course/create), [`course/:courseId/update`](/en/exode-api/school/course/update), [`course/:courseId/get`](/en/exode-api/school/course/get) and in course webhooks. Course ID. Type: `Bundle`, `Webinar`, `TextCourse`, `Assessment`, `VideoCourse`, `PersonalLesson`. ID of the [product](/en/exode-api/objects/entities/product) through which the course is sold and access to it is granted. Content category ID. Reserved: not filled in the response in the current version. Course build state: `Ready` — the course is ready (the default); `AiGenerating` — the course is still being assembled by the AI course wizard; such a course is hidden from everyone except its author. Name. Description. Text alias. Tags. SEO tags. Images: `{ main }` — URL of the main course image. Promo video. Course settings (arbitrary object). Sort order. Whether the course is a bundle. Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## `courseProgress` Returned by the [`course/:courseId/progresses`](/en/exode-api/school/course/progresses) method. A record describes a user's progress in a specific lesson. Progress record ID. Course ID. User ID. Lesson ID. Lesson status. The completion stages that are stored in the progress record (they also arrive in the `CourseProgressChanged` webhook): * `NotStarted` — the student has access but has not opened the lesson yet; * `OnTheory` — the student is studying the theory; * `OnPractice` — the student has opened the practice part; * `OnReview` — the assignment has been sent to the curator and is awaiting review; * `OnCorrection` — the curator has returned the assignment for revision; * `Completed` — the lesson is fully completed. The other values describe lesson availability and are computed on the fly: `NotInitialized` (no progress record yet), `NotParticipant`, `AvailableAsDemo`, `ExpiredByGroup`, `DisabledByGroup`, `AllowedByProgress`, `BlockedByProgress`, `StartScheduled`, `StartScheduledWaitPrev`, `BundleStartBlocked`, `BundleStartPendingConfirmation`. Scheduled start (ISO 8601). Scheduled end (ISO 8601). Practice deadline (ISO 8601). The lesson is completed. Under review. Completion date (ISO 8601). Date sent for review (ISO 8601). Status history: `{ timestamp, status }`. Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## `courseLesson` Lesson ID. Course ID. Type: `Regular`, `Webinar`. Access type: `Demo`, `Participant`. Lesson status. Name. Description. Preview image. Order. Has theory content. Has a practice part. Publication date (ISO 8601). Lesson settings. Published. Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## `courseLessonPractice` Practice ID. Name. Description. Question mode: `List`, `Sequence`. Result mode: `ResultOnly`, `ShowCorrect`, `ShowIncorrect`. Variant mode: `Random`, `Sequence`. Variant mode on retry: `Random`, `Sequence`. Maximum number of attempts. Time limit (minutes). Deadline (days). Pass threshold. Stars per task point. Answers to all tasks are required. Number of tasks. Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## `courseLessonPracticeAttempt` Returned when exporting [practice attempts](/en/exode-api/school/query-export/practice-attempt-find-many) and in the `CourseLessonPracticeCompleted` webhook. Attempt ID. Attempt UUID. Variant ID. User ID. Status: `Created`, `OnReview`, `OnCorrection`, `AutoVerified`, `Verified`, `Failed`, `Stacked`. Attempt number. The attempt is finished. Sent for review (ISO 8601). Sent after the deadline. Deadline (ISO 8601). Pass date (ISO 8601). Number of solved tasks. Points scored. Maximum points. The attempt is not counted. Passed. Percentage of correct answers. Overdue. Status history: `{ timestamp, status }`. Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. *** *Updated: 2026-09-28 05:04 UTC* # Forms and fields Source: https://docs.exode.biz/en/exode-api/objects/entities/form Object structures of a form layout and a custom field value, based on shared zod schemas Public form structures: `formLayout` (layout) and `formFieldValue` (field value). Returned by the [form](/en/exode-api/school/form-layout/create) and [custom field](/en/exode-api/school/custom-field/get) methods. ## `formLayout` Layout ID. Layout UUID. Layout slug. Name. Internal name. Note. Mode: `Form`, `Signup`, `Custom`, `Welcome`, `Participant`. Status: `Draft`, `Published`. Layout configuration (for example, `resubmitMode`). ID of the owner seller. Whether it was edited manually. Creation date (ISO 8601). Update date (ISO 8601). Archive date or `null`. ## `formFieldValue` Value ID. User ID. Field ID. Form submission ID. Normalized field value (universal). Text value (`Text`, `Select`, `Radio`, `Textarea`). Numeric value (`Number`). Boolean value (`Boolean`, `Switch`, `Checkbox`). Date (`Date`, ISO 8601). JSON value (`Json`, `File`, `Multiselect`). Field description. Field ID. Field slug. Field type: `Text`, `File`, `Json`, `Date`, `Radio`, `Switch`, `Number`, `Select`, `Boolean`, `Textarea`, `Checkbox`, `Multiselect`. Field order. Layout ID. Field properties. Display settings. Field access permissions: `read` and `write`, each is `{ api, user, manager }` (boolean). Fields with `read.api = false` are excluded from API output; fields with `write.api = false` cannot be written. Creation date (ISO 8601). Update date (ISO 8601). *** *Updated: 2026-09-25 14:33 UTC* # Group Source: https://docs.exode.biz/en/exode-api/objects/entities/group Structure of the group object in the Exode API The `group` object describes a learning or product group: its members, access rules, schedule and communication settings. The [`group/list/raw`](/en/exode-api/school/group/list) list method returns a compact projection (`groupId`, `name`, `courseId`, `courseName`, `membersCount`). The full public structure of the `group` object is described below. ## `group` fields ### Basic Group ID. Group UUID. Group name. Group space: `Education`. Sort order. Maximum number of members. ### Limitations and settings Communication settings (Telegram channel, group chat, connection modes). Access limitations: start type, common start date, duration, etc. Schedule limitations (access windows by day/hour, date restrictions). Content limitations (lesson access rules, dependencies on assignments). ### Telegram Whether the group has an active Telegram connection. Telegram connection mode: `Disconnected`: no Telegram channel or chat is linked to the group; `Connected`: a channel or chat is linked; `Required`: linked, and the user must have Telegram to enroll in the group. ### System audit fields Creation date (ISO 8601). Update date (ISO 8601). Archive date or `null`. ## `group` object example ```json theme={null} { "id": 101, "uuid": "8b8b2b0e-6a27-4c3c-b5a5-9f4f0e3f1c01", "name": "Group 7A — Math", "space": "Education", "order": 10, "maxMembers": 30, "isTgConnected": false, "tgConnectionMode": "Disconnected", "createdAt": "2025-01-10T09:00:00Z", "updatedAt": "2025-01-15T12:30:00Z", "archivedAt": null, "communication": { "tgChannelId": null, "tgGroupChatId": null }, "accessLimitation": { "active": true, "startPointType": "CommonDate" }, "scheduleLimitation": { "active": false }, "contentLimitation": { "active": false } } ``` ## Related objects * Group members: [`groupMember`](./group-member) objects. *** *Updated: 2026-09-25 14:33 UTC* # Group member Source: https://docs.exode.biz/en/exode-api/objects/entities/group-member Structure of the group member object (groupMember) in the Exode API The `groupMember` object describes a user's link to a group: membership status, blocks and Telegram integration metadata. It is returned, for example, by [`group/:groupId/member/create-many`](/en/exode-api/school/group-member/create-many). ## `groupMember` fields ### Main Member record ID. Group ID. ID of the member user. ID of the inviting user. Whether the member is active in the group. Block end date (ISO 8601) or `null`. The [user](./user) object (without profile). May be absent depending on the method. ### Telegram Whether the member has been added to at least one Telegram chat/channel. Metadata of adding to the Telegram channel (for example, `chatInviteLink`). Metadata of adding to the Telegram group chat (for example, `chatInviteLink`). ### System audit fields Record creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## Example `groupMember` object ```json theme={null} { "id": 1227, "groupId": 101, "userId": 15, "inviterId": null, "active": true, "blockedUntil": null, "isAddedToTg": false, "createdAt": "2025-07-19T15:45:51.212Z", "updatedAt": "2025-07-19T15:45:51.212Z", "archivedAt": null, "tgChannelMeta": { "chatInviteLink": null }, "tgGroupChatMeta": { "chatInviteLink": null }, "user": { "id": 15, "uuid": "YRnh3REH1Wbd", "status": "Active", "active": true, "activated": true, "banned": false, "email": "user@example.com", "phone": "+9876543210" } } ``` ## Related objects * `user` — the [`User`](./user) object. * `groupId` references the [`Group`](./group) object. *** *Updated: 2026-09-25 14:33 UTC* # API objects Source: https://docs.exode.biz/en/exode-api/objects/entities/index Reference of Exode API entities — structures based on shared zod schemas This page collects the public structures of objects returned by the Exode API and webhooks. The set of fields strictly matches the server-side zod schemas of the public contracts: internal and private fields are not returned. List methods (`.../list/raw`) often return a **compact projection** of an object. The full structure of each entity is described on its page below. ## Users and access `user` and `profile`: logins, contacts, statuses, `extId`, permissions. `session`: authorization token, device, online status. `group`: learning/product groups, restrictions, Telegram. `groupMember`: the link between a user and a group, blocks. ## Learning and sales `course`, `courseProgress`, `courseLesson`, `courseLessonPractice`, `courseLessonPracticeAttempt`. `product`, `productAccess`, `productPrice`, `discount`. `payment`, `invoice`, `invoiceProduct`, `acquiring`. `school`, `seller`, `organization`. `certificate`: a personalized course completion certificate and its verification. ## Forms `formLayout` and `formFieldValue`: form layouts and custom field values. ## General field conventions * **Dates** (`createdAt`, `expireAt`, …) are ISO 8601 strings (UTC). Fields with the `At` suffix and a `null` value mean that the event has not occurred. * **Money fields** (`totalAmount`, `amount`, `balance`, …) are numbers. * **Audit fields** are present on most entities: `id`, `createdAt`, `updatedAt`, `archivedAt`. * **`*Id` fields** reference another entity (for example, `courseId`, `userId`). * **Optional fields** (type `… | null`, not marked required) may come with a `null` value or be absent entirely — handle both cases. * **Enum values** are PascalCase strings (`Active`, `OnReview`); compare them case-sensitively. *** *Updated: 2026-09-25 14:33 UTC* # Payment and invoice Source: https://docs.exode.biz/en/exode-api/objects/entities/payment Structures of the payment, invoice, invoice item and acquiring objects based on shared zod schemas Public structures of financial objects: `payment`, `invoice`, `invoiceProduct`, `acquiring`. The full `payment` tree arrives in the [`PaymentCompleted`](/en/exode-api/webhooks/about) webhook. The [`invoice/list/raw`](/en/exode-api/school/invoice/list) method returns a compact projection of the invoice (`invoiceId`, `invoiceUuid`, `type`, `status`, amounts, `currency`, `user`, `products`). The full public schemas are below. Monetary fields are numbers (decimals from the database are cast to number). ## `payment` Payment ID. Payment UUID. Type: `OneTime`, `RecurrentPay`, `RecurrentInit`. Status: `Created`, `WaitingPay`, `WaitingForBinding`, `Processing`, `Completed`, `BindingCompleted`, `Canceled`. The purchase has been delivered to the user: after payment, Exode processed the invoice items — granted or extended access to the products. Payment ID in the payment system. Payment URL. Payment date (ISO 8601). Expiration date (ISO 8601). The payment is completed. The payment is canceled. Internal metadata. Payment system webhook logs. Charge logs. Status history: `{ timestamp, status }`. Acquiring (see below). Invoice (see below). Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## `invoice` Invoice ID. Invoice UUID. Human-readable invoice number. Type: `Regular`, `InstallmentPay`, `InstallmentInit`, `SubscriptionPay`, `SubscriptionInit`. Status: `Active`, `Canceled`. Total amount. Discount amount. Currency: `Free`, `Exes`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`. Expiration date (ISO 8601). The invoice is active. Internal invoice metadata. The buyer — a [user with profile](./user) object; may additionally contain `school`. Invoice items (see `invoiceProduct`). Creation date (ISO 8601). Update date (ISO 8601). ## `invoiceProduct` Item ID. Original price. Total price. Discount amount for the item. Product price — a [`productPrice`](./product) object. Discount — a [`discount`](./product) object. Product — a [`product`](./product) object; may additionally contain `course`. Creation date (ISO 8601). Update date (ISO 8601). ## `acquiring` Acquiring ID. Acquiring UUID. Active. Name. Description. Whether the provider charges a commission. Provider: `{ id, type, name, active }`. Provider secrets (apiKey, configuration) and the payment method/card number are not returned in the public schema. *** *Updated: 2026-09-25 14:33 UTC* # Product and accesses Source: https://docs.exode.biz/en/exode-api/objects/entities/product Structures of the product, access, price and discount objects based on the shared zod schemas Public structures of the sales objects: `product`, `productAccess`, `productPrice`, `discount`. They are used in nested fields of invoices/payments and in webhooks (`ProductEnrolledToFree`, `PaymentCompleted`). The [`product-access/list/raw`](/en/exode-api/school/product-access/list) method returns a compact projection of an access (`accessId`, `productId`, `courseId`, `active`, `expireAt`, `user`). The full public schemas are below. ## `product` Product ID. Seller ID. Type: `Course`, `School`, `Digital`. Status: `Draft`, `OnCheck`, `Declined`, `ReadyToPublish`, `Published`. Currency: `Free`, `Exes`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`. Name. Shown in the catalog. Approvals: `Certified`, `Recommended`. Domains: `Ru`, `Uz`, `Kz`, `Biz`, `Global`. Publication date (ISO 8601). Sales start (ISO 8601). Sales end (ISO 8601). Free. Published. Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## `productAccess` Access ID. Product ID. Parent access ID (for bundles). The access is active. Deactivation date (ISO 8601). Expiration date (ISO 8601). Whether billing for the access is active. Enrollment attribution: `utmParams` — an object with the UTM tags of the visit from which the user enrolled (see [UTM attribution](/en/exode-api/webhooks/about#utm-attribution)); `inviteLinkId` — the invite link ID if the user enrolled via one (`integer | null`). Creation date (ISO 8601). Update date (ISO 8601). ## `productPrice` Price ID. Mode: `AccordingToGroup`, `SelfDefinition`. Type: `Demo`, `OneTime`, `Installment`, `Subscription`, `ExternalLink`. Title. Description. Amount. Previous amount (for displaying the discount). Access duration in days. Unlimited access. The price is active. The price is hidden. Active from (ISO 8601). Active until (ISO 8601). Internal price metadata (arbitrary object). Installment configuration. Subscription configuration. Demo price. Recurring. Installment. Subscription. Creation date (ISO 8601). Update date (ISO 8601). ## `discount` Discount ID. Promo code. Type: `Amount` (fixed amount), `Percent` (percentage). Discount value. Currency: `Free`, `Exes`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`. The discount is active. Active from (ISO 8601). Active until (ISO 8601). Discount note. *** *Updated: 2026-09-25 14:33 UTC* # School and seller Source: https://docs.exode.biz/en/exode-api/objects/entities/school Object structures of the school, seller and organization based on shared zod schemas Public structures: `school`, `seller`, `organization`. The nested `school` object comes, for example, in `invoice.user.school` of the `PaymentCompleted` webhook. The full `school` tree with `seller` is sent only in the system `SchoolCreated` webhook — you cannot subscribe to it from the school's admin panel. A seller (`seller`) has exactly one school; their numeric IDs are the values of the `Seller-Id` and `School-Id` headers. ## `school` School ID. School name. Description. Segment: `Commerce` — a commercial online school, `Corporate` — corporate employee training (the `staff/*` org structure methods work only in such schools). Access type: `Public` — the school's catalog and pages are open to everyone; `Private` — a closed school, the content is available only to authenticated users of this school. Domain type: `Base` — the school runs on an Exode subdomain (`baseDomain`), `Custom` — on its own domain (`customDomain`). Base domain (Exode subdomain). Custom domain. Current active domain. Fully qualified domain name. Fully qualified name on the base domain. Public URL of the school. Icon URL. The school is active. Public. Private. Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. ## `seller` Seller ID. Type: `Tutor`, `School`, `Producer`, `University`. Active. Verified. Balance. Balance available for payout. Base currency: `Free`, `Exes`, `Rub`, `Uzs`, `Kzt`, `Usd`, `Eur`. Whether it is a school. Organization details (see below). Creation date (ISO 8601). Update date (ISO 8601). ## `organization` Organization ID. Organization form. Name. Legal name. Name of the self-employed person. INN (taxpayer identification number). OGRN (primary state registration number). Logo. Address. Whether it is an organization. Creation date (ISO 8601). Update date (ISO 8601). *** *Updated: 2026-09-25 14:33 UTC* # Session Source: https://docs.exode.biz/en/exode-api/objects/entities/session Structure of the session object returned when an authorization token is issued The `session` object describes a user session. It is returned by the [`user/session/auth-token`](/en/exode-api/school/user/session/auth-token) method. Private fields (IP, geolocation, user agent, FCM token) are not returned in the public schema. ## `session` fields Session ID. Session UUID. ID of the session's user. Device UUID. Session token used to authorize the user. The session is active (not ended). The user is currently online in this session. Session launch source. Current location in the app. Location parameters (arbitrary object). App version. Language: `Ru`, `Uz`, `En`, `Qa`. Time zone (offset from UTC in hours). Last activity (ISO 8601). Session expiration date (ISO 8601). Creation date (ISO 8601). Update date (ISO 8601). Archiving date or `null`. *** *Updated: 2026-09-25 14:33 UTC* # User Source: https://docs.exode.biz/en/exode-api/objects/entities/user Structure of the user object (user) and its profile (profile) returned by the Exode API The `user` object describes a user account in an Exode school. It is returned by the `user/create`, `user/update`, `user/upsert` and `user/find` methods, in nested fields of other entities (invoices, accesses, webhooks) and, together with the `profile` object, in `userWithProfile`. The set of fields strictly matches the public response schema: internal/private fields (internal metadata, tokens) are not returned. ## `user` fields ### Identifiers and logins User ID in Exode. User UUID. Domain login. Along with `email` and `phone`, it is used as a sign-in field for the school. By default it is generated in the `id12345` format; you can set your own login (Latin letters, digits, `_` and dots not at the edges, up to 65 characters) in `user/create`/`update`/`upsert`. User email. Phone number in international format. Telegram ID. VK ID (when signing in via VK). Apple ID (when signing in via Apple). External ID from your system (CRM/LMS). Set in `user/create`/`update`/`upsert` and used for lookup in `user/find`: it links the Exode user to a record in your database. It is not a login: you cannot sign in with `extId`. ID of the user's school. ### Statuses Account status, the **single source of truth** for the account lifecycle: `Active`, `OnLeave`, `Banned`, `Blocked`, `Terminated`, `Deleted`. * `Active`: regular access; * `OnLeave`: "on leave" (informational, does not block access; synchronized automatically with the employee's absences, including hourly recalculation by the calendar); * `Banned`: banned by an administrator/moderator; when the ban is lifted, the actual status is recalculated automatically (`Terminated` / `OnLeave` / `Active`, based on employments and absences); * `Blocked`: blocked by an administrator; * `Terminated`: terminated (set automatically on termination of the last active employment; rehiring returns `Active`); * `Deleted`: account deleted (credentials are wiped, data is kept for reports); set by the system on deletion and cannot be passed manually. `Banned`, `Blocked`, `Terminated` and `Deleted` close access: sign-in is denied, sessions are terminated, product enrollments are blocked. The user has confirmed sign-in (via a code/payment). Derived from `status`: `true` when the status is `Banned` or `Deleted`. Returned for compatibility; use `status` to manage blocking. Derived from `status`: `true` when the account is not deleted (`status` ≠ `Deleted`). Returned for compatibility; filter and manage access by `status`. "Live" access: `status` is not one of the blocking statuses (`Banned`/`Blocked`/`Terminated`/`Deleted`). ### Locale and activity Interface language: `Ru`, `Uz`, `En`, `Qa`. Time zone offset from UTC in hours (for example, `5`). Last activity (ISO 8601). The user's current local time, taking the time zone into account. Heuristic for "night time" for the user. ### Platform and gamification "Stars" balance in the gamification system. ### System audit fields Creation date (ISO 8601). Last update date (ISO 8601). Archive date (ISO 8601) or `null`. ## `profile` fields In `userWithProfile`, the user object is extended with the `profile` field (can be `null`). Profile ID. User ID. Whether the profile is official (verified). First name. Last name. Full name. Short name. User avatar: image links in different sizes. Profile ID. Thumbnail URL. Medium-size URL. Maximum-size URL. Date of birth (`YYYY-MM-DD`). Sex: `Men`: male, `Women`: female, `Ufo`: not specified (default value). Country. City. Role: `Student`, `Tutor`, `Parent`. Status (free text). Title. Emoji title. Title state (manual/location/achievements). Profile creation date (ISO 8601). Profile update date (ISO 8601). ## Example `userWithProfile` object ```json theme={null} { "id": 123, "uuid": "550e8400-e29b-41d4-a716-446655440000", "domain": "id123", "email": "user@example.com", "phone": "+9876543210", "tgId": 987654321, "vkId": null, "appleId": null, "extId": "crm_12345", "schoolId": 91, "active": true, "activated": true, "banned": false, "status": "Active", "alive": true, "language": "Ru", "timezone": 5, "lastOnlineAt": "2025-01-15T11:40:00Z", "starsBalance": 0, "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-01-15T11:45:00Z", "archivedAt": null, "profile": { "id": 456, "userId": 123, "official": false, "firstName": "John", "lastName": "Doe", "fullName": "John Doe", "sex": "Men", "role": "Student", "bdate": "1990-01-01" } } ``` *** *Updated: 2026-09-25 14:33 UTC* # API key permissions Source: https://docs.exode.biz/en/exode-api/permissions Which checkboxes to enable for an API key in the account so the SaaS API methods you need work Each API key has a set of permissions — the checkboxes on the key's page in the account. SaaS API methods check these permissions: if a required checkbox is not enabled, the API returns `401` with `cause: "Forbidden"` and the message `Forbidden seller resource - permissions `. 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*