---
title: "Officient to Hailey HR Migration: A Technical Guide"
slug: officient-to-hailey-hr-migration-a-technical-guide
date: 2026-08-31
author: Roopendra Talekar
categories: [Officient, Hailey HR, Migration Guide]
excerpt: "Technical guide to migrating from Officient to Hailey HR — covering API extraction, field mapping, rate limits, GDPR compliance, and loading strategy."
tldr: "No native path exists between Officient and Hailey HR. Extract via Officient's rate-limited API (30 req/5s), reassemble decomposed entities, and load via hybrid CSV + API approach."
canonical: https://clonepartner.com/blog/officient-to-hailey-hr-migration-a-technical-guide
---

# Officient to Hailey HR Migration: A Technical Guide


# Officient to Hailey HR Migration: A Technical Guide

Migrating from Officient to Hailey HR means moving employee data from a Belgian-rooted, decomposed-entity HRIS (now part of Exact) to a Nordic-focused platform built around a unified employee profile model. The two systems organize data differently at a fundamental level: Officient separates an employee across **People, Roles, Wages, Contracts, Days Off, Assets, Weekly Schedules**, and **Custom Fields** as independent API resources. Hailey HR consolidates most of this into a unified employee record with employment details, salaries, and time-off data accessible through a smaller set of endpoints.

**There is no native migration path between these two systems.** No export wizard on either side understands the other's schema. Every migration requires extraction from Officient's API, transformation to reassemble decomposed entities into Hailey's model, and loading via a hybrid approach of API calls, CSV imports, and some manual work.

This guide covers architectural differences, API constraints, field mapping, document handling, GDPR compliance for cross-jurisdiction transfers, and the step-by-step process for a clean migration without data loss.

For the broader HRIS migration framework, see [The Ultimate HRIS Data Migration Checklist](https://clonepartner.com/blog/blog/hris-data-migration-checklist). If sensitive payroll data is in scope, review [How to Safely Migrate Sensitive Employee & Payroll Data](https://clonepartner.com/blog/blog/payroll-data-migration-security-compliance).

## Why Teams Move from Officient to Hailey HR

Officient is a solid SME-focused HRIS built for the Belgian and Benelux market. It handles payroll integrations via connectors to Partena Professional, SD Worx, and Nmbrs, plus employee benefits, contracts, assets, fleet, and days off. It works well for companies with 10–500 employees operating primarily in Belgium and the Netherlands.

Hailey HR is built for the Nordic market — Sweden, Denmark, Norway, and Finland. It covers employee management, time and attendance, onboarding workflows, digital personnel files, performance management, and salary administration. It is not a payroll system itself, but integrates tightly with Nordic payroll providers like Flex Lön, Kontek Lön, Visma Lön 600, and Agda PS. Hailey is used by more than 1,000 companies across Europe.

The typical triggers for this migration:

- **Geographic shift:** A company expanding from Benelux to the Nordics needs an HRIS that handles Scandinavian labor law and integrates with Nordic payroll systems.
- **Consolidation onto a Nordic stack:** Teams already using Nordic payroll providers find that Officient's Belgian-rooted integrations don't connect to their pipeline.
- **UX preference:** Hailey HR's employee self-service and workflow automation appeal to teams that want a more guided experience vs. Officient's admin-heavy approach.

> [!NOTE]
> Hailey HR is not a payroll engine. If your current Officient setup relies heavily on Belgian payroll connectors (SD Worx, Partena Professional), you must separately establish a Nordic payroll integration before migration. Don't assume Hailey replaces that piece.

## Architectural Differences: Officient vs. Hailey HR

Understanding the data-model gap is the prerequisite for a clean migration. Getting this wrong means broken field mappings and silent data loss.

### Officient's Decomposed Entity Model

Officient treats each aspect of an employee as a **separate API resource** with its own lifecycle:

| Entity | Description | API Pattern |
|---|---|---|
| **People** | Core identity: name, email, national number, address | `/1.0/people` |
| **Roles** | Job title, department, manager relationship | Linked to person ID |
| **Wages** | Salary components, one-off wage components | `/1.0/wages/...` |
| **Contracts** | Employment contracts with start/end dates, PDF generation | `/1.0/contracts/{id}/pdf` |
| **Days Off** | Leave balances, requests, calendar entries | Calendar endpoints |
| **Assets** | Company equipment assigned to employees | Asset endpoints |
| **Weekly Schedules** | Work-time patterns | Schedule endpoints |
| **Custom Fields** | Tenant-defined fields with typed values | Custom field endpoints |
| **Fleet** | Company vehicles | Fleet endpoints |
| **Documents** | Uploaded files (base64 JSON responses) | Document endpoints |

An employee record in Officient is a **[graph of related resources](https://clonepartner.com/blog/blog/humaans-to-officient-migration-a-technical-guide)**, not a single document. You cannot pull a complete employee in a single API call. ([apidocs.officient.io](https://apidocs.officient.io/))

### Hailey HR's Unified Employee Model

Hailey HR consolidates employee data into a more [unified structure](https://clonepartner.com/blog/blog/hailey-hr-to-peoplestrong-migration-a-technical-guide). The API exposes:

- **Employees** — A single endpoint returns detailed information including employee ID, account status, contact details, and employment details
- **Salaries** — Dedicated endpoint for salary data across all employees
- **Time Off** — Time-off data with date filtering; supports both requested and approved entries
- **Casual Employments** — Separate resource for non-standard employment types
- **Certificates** — Employee certifications
- **Webhooks** — POST notifications for employee modifications (new hires, title updates, terminations)

Hailey supports **parallel and historical employments**, meaning one person can hold overlapping active employments with priority when intervals overlap. This is an important distinction: you don't need to blindly flatten Officient's employment history into a single row. Model it correctly using Hailey's employment structure.

### Hailey HR Write API Coverage

The distinction between read and write coverage on the Hailey API is operationally critical and must be understood before designing your load pipeline.

**Documented write endpoints** ([api.haileyhr.app](https://api.haileyhr.app/docs/index.html)):

| Endpoint | Method | What It Creates |
|---|---|---|
| Quick Add Employee | POST | New employee record (core fields only) |
| Import Time Off | POST | Bulk time-off entries (all assumed approved) |
| Custom Employee Fields | POST/PUT | Custom field values |
| Employee Documents | POST | File uploads (30 MB cap per document) |
| Clock In/Out | POST | Time tracking entries |

**Entity types not covered by the write API** (require CSV import or Hailey implementation team coordination):

- Employment records and employment history
- Salary records and salary history
- Organizational structure (departments, teams, cost centers)
- Manager relationships (can only be set after all employees exist)
- Certificates

This asymmetry — a broad read API and a narrow write API — is the single most operationally consequential fact about loading data into Hailey HR. It forces a hybrid load strategy regardless of how well-engineered your extraction pipeline is. Confirm current write-endpoint coverage with Hailey support before finalizing your load design, as API surface area changes between releases.

### Key Structural Gaps

| Officient Concept | Hailey HR Equivalent | Migration Impact |
|---|---|---|
| Decomposed contracts with PDF generation | Employment details within employee profile | Contracts must be reassembled; PDFs archived separately |
| Typed wage components (monthly, one-off, bonus) | Salary data via salary endpoint | Belgian gross/net structure ≠ Nordic salary model; requires restructuring |
| Weekly schedule as separate entity | Embedded in employment/attendance | Must merge into employee context |
| Fleet management | No equivalent | Archive or migrate to standalone fleet tool |
| Dimona declarations (Belgian social security) | No equivalent | Not applicable in Nordic jurisdictions |
| Custom fields with typed values | Custom fields (text, number, date, select, boolean) | Type translation needed; see custom fields section |
| Asset management | No native equivalent | Archive or use integration partner (e.g., Velory) |
| Belgian national number (NISS) validation | Nordic personal number formats (personnummer, CPR, etc.) | Different format and validation rules per country |

## Extracting Data from Officient

Officient's REST API is your primary extraction path. Manual CSV export from the admin UI is possible for basic employee lists, but it won't capture the full relational depth — contracts, wage history, custom fields, documents.

### API Authentication

Officient uses **OAuth2 with Bearer tokens**. You exchange an authorization code for an access token and refresh token at `https://api.officient.io/1.0/token`. Every subsequent request includes the `Authorization: Bearer <access_token>` header. Refresh tokens are long-lived and reusable. ([apidocs.officient.io](https://apidocs.officient.io/docs/getting-started))

**Token expiry behavior:** When the access token expires, subsequent requests return HTTP 401. Your extraction client must detect this response code, use the refresh token to obtain a new access token, and retry the failed request. Do not treat 401 as a fatal error during long extractions — it is expected for runs exceeding the token lifetime.

Use an Officient sandbox account during development. Officient's documentation recommends this to protect production data during migration scripting.

### Error Responses

Understanding all API error codes prevents extraction failures from going undetected:

| HTTP Status | Cause | Handling |
|---|---|---|
| 401 Unauthorized | Access token expired | Refresh token and retry |
| 404 Not Found | Employee deleted or ID incorrect | Log and skip; flag for HR review |
| 422 Unprocessable Entity | Validation failure (malformed request) | Log full request payload; fix and retry |
| 429 Too Many Requests | Rate limit exceeded | Sleep 5 seconds (full window), retry — **no Retry-After header provided, empty body** |
| 5xx Server Error | Officient-side error | Retry with exponential backoff; log if persistent |

The 429 behavior is specifically worth calling out: Officient returns HTTP 429 with an **empty response body** and **no `Retry-After` header**. Client-side throttling is mandatory — you cannot rely on the API to signal when to retry.

### Rate Limits and Extraction Math

Officient enforces a [hard rate limit](https://clonepartner.com/blog/blog/heavenhr-to-officient-migration-data-mapping-and-api-limits): **30 requests per 5 seconds**. Exceeding this returns HTTP 429 with an empty response body and no `Retry-After` header.

Rate-limit math for a typical extraction:

- 30 requests / 5 seconds = 360 requests/minute
- 30 items per page (Officient's default pagination size)
- Each employee requires follow-up calls for roles, wages, contracts, days off, assets, custom fields, and documents
- At ~8 API calls per employee (full extraction): 200 employees = ~1,600 requests
- At 360 requests/minute = **~4.5 minutes** minimum extraction time for 200 employees
- For 500 employees: ~4,000 requests = **~11 minutes** minimum, assuming zero retries or 404s

These are floor estimates. Employees with multi-year contract history, many documents, or many leave entries will require additional calls and push total extraction time higher.

```python
import time
import requests
from collections import deque

class OfficientClient:
    BASE_URL = "https://api.officient.io/1.0"
    MAX_REQUESTS = 30
    WINDOW_SECONDS = 5

    def __init__(self, access_token, refresh_token, client_id, client_secret):
        self.token = access_token
        self.refresh_token = refresh_token
        self.client_id = client_id
        self.client_secret = client_secret
        self.timestamps = deque()

    def _throttle(self):
        now = time.monotonic()
        while self.timestamps and self.timestamps[0] < now - self.WINDOW_SECONDS:
            self.timestamps.popleft()
        if len(self.timestamps) >= self.MAX_REQUESTS:
            sleep_time = self.WINDOW_SECONDS - (now - self.timestamps[0])
            time.sleep(max(sleep_time, 0.1))
        self.timestamps.append(time.monotonic())

    def _refresh_access_token(self):
        resp = requests.post(
            f"{self.BASE_URL}/token",
            data={
                "grant_type": "refresh_token",
                "refresh_token": self.refresh_token,
                "client_id": self.client_id,
                "client_secret": self.client_secret,
            }
        )
        resp.raise_for_status()
        self.token = resp.json()["access_token"]

    def get(self, endpoint, params=None, _retry=True):
        self._throttle()
        resp = requests.get(
            f"{self.BASE_URL}{endpoint}",
            headers={"Authorization": f"Bearer {self.token}"},
            params=params
        )
        if resp.status_code == 429:
            time.sleep(5)  # Back off full window; no Retry-After header available
            return self.get(endpoint, params, _retry=True)
        if resp.status_code == 401 and _retry:
            self._refresh_access_token()
            return self.get(endpoint, params, _retry=False)
        if resp.status_code == 404:
            return None  # Caller should log and skip
        resp.raise_for_status()
        return resp.json().get("data", resp.json())
```

### Pagination

Officient pagination is **zero-indexed**. `page=0` returns the first page, `page=1` the second. Each page returns **30 items**. Iterate until you receive fewer than 30 items or an empty array.

If you need former employees for audit or reporting continuity, use the `include_archived=1` parameter on the people list endpoint. ([apidocs.officient.io](https://apidocs.officient.io/reference/list-people))

> [!WARNING]
> Officient wraps API responses in a `data` key. Make sure your extraction scripts unwrap this envelope — otherwise you'll pass nested objects to your transformation layer and mappings will break silently.

### Extraction Order

Extract in dependency order to preserve referential integrity:

1. **Company metadata** — departments, teams, cost centers
2. **Custom field definitions** — field names, types, allowed values
3. **People** — core employee records (paginate through all pages, use `include_archived=1` if needed)
4. **Roles** — for each person, fetch role/job-title history
5. **Contracts** — per person, including contract PDFs
6. **Wages** — salary components and one-off wage history
7. **Days Off** — leave balances and calendar entries
8. **Assets** — equipment assigned per employee
9. **Documents** — uploaded files (base64-encoded JSON responses)
10. **Fleet** — if applicable

> [!CAUTION]
> **Contract PDF links expire after 30 seconds.** Document download URLs are similarly short-lived — valid only for a few minutes. Fetch and persist files immediately during extraction. Do not collect URLs for later download. Hash files on ingest (SHA-256) so you can prove document completeness during validation and satisfy audit requirements. ([apidocs.officient.io](https://apidocs.officient.io/reference/contract-pdf))

## Transforming Officient Data for Hailey HR

This is a **fan-in operation**: you are reassembling a decomposed entity graph into a unified employee record while simultaneously translating between Belgian and Nordic HR conventions. Both transformations must happen before any data touches Hailey.

### Rehire Detection

Rehires are a common silent failure mode. An employee who left in 2019 and returned in 2021 will appear in Officient as two contracts with a gap — not as a flag or indicator. Your transformation must detect this pattern and handle it correctly, or Hailey will calculate incorrect tenure.

Detection logic:

```python
def detect_rehires(contracts):
    """
    Returns list of (gap_days, end_contract, start_contract) tuples
    where gap_days > 30 indicates a likely rehire rather than
    a contract type change or brief gap.
    """
    sorted_contracts = sorted(contracts, key=lambda c: c["start_date"])
    gaps = []
    for i in range(1, len(sorted_contracts)):
        prev_end = sorted_contracts[i-1].get("end_date")
        curr_start = sorted_contracts[i]["start_date"]
        if prev_end:
            gap = (parse_date(curr_start) - parse_date(prev_end)).days
            if gap > 30:
                gaps.append((gap, sorted_contracts[i-1], sorted_contracts[i]))
    return gaps
```

A gap greater than 30 days between contract end and the next contract start is a candidate rehire. Flag these for HR confirmation before deciding whether to model them as a new employment in Hailey or a continuation.

### Personal Number Format Translation

Belgian NISS and Nordic personal numbers are structurally incompatible. Format rules by country:

| Country | Format | Example | Validation Notes |
|---|---|---|---|
| Belgium (NISS) | YY.MM.DD-XXX.CC (11 digits) | 85.03.21-123.45 | Modulo-97 check digit |
| Sweden (personnummer) | YYYYMMDD-XXXX (12 digits) | 19850321-1234 | Luhn variant; last digit is control digit |
| Denmark (CPR) | DDMMYY-XXXX (10 digits) | 210385-1234 | Modulo-11 check (with exceptions for post-2007 births) |
| Norway (fødselsnummer) | DDMMYYXXXXX (11 digits) | 21038512345 | Two check digits (modulo-11) |
| Finland (henkilötunnus) | DDMMYY-XXXX (11 chars) | 210385-123A | Alphanumeric control character |

Do not migrate Belgian NISS values into Hailey HR's personal number fields for Nordic employees. The formats are incompatible and would fail Nordic payroll validation. Obtain correct Nordic personal numbers from employees before migration; archive NISS values in your secure HR records if needed for historical compliance, but do not populate Hailey's `personalNumber` field with a NISS value.

### Belgian-to-Nordic Leave Type Mapping

Officient's Belgian leave categories have no direct Nordic equivalents. Build this mapping table with HR before any time-off data is imported. An incomplete but foundational starting point:

| Officient (Belgian) Leave Type | Nordic Approximate Equivalent | Migration Approach |
|---|---|---|
| Wettelijk verlof / Congé légal (20 days statutory) | Swedish semester (25 days), Danish ferie (25 days) | Different accrual rules; calculate final balance, inject as manual adjustment |
| ADV dagen / Jours RTT (reduction of working time) | No direct equivalent | Archive as custom field or notes; coordinate with HR |
| Europees verlof / Congé européen | No direct equivalent | Archive; discuss with HR whether to carry forward |
| Belgische feestdagen (10 public holidays) | Nordic country-specific public holidays | Hailey manages public holidays via country calendar; do not import as leave entries |
| Ziekteverlof / Congé maladie | Sjukfrånvaro (SE) / Sygefravær (DK) / Sykefravær (NO) | Map to nearest equivalent; validate local legal rules with HR |
| Vaderschapsverlof / Congé de paternité | Pappaledighet (SE) / Barselsorlov (DK) | Map to nearest equivalent; validate |
| Onbetaald verlof / Congé sans solde | Obetald ledighet | Direct functional equivalent; map |

For most migrations, the safest approach is to calculate the final accrued balance for each leave type in Officient as of the cutover date, then inject those balances into Hailey as manual adjustments rather than replaying the full day-by-day history. This avoids unmappable historical entries while preserving the correct starting balance.

### Custom Field Type Translation

Hailey HR's custom field type system supports: `text`, `number`, `date`, `select` (single value from predefined list), `boolean`. It does not natively support `multiSelect` or `person reference` as field types.

Translation rules:

| Officient Type | Hailey Type | Transformation |
|---|---|---|
| `text` | `text` | Direct map |
| `number` | `number` | Direct map |
| `date` | `date` | Verify ISO 8601 format (YYYY-MM-DD) |
| `multiSelect` | `text` | Flatten to comma-separated string; document the change |
| `longText` | `text` | Check for truncation; Hailey text fields have limits |
| `person reference` | `text` | Resolve to employee full name before import; IDs won't be valid across systems |
| `boolean` | `boolean` or `select` | Verify Hailey target type supports boolean; use `select` with Yes/No if not |

> [!TIP]
> Create all custom field definitions in Hailey **before** importing employee data. Custom field values reference field definitions by ID — if the definition doesn't exist at import time, the import will silently drop those values with no error.

### Transformation Code

```python
from datetime import date

def today():
    return date.today().isoformat()

def resolve_department(person, roles):
    # Use most recent role for current department
    sorted_roles = sorted(roles, key=lambda r: r.get("start_date", ""), reverse=True)
    return sorted_roles[0].get("department") if sorted_roles else None

def resolve_manager(roles):
    sorted_roles = sorted(roles, key=lambda r: r.get("start_date", ""), reverse=True)
    return sorted_roles[0].get("manager_id") if sorted_roles else None

def transform_employee(person, contracts, wages, roles):
    active_contract = next(
        (c for c in sorted(contracts, key=lambda x: x["start_date"], reverse=True)
         if not c.get("end_date") or c["end_date"] > today()),
        None
    )
    active_wage = next(
        (w for w in sorted(wages, key=lambda x: x["start_date"], reverse=True)
         if not w.get("end_date") or w["end_date"] > today()),
        None
    )
    all_contract_starts = [c["start_date"] for c in contracts if c.get("start_date")]

    return {
        "external_id": person["id"],
        "first_name": person["first_name"],
        "last_name": person["last_name"],
        "email": person["email"],
        "hire_date": min(all_contract_starts) if all_contract_starts else None,
        "current_salary": active_wage["amount"] if active_wage else None,
        "currency": active_wage.get("currency", "EUR") if active_wage else None,
        "weekly_hours": active_contract.get("weekly_hours") if active_contract else None,
        "department": resolve_department(person, roles),
        "manager_external_id": resolve_manager(roles),
        # Note: Belgian NISS (person["national_number"]) intentionally excluded;
        # Nordic personal number must be sourced separately
    }
```

## Loading Data into Hailey HR

### The Hybrid Load Approach

Do not design this as a pure API-to-API migration. The write-API coverage gap described above requires a hybrid strategy:

1. **CSV/Excel import** for core employee profiles (name, email, department, job title, start date, personal number) and organizational structure — departments, titles, teams, cost centres, workplaces, and managers
2. **CSV import** for employment history and salary records using Hailey's import templates
3. **API import** for time-off data using the dedicated import endpoint
4. **API upload** for employee documents (within the 30 MB per-file limit; for larger files, coordinate with Hailey or split documents)
5. **Manual setup** for workflow automations, contract templates, and signing flows

Load in strict dependency order:

1. **Departments, teams, cost centers** — organizational structure must exist before employees reference it
2. **Employee profiles** — bulk CSV import for core records
3. **Employment records** — via import templates, including historical employments if needed
4. **Manager relationships** — update after all employees exist (requires lookup of Hailey-assigned IDs, not Officient IDs)
5. **Salary data** — via CSV import using Hailey's salary templates
6. **Time-off data** — API import via the dedicated time-off endpoint
7. **Documents** — upload via API (30 MB per file limit)
8. **Custom field values** — via API after field definitions exist

### Time-Off Import Specifics

Hailey's time-off import endpoint accepts an array of employment time-offs, each containing an `employeeId`, `employmentId`, and an array of `timeOffs`. Each time-off entry requires: `date` (ISO 8601), `timeOffReasonId`, `scope` (full/half day), and `minutes`.

> [!CAUTION]
> **Do not import pending leave requests as historical time off.** Officient tracks request outcomes (`pending`, `accepted`, `declined`). Hailey's `ImportTimeOff` endpoint assumes all imported dates are approved. Filter out any Officient entries with `status: "pending"` before generating your import payload. Pending requests must be recreated manually in Hailey's workflow. ([apidocs.officient.io](https://apidocs.officient.io/reference/calendar-request-detail))

### Document Upload

The Hailey API accepts document uploads per employee with a **30 MB cap per file**. For files that exceed this limit, you have two options: coordinate with Hailey's implementation team for a manual upload process, or split multi-page PDFs into smaller files.

Before uploading, verify file integrity against the SHA-256 hashes captured during Officient extraction. After upload, retrieve the document metadata from Hailey's API and confirm the file is accessible.

## Field Mapping: Officient → Hailey HR

Build **crosswalk tables** before importing anything. These tables map each Officient source ID to its Hailey target ID, target object type, and any transformed values. The minimum set of crosswalk tables required:

- `person_id` (Officient) → `employeeId` (Hailey) — populated after initial employee import
- `manager person_id` → Hailey `employeeId` — required before manager relationships can be set
- Officient department IDs → Hailey department names/IDs
- Officient team IDs → Hailey team IDs
- Officient cost-center IDs → Hailey cost-center IDs
- Officient leave-type IDs → Hailey `timeOffReasonId` values
- Officient custom-field definition IDs → Hailey custom-field definition IDs

### Employee Core Fields

| Officient Field | Hailey HR Field | Transformation Notes |
|---|---|---|
| `first_name`, `last_name` | `firstName`, `lastName` | Direct map |
| `email` | `companyEmail` | Verify work vs. personal email distinction |
| `national_number` (Belgian NISS) | `personalNumber` (Nordic personnummer) | **Do not migrate directly** — see personal number section |
| `address` fields | Address fields | Country-code format differences |
| `department` | `department` | Map Officient department IDs to Hailey department names |
| `manager` (person_id reference) | Manager relationship | Requires Hailey employee ID lookup after initial load |
| `start_date` | `startDate` | Direct map; verify ISO 8601 format |
| `employee_type` | Employment type | Officient types (employee, freelancer, intern) → Hailey categories |

### Compensation

Belgian and Nordic salary structures are not equivalent. Belgian payroll distinguishes gross/net with social contributions calculated at the employer level and withheld at source. Nordic salary models vary by country but generally operate on gross-to-net calculations with different contribution structures.

| Officient Concept | Hailey HR Equivalent | Notes |
|---|---|---|
| Monthly wage components | Salary record | Belgian gross/net structure ≠ Nordic salary model; restructuring required |
| One-off wage components (bonus, expense) | No direct equivalent | Archive in custom fields or notes; do not import as salary history |
| Wage history | Salary history | Map chronologically; verify Hailey supports historical salary entries via CSV import |

If Hailey will feed payroll or finance after cutover, missing legal entity, organisation number, salary-type, or identity values can break downstream syncs even when the employee appears correctly in Hailey's UI. Validate these payroll-facing fields specifically — not just the profile screen.

### Contracts

Officient treats contracts as first-class entities with start/end dates, types, and PDF generation. Hailey HR embeds employment terms within the employee profile and supports contract workflows with PDF signing templates. Your options:

1. **Map** contract metadata (type, start date, end date) into Hailey's employment details
2. **Archive** historical contract PDFs by downloading from Officient (30-second link expiry) and uploading as documents in Hailey's rights-managed folders
3. **Accept** that contract-specific fields unique to Belgian labor law (Dimona references, Belgian contract types) have no Hailey equivalent
4. **Rebuild** future-facing contract templates and signing flows in Hailey — do not attempt to recreate historical signature workflow state

## GDPR and Cross-Border Compliance

This migration moves employee PII between two EU-based systems, potentially across different data residency jurisdictions.

**Officient** stores data in Belgian/EU infrastructure. **Hailey HR** stores sensitive employee data with Upcloud, a Finnish company with European data centers. Non-personal data sits on Microsoft Azure servers primarily in Amsterdam with backup in Dublin. Hailey was built post-GDPR with compliance designed in from the start and uses **crypto shredding** for data deletion.

Key considerations:

- **Legal basis for transfer:** You likely process employee data under GDPR Article 6(1)(b) (contractual necessity) or 6(1)(f) (legitimate interest). The migration itself must be covered by your existing processing grounds — it's a change of processor, not a change of purpose.
- **Data Processing Agreement:** Ensure you have a signed DPA with Hailey HR before any PII touches their systems. This should be part of your Hailey contract; verify it is in place before migration begins.
- **Data minimization:** Don't migrate data you no longer need. If Officient contains records for employees terminated years ago, assess whether GDPR retention policies require deletion rather than migration.
- **Hailey's data retention:** Hailey saves data for 30 days after end of agreement, then deletes it. Do not cancel Officient before verifying all data is confirmed in Hailey.
- **Right to erasure:** Hailey's 30-day post-agreement retention and crypto-shredding capabilities mean your exit plan is cleaner than many HRIS platforms. Document this for your privacy register.

> [!CAUTION]
> Do not migrate Belgian national numbers (NISS) into Hailey HR's personal number field for employees based in Nordic jurisdictions. NISS data has no processing purpose outside Belgian social security. Populate Nordic personal number fields (personnummer, CPR, fødselsnummer, henkilötunnus) with the appropriate local identifiers sourced directly from employees, and retain NISS values in your secure HR archive only if needed for historical Belgian compliance.

## Step-by-Step Migration Process

### Step 1: Audit and Scope

Before writing any code:

- Export the full Officient employee list; count active vs. terminated employees and flag records with incomplete data (missing contracts, missing email, no start date)
- Identify which Officient entities you actually need in Hailey (not everything migrates — fleet, assets, and Dimona data do not)
- Map your Officient departments, teams, and cost centers to Hailey's organizational structure; create any missing structures in Hailey first
- Confirm Hailey HR write-API coverage with Hailey support — document which entities require CSV import vs. API loading
- Lock the Hailey-side vocabulary: legal entities, salary types, time-off reasons, document folders, retention rules, and access controls
- Establish all custom field definitions in Hailey before any employee data is imported
- Identify orphaned records in Officient (employees without contracts, deprecated time-off policies, terminated employees with unreturned assets)
- Obtain Nordic personal numbers from employees — do not assume these exist in Officient

### Step 2: Build the Extraction Pipeline

- Implement an Officient API client with sliding-window rate limiting (30 req/5 sec) and 401/429/404 error handling as described above
- Extract in dependency order: company metadata → custom fields → people → roles → contracts → wages → days off → assets → documents
- Use `include_archived=1` to include former employees if needed for audit or reporting
- Store extracted data in a structured staging format — JSON files per entity type keyed by Officient person ID, or a local SQLite/PostgreSQL staging database
- Download contract PDFs and document files immediately upon URL receipt (30-second and few-minute expiry respectively); compute SHA-256 hashes on ingest and log a manifest of all files with their hashes

### Step 3: Transform

- Build crosswalk tables mapping all Officient IDs to Hailey target IDs (populate the Hailey side after each load step)
- Run rehire detection on contract sequences; flag for HR review before proceeding
- Map Officient's multi-entity employee graph to Hailey's employment model — do not flatten employment history into a single row if the employee has legitimate parallel or sequential employments
- Generate CSV files matching Hailey's import templates for bulk employee import
- Build explicit leave-type mapping (use table above as a starting point; validate with HR)
- Calculate final leave balances as of cutover date for injection as manual adjustments
- Convert Belgian wage structures to Nordic salary format
- Translate personal number formats; flag any employees for whom Nordic personal numbers are missing
- Translate custom field types per the mapping table above
- Flag records that fail validation (missing required fields, format mismatches, circular manager references)

### Step 4: Load into Hailey HR

Follow the load order specified above. After each step, export the relevant data from Hailey's read API and verify record counts and spot-check field values before proceeding to the next step. Do not load all entity types in a single pass and validate at the end — incremental validation catches problems before they cascade.

### Step 5: Validate

Push 5% of the workforce to a Hailey HR sandbox first. Then run a field-by-field comparison between source (Officient extract) and target (Hailey HR data):

- **Record count:** Same number of active employees in both systems; terminated employees present if in scope
- **Identity fields:** Name, email, personal number match exactly (note: personal numbers will differ by design — verify Nordic format correctness, not NISS)
- **Employment data:** Start dates, departments, job titles align; rehires modeled correctly
- **Manager tree:** Reporting lines are correct with no circular references; validate the full org chart, not just a sample
- **Salary data:** Current salary figures match; verify currency is correct (EUR → SEK/DKK/NOK/EUR depending on country)
- **Time-off balances:** Accrued balances match the cutover-date snapshot from Officient; confirm no pending requests imported as approved
- **Documents:** Spot-check that uploaded files are accessible; compare SHA-256 hashes against the extraction manifest
- **Custom fields:** Verify values present for all employees who had values in Officient; check for silently dropped fields
- **Downstream integrations:** Test every payroll, finance, and identity integration that depends on Hailey data — validate data at the integration layer, not just in Hailey's UI

### Step 6: Cutover

- Freeze Officient for writes (no new hires, no data changes) and communicate the freeze window to HR
- Run a final delta extraction to catch changes since the last full extract — use Officient's webhooks (`person.created`, `person.updated`) or query for records modified after your last extraction timestamp
- Apply the delta to Hailey
- Switch employee-facing access to Hailey HR
- Monitor Hailey webhooks for the first week to confirm employee modifications flow correctly to downstream systems
- Maintain **read-only Officient access for 30–60 days** as a reference and audit source

**On rollback:** There is no automated path to reverse this migration. Hailey HR does not export data in Officient's format, and Officient does not provide a bulk import API. If migration is abandoned after cutover, data must be re-entered into Officient manually or re-extracted from Hailey's read API and reformatted. Plan accordingly: a rollback decision must be made within the read-only Officient window, and the cost of rollback is high enough that it's effectively a one-way door past the 30-day Officient retention period.

## Common Failure Modes

**Officient 429 with no retry guidance.** Officient returns an empty body on HTTP 429 — no `Retry-After` header, no error message. If your extraction script doesn't handle this explicitly, you'll get silent data gaps. Implement the sliding-window throttle shown above, plus retry-on-429 with a full 5-second backoff.

**Token expiry mid-extraction.** Long extraction runs will exceed access token lifetime. If your client treats 401 as a fatal error rather than a refresh trigger, the extraction stops silently partway through.

**Manager circular references.** If manager A reports to manager B and vice versa in Officient, Hailey may reject the relationship or create an unresolvable hierarchy. Extract the full org chart first, validate the tree structure (no cycles), and resolve circular references with HR before loading.

**Employment history flattened into one row.** This breaks managers, titles, dates, salary history, and reporting. Hailey supports parallel and historical employments — use that structure instead of collapsing everything into a single record.

**Rehires treated as continuous employment.** A gap greater than 30 days between contracts in Officient is likely a rehire. Misclassifying this as continuous employment produces incorrect tenure calculations and may affect leave entitlement in Hailey.

**Pending leave becomes approved leave.** A direct consequence of bulk-importing unresolved time-off requests through Hailey's import endpoint. Filter out `status: "pending"` entries before generating the import payload.

**Contract date overlaps.** Multiple contracts with overlapping date ranges will confuse Hailey's employment-status logic. Validate date ranges in your transformation step before loading.

**Document completeness unprovable.** Officient's contract PDF links expire in 30 seconds, and document download URLs expire within minutes. If your extraction script collects URLs and fetches them later, you'll get 403 errors and missing files. Fetch immediately, hash on ingest, and log a manifest.

**Historical contract gaps for rehires.** If you don't detect and correctly model rehires, Hailey may calculate incorrect hire dates and tenure. Use the detection logic above and confirm rehire classification with HR.

**Archived employees missing from target.** If the Officient export omits `include_archived=1`, terminated employees won't be in the extract. This only surfaces weeks later when HR requests a leaver's record.

**Payroll sync fails after go-live.** Legal entity, organisation number, salary-type mapping, and identity fields often matter more to downstream payroll than to the HRIS profile screen. Validate these specifically before cutover — a clean-looking Hailey profile can still break payroll if the integration-facing identifiers are wrong.

**Time-off type mismatch.** Officient's Belgian leave categories (ADV days, European leave, Belgian public holidays) have no direct Nordic equivalents. Use the mapping table above, validate with HR, and handle unmappable types as manual adjustments or archived notes rather than import failures.

**Custom field values silently dropped.** If custom field definitions don't exist in Hailey before import, field values are silently discarded with no error. Create all definitions first.

**No rollback path after Officient cancellation.** Once Officient's 30-day post-cancellation retention expires, the data is gone. The only recovery path after that point is Hailey's read API, which does not produce Officient-compatible output.

## What This Migration Won't Cover

Be explicit with stakeholders about what doesn't transfer:

- **Belgian payroll connector configurations** — SD Worx, Partena Professional, and Nmbrs integrations don't carry over. New Nordic payroll integrations must be established separately.
- **Dimona declarations** — Belgian social security filings have no Nordic equivalent.
- **Fleet management data** — Hailey HR has no fleet module. Use a standalone fleet tool or a Hailey integration partner.
- **Performance review history** — Both platforms have performance features, but review templates, scores, and feedback history don't transfer structurally. Export as PDFs for archival.
- **Workflow automations** — Officient's custom workflows must be rebuilt in Hailey's workflow engine. There is no migration path for automation logic.
- **Orphaned assets** — If Officient tracks equipment for terminated employees who never returned it, decide whether to clean this data before extraction or carry it into Hailey as archived notes.
- **Historical pending Dimona corrections or disputes** — Belgian social security edge cases are not in scope for any HRIS migration to a Nordic platform.

## Making the Right Call

The Officient-to-Hailey migration is architecturally a **fan-in operation**: reassembling a decomposed entity model into a unified employee record while translating between Belgian and Nordic HR conventions. The API extraction from Officient is well-supported but rate-limited. The load into Hailey requires a hybrid approach (CSV + API + manual) because the write API doesn't cover every entity type.

Plan for 2–4 weeks of elapsed time for a 200-employee migration: extraction scripting and testing (3–5 days), field-mapping validation with HR (2–3 days), two dry runs in Hailey sandbox (3–4 days), payroll integration validation (2–3 days), and cutover with a monitored stabilization period (1 week). Don't try to migrate everything — prioritize active employees with current contracts and current salary data, and archive historical records in a structured export.

For migrations exceeding 300 active employees with full contract and wage history, multi-country operations requiring simultaneous Belgian decommission and Nordic onboarding, or payroll integration switchovers running in parallel with the HRIS cutover, the complexity compounds: plan for 6–10 weeks, multiple stakeholder sign-offs, and dedicated engineering time.

> Planning an Officient to Hailey HR migration with real history, documents, and payroll dependencies? ClonePartner handles extraction, field mapping, compliance, and validation — so your HR team doesn't have to become API engineers for three weeks.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Is there a built-in migration tool from Officient to Hailey HR?

No. There is no native migration path, export wizard, or import tool between the two platforms. Every migration requires API extraction from Officient, custom data transformation, and loading into Hailey HR via API, CSV import, or manual upload.

### What is the Officient API rate limit for data extraction?

Officient enforces a hard limit of 30 requests per 5 seconds. Exceeding this returns HTTP 429 with an empty body and no Retry-After header. You must implement client-side throttling with a sliding-window rate limiter and retry logic.

### How do I handle Belgian national numbers (NISS) when migrating to a Nordic HRIS?

Belgian NISS numbers have no processing purpose outside Belgian social security. Do not migrate them as active identifiers in Hailey. Archive NISS values securely, and populate Nordic personal number fields (personnummer, CPR) with the appropriate local identifiers.

### Does Hailey HR have a write API for bulk employee import?

Hailey HR's API includes a Quick Add Employee endpoint and a time-off import endpoint, but write coverage is limited compared to its read API. For bulk employee creation, employment records, and salary history, Hailey's Excel/CSV import is typically more practical. Confirm available write endpoints with Hailey support before planning your load pipeline.

### What happens to pending time-off requests during migration?

Do not bulk-import pending requests as historical leave. Hailey's ImportTimeOff endpoint assumes all imported dates are already approved. Pending requests should be recreated separately in Hailey's workflow or left in the source archive.
