Sync people from an HR system
Sync people from an HR system
An HR system is the source of truth for who works at a company. Connected as a directory, it provisions an account when somebody starts, keeps their name, title, department and manager current, and deactivates the account — revoking every session — when they leave.
It is a pull directory, like Google Workspace and Microsoft Entra: the platform fetches from the HR system's API on a schedule. Nothing is pushed to us, so nothing on the HR side needs a SCIM endpoint.
| HR system | What the customer creates | Incremental |
|---|---|---|
| Workday | A custom report enabled as a web service (RaaS), shared with an integration system user — or an API client with a refresh token | No — every run is full |
| BambooHR | An API key for a user who can see every employee | Yes (/employees/changed) |
| Rippling | An API token with workers.read, users.read, departments.read |
Yes (updated_at filter) |
| HiBob | An API service user that can read root, work and lifecycle fields of everybody | No |
| Personio | API credentials (v2) with persons and org-units read access | No |
1. Collect and check the credentials
HrisCatalog holds each system's setup steps, its documentation link, and the credentials
its connector reads — so a form built from it collects exactly those.
use Cbox\Id\Directory\DirectoryConnectors;
use Cbox\Id\Directory\Enums\DirectoryProvider;
use Cbox\Id\Directory\Exceptions\DirectoryConnectionFailed;
use Cbox\Id\Directory\Exceptions\IncompleteHrisCredentials;
use Cbox\Id\Directory\Hris\HrisCatalog;
$provider = DirectoryProvider::BambooHr;
$setup = HrisCatalog::for($provider);
$setup->setupSteps; // what to do in BambooHR, in order
$setup->credentials; // key, label, help, example, secret, required
try {
// Trimmed, limited to the keys the connector reads, normalised — a pasted
// https://acme.bamboohr.com becomes `acme`; a Workday report URL is pinned to
// Workday's own hosts and asked for as JSON.
$credentials = HrisCatalog::credentialsFrom($provider, $request->input('credentials'));
// A cheap request with the reason when it is refused.
app(DirectoryConnectors::class)->for($provider)->probe($credentials);
} catch (IncompleteHrisCredentials $e) {
$e->keys; // which fields to mark
} catch (DirectoryConnectionFailed $e) {
$e->getMessage(); // "… failed (403): the credentials lack permission to read employees."
}
2. Register the directory and run the first pull on a worker
use Cbox\Id\Directory\Contracts\Directories;
use Cbox\Id\Directory\Contracts\PullDirectories;
use Cbox\Id\Directory\Jobs\SyncPullDirectory;
$directory = app(Directories::class)->registerPull($organization->id, 'BambooHR', $provider, $credentials);
// Optional: HR fields to copy onto each person, and a pace of its own (15..1440 minutes).
app(PullDirectories::class)->setHrisOptions($directory, ['costCenter', 'location']);
app(PullDirectories::class)->setSyncInterval($directory, 30);
SyncPullDirectory::dispatch($directory->id);
The credentials are sealed (SecretBox, bound to the directory id) and never serialized:
Directory hides credentials and bearer_token_hash. Replace them with
PullDirectories::replaceCredentials(), which also makes the next run a full pull.
After that the scheduler pulls every directory whose interval has elapsed
(cbox-id:directory:sync --due, every fifteen minutes). --full asks an incremental
system for everybody.
What a pull does
- Employment decides access. An employee whose termination date has passed is
deactivated at the end of that day, whatever the status field still says. Somebody whose
start date is in the future gets an account on the day (
cbox-id.directory.hris.pre_hire_daysmoves that earlier). On leave keeps the account. - Leavers who never had an account get none. HR systems report everyone they ever employed; only people already in the directory are written to deactivate them.
- The person is a SCIM user, stored like a SCIM push:
name,emails,title, the enterprise extension'semployeeNumber,departmentandmanager(value= the manager's HR employee id), andurn:cbox-id:params:scim:schemas:extension:hris:1.0:Userwith the employment status, start and termination dates, department id and custom attributes. - Departments become groups (external id
department:{id}), membership rebuilt from the stored people every run, so group → role mappings work exactly as for SCIM groups. Two departments with one name are kept apart by id; a department that is gone and empty is removed. - One bad record costs one record. An email that belongs to an unlinked account, or an
employee with no work email, is a
SyncFailureon the result — external id and a reason with no personal data — and the run ispartial. Such people still count as seen, so a data problem never deprovisions somebody who is employed. - Incremental runs ask only for what changed since the last run started (wound back
five minutes), between full pulls at most
full_sync_hoursapart. They never deprovision anybody for being absent; termination dates are swept from what is stored every run. - A mass deprovisioning is refused. A full pull that would deactivate more than
deprovision_guard(half) of the active people deprovisions nobody and says why — an API key that lost its reach looks exactly like a mass layoff. - Rate limits are waited out. 429 and 502–504 are retried up to
max_attempts, honouringRetry-Afteror a rate-limit reset header.
Every run is recorded on the directory: last_sync_started_at, last_sync_status
(running, succeeded, partial, failed), last_sync_stats (counts and up to
max_reported_failures failures), last_sync_error. A run holds a per-directory lock, so
the scheduler and a "sync now" never pull the same directory at once.
Configuration
'directory' => [
'default_interval_minutes' => 60,
'hris' => [
'pre_hire_days' => 0,
'full_sync_hours' => 24,
'deprovision_guard' => 0.5,
'max_attempts' => 5,
'max_backoff_seconds' => 60,
'max_reported_failures' => 50,
'workday_hosts' => ['.workday.com', '.myworkday.com'],
],
],
Testing
FakeHrisProvider stands in for an HR system: set its employees and departments, make it
incremental (changed() is what a "changed since" query answers), or make it refuse.
use Cbox\Id\Directory\DirectoryConnectors;
use Cbox\Id\Directory\Testing\FakeHrisProvider;
app()->instance(DirectoryConnectors::class, new DirectoryConnectors([
new FakeHrisProvider(employees: [$ada, $grace], departments: [$engineering]),
]));
Your own HR system
Implement Cbox\Id\Directory\Hris\Contracts\HrisProvider — or extend HrisConnector,
which supplies the retrying client (http()), credential reading and the pagination pin —
add a DirectoryProvider case for it in your fork, and rebind DirectoryConnectors with it.