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

# Roster sync

> Keep TeamBuilder's employees, departments and job titles in step with your HR system, and deactivate leavers safely.

Roster sync lets your HR system be the source of truth. Once a day, your job sends the full employee list to [`PUT /v1/employees/sync`](/api-reference/roster-sync/sync-your-employee-roster); TeamBuilder adds new joiners, updates departments and job titles, links existing accounts to your HR ids and, if you opt in, deactivates people who have left.

It is a **push** model: you call TeamBuilder on your schedule. TeamBuilder never pulls from your systems.

<Info>
  Sync needs a key with the `employees:write` scope, which is never granted by default. Ask [contact@teambuilder.ge](mailto:contact@teambuilder.ge) for it, ideally on a key used only by your sync job.
</Info>

## How a sync works

<Steps>
  <Step title="Validate">
    Each row is checked on its own. A malformed row is reported in `errors` and skipped; the rest still apply.
  </Step>

  <Step title="Match">
    Each row is matched to an existing employee by `external_id`, then by `email`. Unmatched rows become new employees.
  </Step>

  <Step title="Resolve departments">
    Departments are matched by your id, then by name; missing ones are created, and archived ones restored.
  </Step>

  <Step title="Apply">
    New employees are added and changed fields updated. Rows that already match are counted as `unchanged`.
  </Step>

  <Step title="Deactivate (opt-in)">
    With `deactivate_missing: true`, employees missing from the roster are deactivated, behind several safety guards.
  </Step>
</Steps>

## Request

```http theme={null}
PUT /v1/employees/sync
Authorization: Bearer tb_live_...
Content-Type: application/json
```

```json theme={null}
{
  "employees": [
    {
      "external_id": "EMP-00042",
      "email": "nino.beridze@example.com",
      "first_name": "Nino",
      "last_name": "Beridze",
      "department_external_id": "D-ENG",
      "department_name": "Engineering",
      "job_title": "Front-End Developer"
    }
  ],
  "deactivate_missing": false
}
```

| Field | Required | Notes |
| - | - | - |
| `employees` | Yes | 1 to 5,000 rows. |
| `employees[].external_id` | Yes | Your HR system's stable key for the person, up to 255 characters. Integers are accepted and read as strings. |
| `employees[].email` | Yes | Work email, case-insensitive, up to 256 characters. |
| `employees[].first_name` | Yes | Up to 50 characters. |
| `employees[].last_name` | Yes | Up to 50 characters; may be empty for single-name employees. |
| `employees[].department_external_id` | No | Your HR system's key for the department, up to 255 characters. |
| `employees[].department_name` | No | Up to 150 characters. Send it with `department_external_id` so an unknown department can be created. |
| `employees[].job_title` | No | Up to 150 characters, for example "Front-End Developer". |
| `deactivate_missing` | No | Default `false`. See [Deactivating leavers](#deactivating-leavers). |
| `max_deactivations` | No | 1 to 100,000. Confirms a deliberately large deactivation. |

Send only the fields above. A row with any other key fails with `validation_failed` (for example `property phone should not exist`), and an unknown top-level key fails the whole request with `400`.

**Omitted, `null` and values mean different things for optional fields:**

| You send | `job_title` | Department fields |
| - | - | - |
| Field omitted | Left unchanged | Left unchanged (when both are omitted) |
| `null` or `""` | Cleared | Employee unassigned, but only when neither department field carries a value |
| A value | Set | Employee moved to that department |

Send the body as raw UTF-8 JSON with a `Content-Length` header; most HTTP clients do both by default. Raw UTF-8 matters for non-Latin names: a JSON encoder that escapes them as `\uXXXX` makes the body up to twice as large. A body sent with chunked transfer encoding and no `Content-Length` returns `411 length_required`.

## Matching employees

Each row is matched in this order:

1. **By `external_id`.** The employee already linked to this HR id.
2. **By `email`.** An employee of your company who has no `external_id` yet, for example someone who joined through SSO before you started syncing. The row links them to your HR id from now on.
3. **New employee.** Nobody matched, so the person is added to your company. If a TeamBuilder account already exists for that email, it is reused; otherwise one is created. **No invitation email is sent**: people join by signing in as usual.

<Note>
  A sync never changes the name or email of an existing account: those belong to the person. Until a new employee signs in for the first time, the API shows the name you synced; after that, their account name.
</Note>

If no employee is linked to the row's `external_id` and its email belongs to an employee linked to a different `external_id`, the row is rejected with `external_id_mismatch`, so a recycled email never takes over another person's record. A row whose `external_id` already matches an employee is applied to that employee whatever its email; a sync never changes an existing email.

## Departments

When a row names a department, TeamBuilder resolves it like this:

| Row sends | And TeamBuilder finds | Result |
| - | - | - |
| `department_external_id` | An active department with that id | Assigned |
| `department_external_id` | An archived department with that id | Restored and assigned (`departments_restored`) |
| `department_external_id` + `department_name` | No department with that id, but an active one with that name and no id | That department is linked to your id and assigned |
| `department_external_id` + `department_name` | Nothing | Created at the top level with your id (`departments_created`) |
| `department_external_id` only | Nothing | Row rejected: `unknown_department` |
| `department_name` only | An active department with that name (case-insensitive) | Assigned |
| `department_name` only | Nothing | Created at the top level (`departments_created`) |

A sync **never renames** an existing department or changes its parent; the one exception is a restored department whose parent is still archived, which is placed at the top level. Build the structure (parent and child departments) in the TeamBuilder admin portal; a sync then fills it. If a name or id already belongs to a different department, the row is rejected with `department_conflict`.

<Tip>
  Always send both `department_external_id` and `department_name`. The id keeps the link stable through renames in your HR system; the name lets TeamBuilder create the department the first time it sees it.
</Tip>

## Deactivating leavers

By default a sync **only reports** who is missing. Every response lists `deactivation_candidates`: active employees linked to an `external_id` that this roster does not name. An employee named by a row that was rejected still counts as on the roster, so a row error never turns that person into a candidate.

Send `"deactivate_missing": true` to deactivate them in the same run. Do this only with your **complete roster in one request**.

### What deactivation means

* Deactivation is **soft**. The account and all of the person's coins, XP, badges and history are kept; nothing is deleted.
* A deactivated employee disappears from every API response and leaderboard.
* If a later sync names them again with the same `external_id`, they are **reactivated** with everything intact and counted in `reactivated`.
* Signing in with SSO does not reactivate someone a sync deactivated: your roster stays the source of truth.

### Who is never deactivated

* **Employees without an `external_id`**, meaning people added in TeamBuilder and never synced. They are counted in `unlinked_members` instead. To bring them under sync, send a row with their email and your HR id.
* **Admins and holders of other workspace roles.** They are managed in the admin portal and are never candidates.

### Safety guards

Nobody is deactivated, and `deactivation_skipped_reason` says why, when either guard trips:

| `deactivation_skipped_reason` | Trigger | Why |
| - | - | - |
| `rejected_rows` | Any row failed with `validation_failed`, `duplicate_external_id`, `duplicate_email`, `email_domain_not_allowed` or `batch_aborted`. | The roster may be incomplete, so absence proves nothing. |
| `limit_exceeded` | More candidates than `deactivation_limit`. | A truncated export must not wipe out your company. |

The default `deactivation_limit` is the larger of **10** or **20%** of your active employees who are linked to an `external_id` (admins and other workspace-role holders excluded). For a genuine reorganisation or layoff, send `max_deactivations` with the number you expect. If the candidates exceed it, nobody is deactivated and `deactivation_skipped_reason` is `limit_exceeded`.

<Warning>
  Never send `deactivate_missing: true` with a partial roster, such as one chunk of a large roster or a single department's export. Everyone not in that request becomes a candidate.
</Warning>

### Employees deactivated in the portal

A sync reactivates only people **a sync** deactivated. If an admin deactivated someone in the portal, a row naming them is rejected with `deactivated_in_portal`; if the person is an admin or holds a workspace role, with `managed_in_portal`. Reactivate them in the admin portal.

## Response

Every run returns a summary, even when some rows failed. Each row you send is counted exactly once: `created + updated + unchanged + errors.length` always equals the number of rows.

| Field | Meaning |
| - | - |
| `created` | Employees added. |
| `updated` | Employees whose `external_id`, department or job title changed, or who were reactivated. |
| `unchanged` | Rows that already matched. A repeated identical sync reports every row here. |
| `reactivated` | Of `updated`: deactivated employees this roster named again. |
| `deactivated` | Employees deactivated by this run; `0` unless `deactivate_missing` was `true`. |
| `departments_created` | Departments created because a row named an unknown one. |
| `departments_restored` | Archived departments restored because a row named their id. |
| `deactivation_candidates` | Up to 1,000 missing employees (`employee_id`, `external_id`, `email`), ordered by `employee_id`. |
| `deactivation_candidates_total` | All candidates, including any beyond the 1,000 listed. |
| `unlinked_members` | Active employees without an `external_id` that the roster does not name. Never deactivated. |
| `deactivation_limit` | How many this run could deactivate: `max_deactivations`, or the default limit. |
| `deactivation_skipped_reason` | `rejected_rows`, `limit_exceeded`, or `null`. |
| `errors` | Rows not applied, in request order. |
| `warnings` | Rows applied that deserve a look, in request order. |

### Row errors and warnings

Each entry carries the row's zero-based `index` in your `employees` array, its `external_id` when it had a usable one, a `code` and a `message` saying what to fix. Messages never echo the values you sent.

<AccordionGroup>
  <Accordion title="Row error codes">
    | `code` | Meaning | Fix |
    | - | - | - |
    | `validation_failed` | A field is missing, too long or malformed. | Correct the row. |
    | `duplicate_external_id` | Two or more rows share this `external_id`; all of them are rejected. | De-duplicate your export. |
    | `duplicate_email` | Two or more rows share this email; all of them are rejected. | De-duplicate your export. |
    | `external_id_mismatch` | No employee is linked to this `external_id`, and the email belongs to an employee linked to a different one. | Check which HR record is correct. A person keeps one `external_id` in TeamBuilder. |
    | `email_domain_not_allowed` | Your company signs in with SSO and the email's domain is not one of the connection's domains. | Send work emails only. |
    | `unknown_department` | `department_external_id` matches nothing and no `department_name` was sent. | Send `department_name` too. |
    | `department_conflict` | The department name or id belongs to a different department. | Align the names and ids, or fix the department in the portal. |
    | `department_limit_reached` | Creating the department would exceed your company's department limit. | Archive unused departments, or contact us. |
    | `deactivated_in_portal` | An admin deactivated this employee in the portal. | Reactivate them in the portal. |
    | `managed_in_portal` | This deactivated employee is an admin or holds a workspace role. | Reactivate them in the portal. |
    | `sandbox_limit_reached` | Test keys only: your sandbox holds no more employees. | Use fewer rows in test runs. |
    | `conflict` | The employee changed while the sync ran. | Run the sync again. |
    | `batch_aborted` | Not attempted, because too many rows of this run failed to write. | Fix the other errors, then run again. |
  </Accordion>

  <Accordion title="Warning codes">
    | `code` | Meaning |
    | - | - |
    | `email_matches_other_employee` | Matched by `external_id`, but the email belongs to another employee of your company who has no `external_id`. Usually a duplicate person; tidy it up in the portal. |
    | `department_moved_to_top_level` | An archived department was restored, but its parent is still archived, so it now sits at the top level. |
  </Accordion>
</AccordionGroup>

## Idempotency and concurrency

* A sync is **idempotent**: sending the same roster twice changes nothing the second time, and every row is reported as `unchanged`. Retrying after a timeout is always safe.
* **One sync per company runs at a time.** A second request while one is running returns `409 conflict` with `Retry-After: 30`; so does a request that arrives while the service is already running its maximum of four syncs. Wait, then send the same request again.
* Each key may start **30 syncs per hour**; beyond that you get `429 rate_limit_exceeded` with `Retry-After`. Sync requests also count towards the key's per-minute [rate limit](/reference/rate-limits).

## Large rosters

A request carries at most **5,000 rows** and 16 MB. For a larger roster:

1. Split it into chunks of up to 5,000 rows.
2. Send the chunks **one after another**, never in parallel; each waits for the previous response.
3. Send every chunk with `deactivate_missing: false`.

Because no single request contains the whole roster, `deactivate_missing` cannot be used on split rosters. To find leavers, list your active employees with [`GET /v1/employees`](/api-reference/employees/list-employees) and compare their `external_id` values with your HR export, then deactivate the leavers in the admin portal, or [contact us](mailto:contact@teambuilder.ge) about rosters above 5,000 people.

## SSO companies

If your company signs in through an SSO connection, every synced email must belong to one of that connection's domains; other rows fail with `email_domain_not_allowed`. Sync and SSO work together: SSO lets people sign in, and the sync supplies their department, job title and HR id.

## Recommended schedule

<Steps>
  <Step title="Dry run in the sandbox">
    Send your real export with a `tb_test_` key. Test syncs write only to your [sandbox](/guides/sandbox), where emails are stored as non-deliverable stand-ins and no SSO domain check applies.
  </Step>

  <Step title="First live run: report only">
    Send the full roster with `deactivate_missing: false`. Review `created`, `errors` and especially `deactivation_candidates`: they show exactly who would be deactivated.
  </Step>

  <Step title="Run daily">
    Schedule the sync once a day, outside working hours and after your HR system's nightly export, for example at 02:00 local time.
  </Step>

  <Step title="Turn on deactivation">
    Once the candidates list looks right, switch to `deactivate_missing: true`.
  </Step>

  <Step title="Monitor every run">
    Alert when `errors` is not empty or `deactivation_skipped_reason` is not `null`, and log the counts so you can see trends.
  </Step>
</Steps>

## Worked example

Your company has Nino (linked to `EMP-00042`), Giorgi (joined through SSO, no HR id yet) and Luka (linked to `EMP-00311`, who has left). You send:

<CodeGroup>
  ```bash curl theme={null}
  curl -X PUT https://api.teambuilder.ge/v1/employees/sync \
    -H "Authorization: Bearer $TEAMBUILDER_SYNC_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @roster.json
  ```

  ```javascript Node.js theme={null}
  import { readFile } from "node:fs/promises";

  // JSON.stringify keeps non-ASCII characters as raw UTF-8, as the API expects.
  const roster = JSON.parse(await readFile("roster.json", "utf8"));

  const res = await fetch("https://api.teambuilder.ge/v1/employees/sync", {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${process.env.TEAMBUILDER_SYNC_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(roster),
  });

  if (res.status === 409 || res.status === 429) {
    const retryAfter = Number(res.headers.get("Retry-After") ?? 30);
    // Another sync is running or the hourly budget is spent: try again later.
    throw new Error(`Sync refused, retry in ${retryAfter}s`);
  }
  const result = await res.json();
  if (!res.ok) throw new Error(`${result.error.code}: ${result.error.message} (${result.error.request_id})`);
  if (result.errors.length || result.deactivation_skipped_reason) {
    console.warn("Sync needs attention", result);
  }
  ```

  ```python Python theme={null}
  import json, os, requests

  with open("roster.json", encoding="utf-8") as f:
      roster = json.load(f)

  # Send raw UTF-8: requests' json= would escape non-ASCII names as \uXXXX.
  res = requests.put(
      "https://api.teambuilder.ge/v1/employees/sync",
      data=json.dumps(roster, ensure_ascii=False).encode("utf-8"),
      headers={
          "Authorization": f"Bearer {os.environ['TEAMBUILDER_SYNC_KEY']}",
          "Content-Type": "application/json",
      },
      timeout=120,
  )
  if res.status_code in (409, 429):
      retry_after = int(res.headers.get("Retry-After", 30))
      raise RuntimeError(f"Sync refused, retry in {retry_after}s")
  res.raise_for_status()
  result = res.json()
  if result["errors"] or result["deactivation_skipped_reason"]:
      print("Sync needs attention:", result)
  ```
</CodeGroup>

```json roster.json theme={null}
{
  "employees": [
    {
      "external_id": "EMP-00042",
      "email": "nino.beridze@example.com",
      "first_name": "Nino",
      "last_name": "Beridze",
      "department_external_id": "D-ENG",
      "department_name": "Engineering",
      "job_title": "Engineering Manager"
    },
    {
      "external_id": "EMP-00107",
      "email": "giorgi.kapanadze@example.com",
      "first_name": "Giorgi",
      "last_name": "Kapanadze",
      "department_external_id": "D-DATA",
      "department_name": "Data",
      "job_title": "Data Analyst"
    },
    {
      "external_id": "EMP-00388",
      "email": "mariam.lomidze@example.com",
      "first_name": "Mariam",
      "last_name": "Lomidze",
      "department_external_id": "D-ENG",
      "department_name": "Engineering",
      "job_title": "Front-End Developer"
    },
    {
      "external_id": "EMP-00390",
      "email": "not-an-email",
      "first_name": "Ana",
      "last_name": "Gelashvili"
    }
  ],
  "deactivate_missing": true
}
```

```json Response theme={null}
{
  "created": 1,
  "updated": 2,
  "unchanged": 0,
  "reactivated": 0,
  "deactivated": 0,
  "departments_created": 1,
  "departments_restored": 0,
  "deactivation_candidates": [
    {
      "employee_id": "8e3f1a6c-2d7b-4c59-b0e4-6a1f9c3d7e28",
      "external_id": "EMP-00311",
      "email": "luka.tsiklauri@example.com"
    }
  ],
  "deactivation_candidates_total": 1,
  "unlinked_members": 0,
  "deactivation_limit": 10,
  "deactivation_skipped_reason": "rejected_rows",
  "errors": [
    {
      "index": 3,
      "external_id": "EMP-00390",
      "code": "validation_failed",
      "message": "email must be an email"
    }
  ],
  "warnings": []
}
```

What happened:

* **Nino** matched by `external_id`; her job title changed, so she counts as `updated`.
* **Giorgi** matched by email (he had no HR id), was linked to `EMP-00107` and moved to the new **Data** department, which was created: `updated` and `departments_created: 1`.
* **Mariam** is new: `created`. No invitation email was sent; she appears in the API straight away, with the name you synced.
* **Ana's** row had an invalid email, so it was rejected (`index: 3`).
* **Luka** is missing from the roster, so he is a candidate. But because a row was rejected, the roster might be incomplete, so **nobody was deactivated** (`deactivation_skipped_reason: "rejected_rows"`). Fix Ana's email and the next run deactivates Luka.


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