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, employments, absences and
managers are addressed by extId (ext/{extId}/... routes), users — through
user/find, user/ext/{extId}/update and
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.1C → Exode: a ready-made sync example
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:- Department — a school’s organizational unit; the hierarchy is built through
parentId(parentExtId). - Employment — the central link: user + department + position, 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
1
Departments
Create the org structure using the methods in the Departments section. Pass
On subsequent runs, call
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).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.2
Positions
Create positions using the methods in the Positions 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).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.3
Employees
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 for details. In all cases, employments are
passed as an array in extra.staff.employments (each element is positionExtId + departmentExtId,
optionally extId, 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).4
Managers
Assign department managers with the
staff/department-manager/set 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.5
Other data — custom fields
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 and write the values
through
custom-field/value/set-by-slug after upserting the user.6
Absences
Create vacations, sick leaves and business trips with the
staff/absence/create 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.7
Terminations
On termination, call
staff/employment/terminate
(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 — all active sessions are ended; see
User status lifecycle.If your system returns the full list of employees (rather than changes), get the current active
employments through staff/employment/list (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
Onlycreate 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),tgIdorextIdalready 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(orPUT /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.
- 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 throughupdate. The typical pattern:updatebyextId→ onNotFound→create(everything is addressed byextId, no internal Exode IDs needed). This won’t work withupsert: if the user already exists,upsertgoes to the update branch and the password you set won’t be applied.
Employments assignment behavior
Theemployments 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.
extra, but through separate endpoints
in the Employments section:
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
Theuser.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 toTerminated: sign-in is denied and all sessions are ended. Rehiring (hire) an employee with theTerminatedstatus automatically restoresActive. 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. Activedoes 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 — theActivestatus is restored automatically.OnLeaveis managed by the absences module: while an absence is in effect (vacation/sick leave), the status automatically becomesOnLeave, and when it ends — returns toActive.OnLeaveis 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 (passActive), the actual status is recalculated automatically based on employments and absences: a terminated user returns toTerminated, a user on vacation — toOnLeave, the rest — toActive.
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
Auser/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):
- Middle name, city, tags, arbitrary CRM status, additional email → custom fields
(
custom-field/value/set-by-slugafter creating/upserting the user). - Managers (
leader_external_ids) → a separate call tostaff/department-manager/set(departmentExtId+employmentExtId). - Roles/tags with no counterpart — are ignored.
Limitations to know in advance
Minimal example: an employee with an employment
cURL
cURL
Updated: 2026-09-25 14:33 UTC