# People sync

Use this recipe to keep an external directory, access-control list, reporting
tool, or data warehouse aligned with HollyHR's safe People projection.

The default sync uses `people:read` only. It does not read dates of birth, home
contact details, demographics, compensation, bank details, tax or government
identifiers, document bytes, avatar bytes, or sensitive notes.

## What it reads

- `GET /people`
- `GET /people/{personId}` for detail refreshes
- `GET /people/{personId}/org-links` when you need historical org placement
- optional `GET /people/{personId}/employment` for current employment detail

Use webhooks for public API-origin changes when you need near-real-time
notifications, then fetch current state through REST. UI-origin changes inside
HollyHR do not currently emit public API webhooks.

## Scopes

```text
people:read
```

Add `people:write` only for a deliberate provisioning or lifecycle integration.
Do not add `people:personal:read` unless your integration has a documented need
for one-person-at-a-time elevated personal profile reads.

## Baseline sync

```js
const apiBaseUrl = process.env.HOLLYHR_API_BASE_URL;
const apiToken = process.env.HOLLYHR_API_TOKEN;

if (!apiBaseUrl || !apiToken) {
  throw new Error("Missing HOLLYHR_API_BASE_URL or HOLLYHR_API_TOKEN");
}

async function holly(path) {
  const response = await fetch(`${apiBaseUrl}${path}`, {
    headers: {
      Authorization: `Bearer ${apiToken}`,
      Accept: "application/json",
    },
  });

  if (!response.ok) {
    throw new Error(`HollyHR API request failed: ${response.status} ${await response.text()}`);
  }

  return response.json();
}

async function listAll(path) {
  const items = [];
  let cursor;

  do {
    const separator = path.includes("?") ? "&" : "?";
    const page = await holly(
      `${path}${cursor ? `${separator}cursor=${encodeURIComponent(cursor)}` : ""}`,
    );
    items.push(...page.data);
    cursor = page.pagination?.next_cursor;
  } while (cursor);

  return items;
}

const people = await listAll("/people?limit=100");

for (const person of people) {
  await upsertDirectoryUser({
    hollyhrId: person.id,
    displayName: person.display_name,
    workEmail: person.work_email,
    status: person.status,
    department: person.department?.name ?? null,
    jobTitle: person.job_title ?? null,
    managerId: person.manager?.id ?? null,
    updatedAt: person.updated_at,
  });
}
```

## Incremental sync

Store the latest successful `updated_at` checkpoint from your downstream store,
then request only changed records:

```js
const checkpoint = "2026-06-16T09:00:00.000Z";
const changedPeople = await listAll(
  `/people?limit=100&updated_since=${encodeURIComponent(checkpoint)}`,
);
```

Always follow cursor pagination until `pagination.next_cursor` is empty before
advancing the checkpoint.

## Optional safe writes

For starter provisioning, use `POST /people` with `people:write` and an
`Idempotency-Key`. HollyHR accepts safe setup fields such as work email, name,
job title, and start date. It does not send invite emails and does not accept
payroll, bank, tax, government-id, home contact, date-of-birth, demographic,
notes, avatar, or document payloads.

For updates, read the person first and send the returned `ETag` as `If-Match`
with an idempotency key. If the API returns `412`, fetch the latest record and
ask a human or deterministic merge rule to resolve the conflict.

## Webhook pairing

Create a webhook for `person.created`, `person.updated`, `person.ended`, and
`person.reactivated` if your integration needs notification-driven refreshes.
The endpoint key needs `webhooks:manage` plus `people:read`.

Webhook payloads are thin notifications. Deduplicate by
`HollyHR-Webhook-Id`, verify the signature, then call `GET /people/{personId}`
for the current projection before updating downstream systems.
