# Sync employees from any HRIS with the unified HRIS API

Pulling an employee directory out of a customer's HRIS, then keeping it current as people are hired, change roles, or leave, is the foundation of most workforce-aware product features. Apideck exposes a single `employees` resource through the [HRIS API](/apis/hris/reference) that maps onto BambooHR, HiBob, Workday, Personio, Sage HR, and Gusto with the same request shape. A working Next.js 14 sample with the full import, ID-cherry-pick, and department-filtered patterns documented below lives at [github.com/apideck-samples/hris-employees-sync](https://github.com/apideck-samples/hris-employees-sync).

![The hris-employees-sync sample app at hris.apideck.dev](/guides/hris-employees-sync-sample.png)

## Why a Unified API

Every HRIS exposes a different employee model, with different fields, status vocabulary, and pagination semantics. Mapping one is straightforward. Mapping six, then maintaining each as the upstream platform evolves, is recurring engineering effort that does not differentiate your product.

- One request shape for listing, fetching, and creating employees across every connector
- Vault handles OAuth, API keys, and token refresh per consumer, so HRIS credentials never reach your servers
- Connector-specific status and field quirks are normalized into `employment_status`, `employment_start_date`, and `employment_end_date`
- New HRIS connectors light up without code changes on your side

## Resource mapping

Each connector returns the same unified `Employee` object, but the downstream entity behind it differs.

| Connector | Downstream object |
| --- | --- |
| BambooHR | Employee record |
| HiBob | Person profile |
| Workday | Worker (Employee subtype) |
| Personio | Employee |
| Sage HR | Employee |
| Gusto | Employee |

## Walkthrough

The three sync patterns share the same building blocks. Pick the one that matches the data volume and freshness needs of the feature you are building, then graduate to webhooks once polling becomes the bottleneck.

### 1. Full import

The simplest pattern. Page through `/hris/employees` for a consumer and upsert every result into your own store, keyed by `id`. Useful for the first sync after a connection is established and for nightly reconciliation jobs.

```http
GET https://unify.apideck.com/hris/employees?limit=100
Authorization: Bearer <APIDECK_API_KEY>
x-apideck-app-id: <APIDECK_APP_ID>
x-apideck-consumer-id: cons_01H8X9Y2A3B4C5D6E7F8G9H0J1
x-apideck-service-id: bamboohr
```

A typical response item from [Employees list](/apis/hris/reference#operation/employeesAll) looks like this.

```json
{
  "id": "emp_01HG4M2P9R7K3N5T8V0X1Y2Z3A",
  "first_name": "Priya",
  "last_name": "Anand",
  "display_name": "Priya Anand",
  "preferred_name": "Priya",
  "title": "Staff Software Engineer",
  "department_name": "Engineering",
  "employment_status": "active",
  "employment_start_date": "2022-04-18",
  "employment_end_date": null,
  "emails": [
    { "email": "priya.anand@acme.com", "type": "work" }
  ],
  "manager": {
    "id": "emp_01HG4M2P9R7K3N5T8V0X1Y2Z3B",
    "name": "Lukas Becker"
  }
}
```

Use the `next` cursor on the response envelope to walk subsequent pages. Persist `updated_at` per employee so the next run can detect changes.

### 2. Cherry-pick by ID

When the customer connects an HRIS but only a subset of their workforce belongs in your product, list once, let the admin select the relevant employees, then refresh those individually. This keeps the active dataset small and avoids importing personal records you do not need.

Send this to [Employees one](/apis/hris/reference#operation/employeesOne).

```http
GET https://unify.apideck.com/hris/employees/emp_01HG4M2P9R7K3N5T8V0X1Y2Z3A
x-apideck-service-id: hibob
```

A single-employee fetch returns the same shape as a list item. Run it on a schedule for the selected IDs to keep them current without paying the cost of a full crawl.

### 3. Department filter with email-based dedup

For products that scope to one team (Engineering, Sales, Operations), filter the imported list down to a department and dedup against your existing users by work email. This pattern is idempotent: re-running it never creates duplicates and it tolerates an employee moving in or out of the target department between runs.

```ts
import { Apideck } from '@apideck/unify'

const apideck = new Apideck({
  apiKey: process.env.APIDECK_API_KEY,
  appId: process.env.APIDECK_APP_ID,
  consumerId: 'cons_01H8X9Y2A3B4C5D6E7F8G9H0J1'
})

const department = 'Engineering'
const existingByEmail = await loadExistingUsersByWorkEmail()

const page = await apideck.hris.employees.list({
  serviceId: 'personio',
  limit: 100
})

const employees = (page.getEmployeesResponse && page.getEmployeesResponse.data) || []
const candidates = employees.filter((e) => e.departmentName === department)

for (const employee of candidates) {
  const work = (employee.emails || []).find((m) => m.type === 'work')
  const workEmail = work && work.email
  if (!workEmail) continue
  if (existingByEmail.has(workEmail.toLowerCase())) continue
  await createUserInYourProduct(employee, workEmail)
}
```

Lowercasing the email before comparison is the small detail that prevents duplicate users when the HRIS stores `Priya.Anand@acme.com` and your product stores `priya.anand@acme.com`.

### 4. Onboarding new hires

When the flow runs the other way, your product is the source of truth and the HRIS needs the new record, post the same shape to [Employees add](/apis/hris/reference#operation/employeesAdd).

```http
POST https://unify.apideck.com/hris/employees
x-apideck-service-id: bamboohr
Content-Type: application/json
```

```json
{
  "first_name": "Mia",
  "last_name": "Okafor",
  "preferred_name": "Mia",
  "title": "Product Designer",
  "department_name": "Design",
  "employment_status": "active",
  "employment_start_date": "2026-06-01",
  "emails": [
    { "email": "mia.okafor@acme.com", "type": "work" }
  ],
  "employments": [
    {
      "employee_pay_type": "salary",
      "payroll_code": "DES-12"
    }
  ]
}
```

The sample app wires this form to the [Employee Onboarding MCP server](https://www.apideck.com/mcp-server/employee-onboarding), so the same payload can also be created from an LLM tool call.

### 5. From polling to webhooks

Polling on a schedule is a fine place to start, but it scales poorly past a few thousand employees per consumer. When that becomes the bottleneck, subscribe to HRIS lifecycle events and treat the periodic full sync as a reconciliation safety net rather than the primary data path. Subscribe to `employee.created`, `employee.updated`, `employee.deleted`, and termination events through Apideck's unified webhooks. The full setup, signature verification, and replay semantics are documented in the [Webhooks guide](/guides/webhooks).

## Connector-specific behavior

Every connector in the catalog returns the same unified `Employee` object, but a few have quirks worth knowing before you ship.

| Connector | Notes |
| --- | --- |
| BambooHR | Default sync excludes terminated employees. Include them by passing the `include_terminated` filter when supported, otherwise rely on `employment_status` to detect leavers. |
| HiBob | Personal vs work email split is strictly enforced upstream. Always match on the `work` entry in `emails[]`. |
| Workday | Tenant URL is configured at connection time in Vault. Large directories paginate aggressively, so prefer cursor pagination over re-listing. |
| Personio | Department membership is the cleanest filter target. `department_name` maps reliably across tenants. |
| Sage HR | Standard mapping. No known quirks beyond the unified model. |
| Gusto | Standard mapping. No known quirks beyond the unified model. |

### BambooHR

Terminated employees disappear from the default list response. Either widen the request with `include_terminated=true` or run a separate reconciliation pass that compares the latest IDs against your store and marks missing ones as offboarded. Combine this with `employee.deleted` webhooks once you have those wired up.

### Workday

Workday tenants can hold tens of thousands of workers. The `next` cursor returned in the unified response envelope is the correct way to paginate. Avoid re-issuing the same list query with a higher offset. The sample app's `/sync` page uses cursor-based pagination by default, which is the pattern to follow.

### HiBob

HiBob keeps personal and work email in distinct slots upstream, and the unified model preserves that separation through the `type` discriminator on each `emails[]` entry. The dedup pattern above filters on `type === 'work'` for that reason. Doing the same on the consuming side avoids cross-matching against personal Gmail addresses.

## Next steps

- Read the companion [Onboard and Offboard Employees guide](/guides/onboard-and-offboard-employees-hris-api) for the full lifecycle, including offboarding.
- Wire lifecycle events with the [Webhooks guide](/guides/webhooks) once polling stops keeping up.
- Explore the working sample at [github.com/apideck-samples/hris-employees-sync](https://github.com/apideck-samples/hris-employees-sync), which ships with mock connectors that work without credentials.
- Review [Data Scopes for HRIS](/guides/data-scopes-for-hris) to restrict which employee fields a consumer can read.
