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

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

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

## 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

<ResponseField name="sellerId" type="number" required>
  Seller ID. Sent in the `Seller-Id` header.
</ResponseField>

<ResponseField name="schoolId" type="number" required>
  School ID. Sent in the `School-Id` header.
</ResponseField>

<ResponseField name="token" type="string" required>
  API token of the service user. Sent in `Authorization: Bearer <token>`.
</ResponseField>

<ResponseField name="baseUrl" type="string">
  Base API URL. Defaults to `https://api.exode.biz/saas/v2`.
</ResponseField>

<ResponseField name="timeout" type="number">
  Request timeout in milliseconds. Defaults to `30000`. When exceeded, an `ExodeAPIError` with `cause: "Timeout"` (code 408) is thrown.
</ResponseField>

<Warning>
  Keep the token in environment variables. Never commit tokens or pass them to the client side: `ExodeAPI`
  works on the server only.
</Warning>

## Available resources

The client exposes a `school` namespace with nine resources:

<CardGroup cols={2}>
  <Card title="school.user" icon="user">User CRUD, search, state, deletion, auth tokens.</Card>
  <Card title="school.staff" icon="sitemap">Org structure (HR): departments, positions, employments, managers, absences.</Card>
  <Card title="school.group" icon="users">Lists of groups and members, bulk adding/removing members.</Card>
  <Card title="school.course" icon="graduation-cap">List of courses and members' course progress.</Card>
  <Card title="school.certificate" icon="award">List of issued certificates.</Card>
  <Card title="school.productAccess" icon="box">List of product accesses.</Card>
  <Card title="school.invoice" icon="receipt">List of invoices.</Card>
  <Card title="school.form" icon="list-check">Form layouts and custom field values.</Card>
  <Card title="school.queryExport" icon="file-export">Generating exports and polling their results.</Card>
</CardGroup>

## 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=<token> for auto-login
```

<Tip>
  For more on auto-login via `___uat`, see
  [Telegram Mini App integration](/en/customization/tg-mini-app).
</Tip>

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

## 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 |

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

## 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 |

<Info>
  The full list of domain `cause` codes is in [Working with the API](/en/exode-api/setup#error-format).
</Info>

***

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


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