Skip to main content
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; 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.
Sync needs a key with the employees:write scope, which is never granted by default. Ask contact@teambuilder.ge for it, ideally on a key used only by your sync job.

How a sync works

1

Validate

Each row is checked on its own. A malformed row is reported in errors and skipped; the rest still apply.
2

Match

Each row is matched to an existing employee by external_id, then by email. Unmatched rows become new employees.
3

Resolve departments

Departments are matched by your id, then by name; missing ones are created, and archived ones restored.
4

Apply

New employees are added and changed fields updated. Rows that already match are counted as unchanged.
5

Deactivate (opt-in)

With deactivate_missing: true, employees missing from the roster are deactivated, behind several safety guards.

Request

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

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

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.

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.

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.

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 and compare their external_id values with your HR export, then deactivate the leavers in the admin portal, or contact us 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.
1

Dry run in the sandbox

Send your real export with a tb_test_ key. Test syncs write only to your sandbox, where emails are stored as non-deliverable stand-ins and no SSO domain check applies.
2

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

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

Turn on deactivation

Once the candidates list looks right, switch to deactivate_missing: true.
5

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.

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:
roster.json
Response
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.