Skip to content

Sync people from an HR system

Keep Flowstate's live workforce in step with an HR system using scheduled PULL hooks. This page assumes you've read Custom integrations, Writing hooks and the Data model. Whether the HR system should be the source of truth at all is covered in Get your people data into Flowstate.

This is the scheduled-sync route. The example calls a generic HR API; adapt the fetch to your system, using the notes for each supported HR system. For the event-driven route, where your integration platform receives the HR system's events and writes to Flowstate, use Sync people from an HR system with the REST API. Choosing between them: Connect your HR system.

Hooks

One integration per HR system, with one PULL hook per entity type. On the first sync, run them in this order:

#Entity typeCarriesNeeds first
1teamHierarchy (parentTeamId), manager (teamManagerEmail)Managers invited as Flowstate users
2employeePerson, jobRole, teamAllocations, projectAllocations, salaryAdjustmentsTeams, or teamName on every allocation
3contractorContractor, rateType, rate, rateAdjustments, teamAllocationsAs for employees
4vacancyOpen and filled positions, filledByThe employees and contractors named in filledBy
5functional_group, functional_position, functional_assignment (optional)The org chartPeople. See the functional structure example

Why the order matters:

  • An allocation that names a team only by teamId is skipped when no team has that ID yet. Send teamName as well, and the team is found by name or created.
  • teamManagerEmail is matched to a Flowstate user, not an employee. With no matching user, the team syncs without a manager. Re-run after inviting.
  • filledBy fails the vacancy when the named person hasn't been synced.

Source System Key. Use one value per HR system, such as workday, and never share it with another integration. It's how Flowstate tells this integration's records, allocations and pay changes apart from everyone else's, and it can't be changed later.

Not settable from a hook: an employee's location, resource type (which carries overhead) and line manager. A hook never changes them. Set them on screen, or with PATCH /employees/:id (geographyId, workTypeId, managerId).

Matching

externalId is the key. For each record, Flowstate looks for:

  1. a record this integration wrote with that externalId, then
  2. a record whose externalId field, set through the REST API, equals it.

If neither exists, it creates one. Email is not a match key. Use the HR system's permanent worker ID, not an email or an employee number that can change or be reissued.

Creating someone who already exists under another key doesn't merge them:

EntityWhat happens
EmployeeThe record fails with "A record with this … already exists in your organisation". Work email is unique per organisation.
ContractorA second contractor is created.
TeamA second team with the same name is created.

Adopt people who are already in Flowstate

People loaded by spreadsheet, added on screen or created by filling a vacancy have no HR ID. Before the first real run, set each one's externalId to their worker ID:

bash
curl -X PATCH "https://{tenant}.flowstate.inc/api/v1/org/{orgId}/employees/{id}" \
  -H "Authorization: Bearer private_..." \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "W-10442" }'

Do the same for contractors (PATCH /contractors/:id), teams (PATCH /teams/:id) and vacancies (PATCH /vacancies/:id). An externalId is unique per entity type, at most 255 characters, and can't look like a Flowstate ID.

Then run Test (Dry Run) and open Preview. Anyone listed under Creates who should already exist hasn't been adopted.

The first real run takes adopted records over. Their existing allocations and pay changes are taken over where they line up with what you send; see edits made in Flowstate.

What a run changes

Every run reconciles every record it returns. Returning the whole population on each run is safe, and it's the simplest way to pick up corrections.

You sendResult
A top-level field in dataOverwrites Flowstate's value, including an edit made on screen since the last run.
A string, number or date field left out, or nullLeaves Flowstate's value alone. A hook can't clear a field. Use the REST API, for example { "endDate": null }.
teamAllocations or projectAllocationsThe complete set of this integration's allocations for that person. An allocation you stop sending is deleted, not ended.
teamAllocations: []Deletes all of this integration's team allocations for that person.
No allocation arrayLeaves allocations alone.
salaryAdjustments or rateAdjustmentsCreates or updates the entries you send. Entries you stop sending are kept. Remove one with deletedAt.
No record for someoneLeaves them exactly as they are. Nothing is ended or deleted.

Never default a missing array to [] in your mapping. An empty array deletes the integration's allocations for that person.

Edits made in Flowstate

  • Fields. A field the hook sends wins on the next run. A field it doesn't send keeps whatever was set on screen.
  • Allocations added on screen or by another integration are left alone, except one on the same team or project with the same start date as an allocation you send. That one is taken over and joins this integration's set.
  • Pay changes. A pay change you send without an externalId is matched on its effective date, so one entered on screen for that day is overwritten.
  • Merged scenarios are live edits like any other. A merged change to a field the hook sends lasts until the next run.

Pay changes from a date

js
salaryAdjustments: [
  { externalId: 'COMP-88121', effectiveDate: '2025-04-01', salary: 92000, currencyCode: 'GBP' },
  { externalId: 'COMP-90310', effectiveDate: '2026-10-01', salary: 98000, currencyCode: 'GBP', reason: 'promotion' }
]
  • Send one entry per change: annual base salary in currencyCode, from effectiveDate. Leave out employer costs. Overhead comes from the person's resource type; see How costs are calculated.
  • Future-dated entries are fine. They show as Scheduled on the person's Compensation tab until their date.
  • An entry without effectiveDate, salary or currencyCode is skipped with no error. Check before returning, and ctx.log.warn what you drop.
  • Send the compensation record's own ID as externalId, so a corrected effective date updates the entry instead of adding a second one.
  • Send past changes as well as the current salary if past months should be costed from the HR system.

Contractors work the same way with rateAdjustments (effectiveDate, rateType, rate, currencyCode).

Leavers

  • Send endDate, the person's last day. Allocations you send without their own endDate inherit it.
  • Keep leavers in the feed. If your HR API returns only active workers by default, ask it for terminated workers too. Someone who drops out of the feed is left untouched and never gets an end date.
  • Allocations the hook doesn't own keep their dates. Recording a leaver on screen ends their active allocations. A hook only changes the allocations it sends.
  • Don't use deletedAt for leavers. It deletes the person along with their allocations and pay history, so they disappear from past months. Keep it for records created by mistake.
  • Rehires. A hook can't clear endDate. Send { "endDate": null } with PATCH /employees/:id. If the HR system gives the rehire a new worker ID, set the old record's externalId to the new ID in the same call, or the new record fails on the email.

Positions and vacancies

Sync open and filled positions as vacancies. status is open, committed, filled or cancelled. filledBy: { externalId } names the employee or contractor in the seat, whose own record then carries the cost. filledBy: null re-opens it. The full lifecycle is in Vacancies (positions).

Failures and re-runs

  • Each record is written on its own. A failed record doesn't undo the others, and pages already written stay written if a later page fails.
  • Each failure is logged as Record <externalId>: <message> in the run's log on Execution History.
  • Throw when the HR API call fails. Returning { records: [] } ends the run as Completed and hides the problem. Throwing marks it Failed.
  • Send an externalId on every allocation. Without one, an allocation is matched on its team or project and start date, so a corrected start date replaces the row instead of updating it.
  • Send the full allocation history, ended allocations included, with their endDate. The array is the complete set, so an allocation that drops out of the feed is deleted, and the person's past time on that team goes with it.
  • Send fte as a number. A string fte is ignored, and the allocation is saved at the default of 1.

Limits that shape the hook

  • 100 pages per run, and 30 seconds and 50 HTTP requests per page. Return one HR API page per hook page with more and meta. If your population needs more than 100 pages, ask the HR API for bigger pages.
  • HTTPS only, public addresses only, no redirects followed, and 10 MB per response.
  • Daily at a Preferred hour in your organisation's timezone suits most HR data. See Choose when it runs.
  • Every limit: Limits.

Example: employees from a generic HR API

The HR API here (hr.example.com) is made up. It returns workers a page at a time, each with their organisation assignments and compensation history. Store its token as the credential HR_API_TOKEN.

js
async function run(ctx) {
  const query = { limit: '100', include_terminated: 'true' };
  if (ctx.meta?.cursor) query.cursor = ctx.meta.cursor;

  const resp = await ctx.http.get('https://hr.example.com/api/v1/workers', {
    headers: { Authorization: `Bearer ${ctx.secrets.HR_API_TOKEN}` },
    query
  });
  if (resp.status !== 200) {
    // Fail the run so Execution History shows it. Earlier pages stay written.
    throw new Error(`HR API returned ${resp.status}`);
  }

  const records = [];
  for (const w of resp.body.workers) {
    if (w.worker_type !== 'employee') continue; // contractors have their own hook
    if (!w.work_email) {
      ctx.log.warn('Skipping worker with no work email', { id: w.id });
      continue;
    }

    records.push({
      // The permanent worker ID. Never the email.
      externalId: String(w.id),
      data: {
        firstName: w.first_name,
        lastName: w.last_name,
        email: w.work_email,
        internalEmployeeId: w.employee_number,
        startDate: w.hire_date,
        // Last day for leavers; left out for everyone else.
        endDate: w.termination_date || undefined,
        jobRole: w.job ? { title: w.job.title, externalId: String(w.job.id) } : undefined,

        // Full history, ended rows included: this is the complete set.
        // If org_assignments is missing, .map throws and the run fails,
        // which is safer than sending [] and deleting allocations.
        teamAllocations: w.org_assignments.map(a => ({
          externalId: String(a.id),
          teamId: String(a.org_unit_id),
          teamName: a.org_unit_name,
          startDate: a.start_date,
          endDate: a.end_date || undefined, // inherits the worker's endDate
          fte: Number(a.fte)
        })),

        salaryAdjustments: w.compensation
          .filter(c => {
            const complete = Boolean(c.effective_date && c.annual_base && c.currency);
            if (!complete) ctx.log.warn('Skipping incomplete pay change', { worker: w.id, id: c.id });
            return complete;
          })
          .map(c => ({
            externalId: String(c.id),
            effectiveDate: c.effective_date,
            salary: Number(c.annual_base),
            currencyCode: c.currency,
            reason: c.reason || undefined
          }))
      }
    });
  }

  ctx.log.info('Fetched workers', { count: records.length });

  return {
    records,
    more: Boolean(resp.body.next_cursor),
    meta: { cursor: resp.body.next_cursor }
  };
}

Once employees sync cleanly, write the contractor hook the same way with name, email, rateType, rate, currencyCode, startDate, endDate, rateAdjustments and teamAllocations.

Adapt it to your HR system

Keep the record shape, and change only how the hook fetches and maps. Use an API user your HR team sets up, and your vendor's API reference for field names.

Flowstate fieldTake it from
externalIdThe worker's permanent ID
emailWork email
startDate, endDateHire date, and last day of employment
internalEmployeeIdEmployee or payroll number
jobRoleJob or position title, with its ID
teamAllocationsOrganisation, department or team assignments, with their dates
salaryAdjustmentsCompensation history: annual base pay, currency and effective date

Tokens. If the HR API issues short-lived tokens, store the client ID and secret with Add Credential and fetch a token inside the hook. ctx.http.post sends a string body as it is, but sets Content-Type: application/json unless you set it yourself. Cache the token in ctx.kv for less than its lifetime:

js
async function run(ctx) {
  let token = await ctx.kv.get('hr_token');
  if (!token) {
    const form = 'grant_type=client_credentials'
      + '&client_id=' + encodeURIComponent(ctx.secrets.HR_CLIENT_ID)
      + '&client_secret=' + encodeURIComponent(ctx.secrets.HR_CLIENT_SECRET);
    const auth = await ctx.http.post('https://hr.example.com/oauth/token', form, {
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    });
    if (auth.status !== 200) throw new Error(`Token request returned ${auth.status}`);
    token = auth.body.access_token;
    // Let the cached token lapse a minute before the HR system's does.
    await ctx.kv.set('hr_token', token, Math.max(auth.body.expires_in - 60, 1));
  }

  // Fetch workers as in the example above, with `Authorization: Bearer ${token}`.
}

Hooks run in an isolated JavaScript sandbox without browser or Node.js APIs. If your token endpoint needs HTTP Basic authentication, store the already-encoded client_id:client_secret value as a credential rather than encoding it in the hook.

Network. The HR API must answer over HTTPS on a public address, without redirects. If it's only reachable inside your network, run the sync there and call the REST API instead; see Sync people from an HR system with the REST API.

Per-system notes

Hook means a PULL hook like the example above. Middleware means your integration platform calls the HR system, or receives its webhooks, and writes to Flowstate with the REST recipe. Use middleware when the vendor needs a client certificate or a signed assertion, when the data only comes as XML (hooks have no XML parser), or when you want the vendor's webhooks: they go to your platform, not to Flowstate.

SystemSign-inWorkersChangesRuns as
WorkdayIntegration System User in an Integration System Security Group with Get permission on the domain security policies you read. OAuth API client from Register API Client for Integrations, with a refresh token for that userGet_Workers in the Human_Resources web service (SOAP), or an advanced custom report with Enable as Web ServiceGet_Workers with Transaction_Log_Criteria_Data date ranges, or a push subscription (outbound message) from an Integration System to your platformMiddleware for SOAP and push. A hook only if your custom report returns JSON
ADP Workforce NowPOST https://accounts.adp.com/auth/oauth/v2/token, grant_type=client_credentials, client ID and secret as Basic, and the X.509 client certificate on every request (mutual TLS)GET /hr/v2/workers, paged with $top and $skip until workers is emptyEvent queue GET /core/v1/event-notification-messages returns one message; DELETE it by adp-msg-msgid to read the next. Or ADP webhooks. Re-read GET /hr/v2/workers/{aoid} for each eventMiddleware
SAP SuccessFactorsOAuth 2.0 SAML bearer assertion: register a client in Manage OAuth2 Client Applications with an X.509 certificate, sign the assertion, exchange it at /oauth/tokenOData v2 on your data centre's API server: EmpJob, EmpPayCompRecurring, User with $expand=manager. paging=snapshot, follow __next; up to 1,000 per responsefromDate and toDate windows. lastModifiedDateTime isn't a clean delta on effective-dated entities, so SAP advises a wide extraction and a diff. Or Intelligent Services events with an Integration Center jobMiddleware
Oracle Fusion Cloud HCMBasic auth over HTTPS, or OAuth client credentials from an OCI IAM Confidential Application at https://<domainURL>/oauth2/v1/token (the client ID must also be a Fusion user with roles). Role: Human Capital Management Integration Specialist, in a security profile/hcmRestApi/resources/11.13.18.05/workers, then workRelationships and assignments; /salaries. limit and offset, until hasMore is falseAtom feeds at /hcmRestApi/atomservlet/employee/ (newhire, empassignment, empupdate, termination, workrelshipupdate, cancelworkrelship), polledHook for the REST resources. Oracle Integration Cloud as middleware if you run it
BambooHRAPI key as the Basic auth username, any password. It has its user's permissionsGET https://{companyDomain}.bamboohr.com/api/v1/employees with page[limit] and page[after]; pay and job history from /api/v1/employees/{id}/tables/compensation and /tables/jobInfoGET /api/v1/employees/changed?since=. Or webhooks (employee.created, employee.updated, employee.deleted), signed with HMAC-SHA256 in X-BambooHR-SignatureHook. Store the Basic value already encoded. Webhooks through middleware
HiBobService user ID and token as Basic auth. The service user starts with no access; grant it through a permission group, including View historyPOST https://api.hibob.com/v1/people/search isn't paged: fetch root.id first, then batches. Send showInactive: true for leavers. History from /bulk/people/work, /bulk/people/employment and /bulk/people/salaries (cursor)No changed-since filter on people search: reconcile each run. Or webhooks v2 (employee.*), signed with HMAC-SHA512 in Bob-SignatureHook. Webhooks through middleware
PersonioPOST /v2/auth/token, form-encoded, grant_type=client_credentials. The token lasts a day by defaultGET /v2/persons (cursor, limit up to 50), GET /v2/persons/{person_id}/employments, GET /v2/compensationsupdated_at.gt on persons and employments. Or webhooks v2 (person.*, employment.*), signed with HMAC-SHA256 in X-Personio-Webhook-SignatureHook. Webhooks through middleware
RipplingAPI token as Bearer, from Tools → Developer → API Tokens. Access is the creator's permissions, limited to the scopes chosenGET https://rest.ripplingapis.com/workers/ (cursor, limit up to 100; follow next_link). expand for manager and userupdated_at is filterable. The Worker Changes feed needs an entitlement from Rippling. Customer tokens get no worker webhooksHook
DeelOrganisation API token as Bearer, from More → Developer → Access Tokens, with people:read, contracts:read and organizations:readGET https://api.letsdeel.com/rest/people (offset and limit). Pay from GET /contracts/{id}, or GET /eor/contracts/{id}/details for EOR. Pin a version with X-VersionNo changed-since filter documented: reconcile each run. Or webhooks (such as contract.terminated and people.terminated), signed with HMAC-SHA256 in x-deel-signatureHook. Webhooks through middleware

Watch for these:

  • Workday: As_Of_Effective_Date and As_Of_Entry_DateTime are separate. Set an effective date to see future-dated hires and leavers, and pin the entry date while paging so pages don't shift. Reorganisations can't be pushed.
  • ADP Workforce Now: a webhook endpoint must be publicly reachable and hosted in the US. Queued events older than 30 days are purged, and ADP suggests polling at least hourly. An event is raised when a change is entered, not when it takes effect.
  • SAP SuccessFactors: without fromDate and toDate, effective-dated entities return only today's record, so future-dated hires and leavers are missed. Build on OAuth: basic auth is being removed.
  • Oracle Fusion Cloud HCM: read with effectiveDate. Someone can hold several work relationships and assignments; use PrimaryFlag and PrimaryAssignmentFlag.
  • BambooHR: fields the key's user can't see are left out or come back null, without an error.
  • HiBob: people search allows 50 requests a minute. Fields without permission are dropped without an error.
  • Personio: a compensation request covers a window of one month or less.
  • Rippling: a customer token is revoked when its owner is terminated or it's unused for more than 30 days. null in a response means the token can't see that field.
  • Deel: 5 requests a second for the whole organisation, across all tokens, with no rate-limit headers. Pay has a different shape for each contract type.

Before you turn it on

  1. The Source System Key is unique to this HR system.
  2. People, contractors, teams and vacancies already in Flowstate are adopted.
  3. Test (Dry Run)Preview shows no unexpected Creates and no unexpected Removes.
  4. Someone owns setting location and resource type on new people.
  5. The schedule is set, and the hook is switched on with Enable Hook.

To send Flowstate changes back to another system, use a PUSH hook or outbound webhooks.

Flowstate Documentation