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:- By
external_id. The employee already linked to this HR id. - By
email. An employee of your company who has noexternal_idyet, for example someone who joined through SSO before you started syncing. The row links them to your HR id from now on. - 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.
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.
Deactivating leavers
By default a sync only reports who is missing. Every response listsdeactivation_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 inreactivated. - 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 inunlinked_membersinstead. 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, anddeactivation_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.
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 withdeactivated_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-basedindex 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.
Row error codes
Row error codes
Warning codes
Warning codes
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 conflictwithRetry-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_exceededwithRetry-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:- Split it into chunks of up to 5,000 rows.
- Send the chunks one after another, never in parallel; each waits for the previous response.
- Send every chunk with
deactivate_missing: false.
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 withemail_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
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 toEMP-00042), Giorgi (joined through SSO, no HR id yet) and Luka (linked to EMP-00311, who has left). You send:
roster.json
Response
- Nino matched by
external_id; her job title changed, so she counts asupdated. - Giorgi matched by email (he had no HR id), was linked to
EMP-00107and moved to the new Data department, which was created:updatedanddepartments_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.
