> ## Documentation Index
> Fetch the complete documentation index at: https://docs.exode.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Upsert a user

> Create a new user or update an existing one in a school, including profile data

<Tip>
  **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.
</Tip>

## Request headers

<ParamField header="Authorization" type="string" required>
  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.
</ParamField>

<ParamField header="Seller-Id" type="integer" required>
  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.
</ParamField>

<ParamField header="School-Id" type="integer" required>
  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.
</ParamField>

```
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.

<Info>
  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 cannot set your own password via upsert:
  the `password` field is supported only in `user/create`.
</Info>

<Warning>
  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**.
</Warning>

<Note>
  **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.
</Note>

<ParamField body="email" type="string" required={false}>
  User's email address. Must be a valid email. An empty string is converted to `null`.
</ParamField>

<ParamField body="phone" type="string" required={false}>
  User's phone number. Must be in international format (for example, +9876543210). An empty string
  is converted to `null`.
</ParamField>

<ParamField body="domain" type="string" required={false}>
  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`.
</ParamField>

<ParamField body="tgId" type="integer" required={false}>
  User's Telegram ID. An integer or `null`. Not a login.
</ParamField>

<ParamField body="extId" type="string" required={false}>
  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.
</ParamField>

### Additional parameters

<ParamField body="status" type="enum" required={false}>
  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.
</ParamField>

<Info>
  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`.
</Info>

<Warning>
  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.
</Warning>

### Profile parameters

<ParamField body="profile" type="object" required={false}>
  Object with the user's profile data.

  <Expandable title="Profile properties">
    <ParamField body="firstName" type="string" required={false}>
      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.
    </ParamField>

    <ParamField body="lastName" type="string" required={false}>
      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.
    </ParamField>

    <ParamField body="bdate" type="string" required={false}>
      Date of birth in YYYY-MM-DD format.
    </ParamField>

    <ParamField body="sex" type="enum" required={false}>
      User's sex. Possible values: `Ufo`, `Women`, `Men`.
    </ParamField>

    <ParamField body="role" type="enum" required={false}>
      User's role. Possible values: `Student`, `Tutor`, `Parent`.
    </ParamField>
  </Expandable>
</ParamField>

<Info>
  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.
</Info>

### Additional aggregates (`extra`)

<ParamField body="extra" type="object" required={false}>
  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`.
</ParamField>

<Info>
  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).
</Info>

<RequestExample>
  ```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}
  <?php

  $url = 'https://api.exode.biz/saas/v2/user/upsert';
  $data = [
    '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'
  ];

  $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 " + Ответ.КодСостояния);
      Сообщить(Ответ.ПолучитьТелоКакСтроку());
  КонецЕсли;
  ```
</RequestExample>

<ResponseExample>
  ```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"
  }
  ```
</ResponseExample>

## Permission requirements

<Check>
  Creating or updating a user requires the **"School User Management"** permission
  (`SchoolManageUsers`).
</Check>

<Warning>
  The service user must be authenticated with a token and have the appropriate access rights to the specified
  school.
</Warning>

<Info>
  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.
</Info>

***

*Updated: 2026-09-25 14:33 UTC*


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.