Skip to content

HeavenHR to Officient Migration: Data Mapping and API Limits

Technical guide to migrating HR data from HeavenHR to Officient. Covers API extraction, field-by-field mapping, rate limits, and step-by-step loading.

Nachi Raman Nachi Raman · · 26 min read
HeavenHR to Officient Migration: Data Mapping and API Limits
TALK TO AN ENGINEER

Planning a migration?

Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.

Schedule a free call
  • 1,500+ migrations completed
  • Zero downtime guaranteed
  • Transparent, fixed pricing
  • Project success responsibility
  • Post-migration support included

HeavenHR to Officient Migration: Data Mapping and API Limits

Migrating from HeavenHR to Officient (Exact Officient) means moving employee data from a Germany-centric HRIS with a relatively flat, embedded data model to a Belgian-rooted HR platform that decomposes employees into separate, independently managed entities and enforces strict validation at write time. There is no native migration path between these two systems — no export wizard in HeavenHR that speaks Officient's schema, and no Officient import tool that understands HeavenHR's structure.

Every migration requires extracting data from HeavenHR (via its REST API or manual CSV export), transforming that data to match Officient's separated entity model, and loading it via the Officient API in strict dependency order.

The core challenge is architectural. HeavenHR stores an employee as a single object with contract, work-schedule, and compensation data embedded inline. Officient decomposes those same concepts into separate resources — People, Roles, Wages, Functions, Contracts, Days Off, Assets, Weekly Schedules, and Custom Fields. Data that lives as nested JSON in a HeavenHR employee record must be split, validated against Officient's field-level constraints, and loaded sequentially. If you attempt a direct 1:1 mapping, Officient will reject overlapping contract dates, drop unmapped time-off types, and fail to calculate accruals correctly.

This guide covers the API constraints on both sides, field-by-field mapping, rate-limit math, idempotency and failure recovery, error message examples, compliance considerations, and the step-by-step process to execute a clean migration.

For the broader HRIS migration framework, see The Ultimate HRIS Data Migration Checklist. If you are handling sensitive payroll data during this move, review How to Safely Migrate Sensitive Employee & Payroll Data.

Why Teams Move from HeavenHR to Officient

HeavenHR is a Berlin-based HRIS focused on Germany, Austria, Switzerland, and France. It handles digital personnel files, absence management, time tracking, and integrated payroll processing with real-time calculations and statutory reporting to German social security agencies (Deutsche Rentenversicherung, Bundesagentur für Arbeit).

Officient (now Exact Officient) is a Belgian HR platform acquired by Exact in 2020. It serves SMEs primarily in Belgium and the Netherlands with modules for employee records, contracts, days off, assets, fleet, expenses, and payroll integrations with Belgian social secretariats: SD Worx, Securex, and Partena Professional.

The typical migration triggers:

  • Geographic shift: A company moving its operational center from Germany to the Benelux needs native Belgian payroll compliance — DIMONA declarations (mandatory employer notification to ONSS/RSZ within one working day of employment start), NISS/INSZ national number validation, and direct integrations with Belgian social secretariats.
  • Exact ecosystem consolidation: Companies already running Exact Online for accounting want a single-vendor HR stack with native data flow between finance and HR. Officient's Exact Online connector synchronizes employee cost center allocations and payroll journals directly.
  • Cost and complexity reduction: Smaller teams (25–200 employees) in Western Europe find Officient's pricing and feature scope better matched to their needs than HeavenHR's German payroll-heavy model.
  • Payroll provider change: Switching from a German Lohnabrechnung provider to a Belgian social secretariat fundamentally changes which HRIS can serve as the system of record. Belgian social secretariats pull employee master data directly from Officient; HeavenHR cannot supply that data feed.

The Architectural Mismatch

HeavenHR uses a relatively flat employee profile where compensation, working hours, and contract details are tightly coupled to the primary employee record. It is optimized for the German market, natively handling concepts like Minijobs, specific health insurance providers (Krankenkassen), and German tax IDs (Steueridentifikationsnummer).

Officient relies on a strict, relational schema-on-write model. An employee in Officient is a shell (a Person object). To make that Person functional, you attach a Contract. To pay them, you attach a Wage. To define working patterns, you set a Weekly Schedule. Each entity has its own API endpoint, its own lifecycle, and its own validation rules.

Info

Schema-on-write validation: Officient enforces strict data integrity on every write. If you try to insert a contract that overlaps with an existing contract for the same employee by even one day, the API returns HTTP 400 with a validation error (see Officient API Error Reference below). HeavenHR allows more historical ambiguity, so your source data is likely messier than Officient will accept.

Think in target objects, not source rows:

HeavenHR employee (single embedded object)
  → Officient Person          (POST /1.0/people)
  → Officient Role            (POST /1.0/people/{id}/roles)
  → Officient Wage            (POST /1.0/people/{id}/wages)
  → Officient Weekly Schedule (POST /1.0/people/{id}/schedules)
  → Officient Contract        (POST /1.0/people/{id}/contracts)
  → Officient Custom Fields   (PUT  /1.0/people/{id}/custom-fields/{field_id})
  → Officient Documents       (POST /1.0/people/{id}/documents)
  → Officient Assets          (POST /1.0/people/{id}/assets)

Canonical transformation example

Below is an abridged HeavenHR employee record and the corresponding Officient API call sequence it produces. This is the shape of the transformation your script must perform for every employee.

HeavenHR source record (GET /api/v2/employees/{id}):

{
  "id": "emp-4821",
  "firstName": "Anneliese",
  "lastName": "Müller",
  "email": "anneliese.mueller@example.com",
  "gender": "FEMALE",
  "dateOfBirth": "1988-03-15",
  "jobTitle": "Senior Accountant",
  "departmentId": "dept-9",
  "employeeNumber": "EMP-0042",
  "startDate": "2021-06-01",
  "permanentOrTemporary": "PERMANENT",
  "contract": {
    "grossSalary": 62000,
    "settlementPeriodSalary": "perYear",
    "workingHoursPerWeek": 40,
    "holidaysPerYear": 25
  },
  "workSchedule": {
    "days": [
      {"day": "MONDAY", "worktimeInMinutes": 480},
      {"day": "TUESDAY", "worktimeInMinutes": 480},
      {"day": "WEDNESDAY", "worktimeInMinutes": 480},
      {"day": "THURSDAY", "worktimeInMinutes": 480},
      {"day": "FRIDAY", "worktimeInMinutes": 480}
    ]
  }
}

Resulting Officient API call sequence:

// Call 1: Create Person
POST /1.0/people
{
  "first_name": "Anneliese",
  "last_name": "Müller",
  "personal_email": "anneliese.mueller@example.com",
  "gender": "female",
  "date_of_birth": "1988-03-15",
  "employee_number": "EMP-0042"
}
// → returns { "id": 8801 }
 
// Call 2: Assign Role
POST /1.0/people/8801/roles
{
  "job_title": "Senior Accountant",
  "team_id": 14,           // pre-translated from HeavenHR dept-9
  "start_date": "2021-06-01"
}
 
// Call 3: Create Wage
POST /1.0/people/8801/wages
{
  "amount": 62000,
  "period": "annual",      // from settlementPeriodSalary: perYear
  "currency": "EUR",
  "start_date": "2021-06-01"
}
 
// Call 4: Create Weekly Schedule
POST /1.0/people/8801/schedules
{
  "monday": 480,
  "tuesday": 480,
  "wednesday": 480,
  "thursday": 480,
  "friday": 480,
  "saturday": 0,
  "sunday": 0
}
 
// Call 5: Create Contract
POST /1.0/people/8801/contracts
{
  "type": "permanent",
  "start_date": "2021-06-01"
}

This sequence — 5 API calls for one employee — scales to 600–900 calls for a 150-person company once you add custom fields, documents, and leave records.

HeavenHR API: Extraction Constraints

HeavenHR exposes a public REST API at https://api.heavenhr.com/api/v2/. Authentication uses OAuth 2.0 — you Base64-encode your client_id:client_secret, exchange it for an access token, and include that token in all subsequent requests.

Warning

HeavenHR's API documentation was last updated on 2020-11-30. Current HeavenHR marketing promotes HeavenHR 2.0 features that may not match the documented API surface. Confirm which API version your account is using by checking the response headers on a test call — look for an X-API-Version or similar field — before building extraction scripts.

Available extraction endpoints

Data Type Endpoint Method Notes
All employees (list) /employees GET Returns limited fields: id, name, employeeNumber, jobStatus, email, department
Employee detail /employees/{id} GET Full record with embedded contract and workSchedule
Company info /company/ GET Company name, custom attribute definitions
Organization tree /company/organizations GET Hierarchical department structure
Locations /company/locations GET Office locations with city and country
Cost centers /company/cost-centers GET Cost center names, numbers, and employee counts
Absence types /company/absences/types GET HOLIDAY, UNPAIDLEAVE, OVERTIME, ILLNESS, SPECIALLEAVE
Company absences /company/absences GET Requires startDate, endDate, status, page, pageSize
Employee absences /employees/{id}/absences/ GET Same parameters as company absences
Payroll periods /payroll/payrollperiod GET OPEN and CLOSED periods with date ranges
Salary details /payroll/payrollperiod/{id}/salary GET fixedSalary and others per employee
Time tracking /employees/{id}/timetracking GET Individual time entries with status

Pagination

HeavenHR uses zero-indexed, page-based pagination. The default pageSize varies by endpoint — 200 for the employee list, 10 for some others, 1000 for payroll periods. Always set pageSize explicitly to avoid surprises. The employee list endpoint supports an updatedAt filter, which is useful for delta pulls during cutover.

import requests
 
def extract_all_employees(base_url, token):
    employees = []
    page = 0
    while True:
        resp = requests.get(
            f"{base_url}/api/v2/employees",
            headers={"Authorization": f"Bearer {token}"},
            params={"page": page, "pageSize": 200}
        )
        data = resp.json()
        employees.extend(data["data"])
        if page >= data["totalPages"] - 1:
            break
        page += 1
    return employees

Key extraction gotchas

  • The employee list endpoint returns limited fields. You must call /employees/{id} individually for each employee to get contract, workSchedule, customAttributes, and address data. That means N+1 API calls for N employees.
  • Absence extraction requires date ranges. You cannot request "all absences ever" — you must specify startDate and endDate. Query in yearly chunks going back to your earliest employee start date.
  • Salary encoding in HeavenHR: The grossSalary field in /employees/{id} returns the annual or monthly gross as a plain decimal in the account's configured currency (e.g., 62000 for €62,000/year). However, /payroll/payrollperiod/{id}/salary returns fixedSalary as a long integer in millicents — the documented example value 1500000000 represents €15,000.00 (1,500,000,000 millicents ÷ 100,000). Do not mix these two salary sources without applying the correct conversion. Validate against a known employee's salary before bulk loading.
  • Custom attributes are company-level definitions. Retrieve them from /company/ first to build a mapping table between custom attribute IDs and their names/types.
  • Documents are not available via API. HeavenHR's public API does not expose a document download endpoint. You must export personnel files (contracts, payslips, certificates) through the HeavenHR UI manually.
  • Rate limits are not publicly documented. The API returns HTTP 429 on overload, but the exact threshold is unspecified. Implement a conservative client-side limiter — 5–10 requests per second with exponential backoff on 429 responses.
Warning

Encoding trap: HeavenHR exports (particularly CSV reports) often default to ISO-8859-1 (Latin-1) encoding due to the prevalence of German umlauts (ä, ö, ü, ß). Officient's API strictly expects UTF-8 JSON payloads. If you do not explicitly convert encoding before running your transformation scripts, you will corrupt employee names and addresses. In Python: text.encode('latin-1').decode('utf-8') — or use chardet to detect encoding before conversion.

Officient API: Loading Constraints

Officient exposes its API at https://api.officient.io/1.0/. Authentication uses OAuth 2.0 with Bearer tokens. Refresh tokens are long-lived and reusable.

Info

Officient recommends using a sandbox account during development. Contact support to get your app verified for third-party access before scoping your migration timeline. Sandbox environments mirror production validation behavior, so errors you encounter during dry runs reflect exactly what production will reject.

Rate limit: 30 requests per 5 seconds

This is the hard constraint that shapes your entire loading strategy. Officient enforces a maximum of 30 API calls every 5 seconds. Exceeding this returns HTTP 429 with an empty body and no Retry-After header — your client must implement its own backoff logic.

Migration time estimates by company size:

Employee Count Estimated API Calls Minimum Load Time (at 30/5s) With 15% Retry Overhead
50 200–300 ~50 seconds ~60 seconds
150 600–900 ~2.5 minutes ~3 minutes
300 1,200–1,800 ~5 minutes ~6 minutes
500 2,000–3,000 ~8 minutes ~10 minutes

These estimates cover Person + Role + Wage + Contract + Schedule only. Add ~3 calls per employee for custom fields, ~2 calls per historical absence year, and ~1 call per document upload.

import time
from collections import deque
 
class OfficientRateLimiter:
    def __init__(self, max_calls=28, window=5.0):  # 28 to leave headroom
        self.max_calls = max_calls
        self.window = window
        self.calls = deque()
 
    def wait_if_needed(self):
        now = time.monotonic()
        while self.calls and self.calls[0] < now - self.window:
            self.calls.popleft()
        if len(self.calls) >= self.max_calls:
            sleep_time = self.calls[0] + self.window - now
            time.sleep(max(sleep_time, 0.1))
        self.calls.append(time.monotonic())

Officient pagination

Officient returns 30 items per page, zero-indexed. This is fixed — you cannot request a larger page size. Plan for multiple pages even with modest employee counts when querying existing data for validation.

Officient's entity model

Officient Entity Description HeavenHR Source
People Core person record (name, email, national number, address) Employee base fields
Roles Job title, department, team assignment, role history jobTitle, departmentId, occupation
Wages Current compensation, salary components contract.grossSalary, contract.settlementPeriodSalary
Functions Job function classification occupation field
Contracts Employment contract with dates, type permanentOrTemporary, startDate, endOfContract
Days Off Leave records, budgets, calendar events Absence records (HOLIDAY, ILLNESS, etc.)
Weekly Schedule Work hours per day workSchedule.days
Assets Company equipment assigned to employees Not available via HeavenHR API
Cost Centers Financial allocation units Cost center data
Custom Fields Additional typed data points customAttributes

Officient API Error Reference

These are the actual error patterns Officient returns on common migration failures. Log the full response body on every 4xx — Officient's error messages are your primary debugging tool.

Contract date overlap

// POST /1.0/people/8801/contracts
// Request body: { "type": "permanent", "start_date": "2021-06-01" }
// When a contract already exists starting 2021-06-01:
 
HTTP 400 Bad Request
{
  "error": "validation_error",
  "message": "Contract dates overlap with an existing contract for this employee.",
  "field": "start_date"
}

Resolution: Query existing contracts for the person before posting. If an overlap exists, either delete the conflicting draft contract or adjust start/end dates so no day is covered by two contracts simultaneously.

Missing team reference

// POST /1.0/people/8801/roles
// Request body: { "team_id": 9999, "job_title": "Accountant", "start_date": "2021-06-01" }
// When team_id 9999 does not exist:
 
HTTP 400 Bad Request
{
  "error": "validation_error",
  "message": "The specified team does not exist.",
  "field": "team_id"
}

Resolution: Pre-create all teams before loading people. Store the Officient team ID alongside the HeavenHR department ID in your translation table.

NISS/INSZ validation failure

// POST /1.0/people
// Request body includes: { "national_number": "85.03.15-123.45" }
// When modulus check fails:
 
HTTP 400 Bad Request
{
  "error": "validation_error",
  "message": "National identification number is not valid.",
  "field": "national_number"
}

Resolution: Belgian NISS numbers use a modulus-97 check. Validate before submission: compute 97 - ((birth_date_digits + sequence_digits) mod 97) and compare against the check digits. For employees born after 2000, prepend 2 to the birth date component. If the HeavenHR record holds a German Steueridentifikationsnummer instead, pass null for national_number and set the correct country code.

Duplicate national number

HTTP 400 Bad Request
{
  "error": "validation_error",
  "message": "A person with this national identification number already exists.",
  "field": "national_number"
}

Resolution: Run a deduplication check against existing Officient people before loading. This occurs in dry-run-then-production sequences where sandbox data leaks into production, or when an employee has already been manually created in Officient.

Wage with invalid period

HTTP 400 Bad Request
{
  "error": "validation_error",
  "message": "Invalid wage period. Accepted values: hourly, daily, weekly, monthly, annual.",
  "field": "period"
}

Resolution: HeavenHR's settlementPeriodSalary uses perYear and perMonth. Map to Officient's annual and monthly respectively before posting.

Idempotency and Partial Failure Recovery

Officient's API does not support idempotency keys. If your migration script fails at employee 87 of 150 — due to a network timeout, rate limit burst, or validation error — you cannot simply re-run from the beginning without creating duplicates.

State file approach

Maintain a local state file (migration_state.json) that records the Officient ID for every successfully created entity, keyed by HeavenHR ID:

{
  "people": {
    "emp-4821": 8801,
    "emp-4822": 8802
  },
  "roles": {
    "emp-4821": 12201
  },
  "wages": {
    "emp-4821": 9901
  },
  "contracts": {
    "emp-4821": 7701
  }
}

Before each POST, check the state file. If an entry already exists, skip the create and use the stored Officient ID for downstream references. This makes the script safely re-runnable.

import json
import os
 
STATE_FILE = "migration_state.json"
 
def load_state():
    if os.path.exists(STATE_FILE):
        with open(STATE_FILE) as f:
            return json.load(f)
    return {"people": {}, "roles": {}, "wages": {}, "contracts": {}}
 
def save_state(state):
    with open(STATE_FILE, "w") as f:
        json.dump(state, f, indent=2)
 
def get_or_create_person(heavenhr_id, payload, state, client):
    if heavenhr_id in state["people"]:
        return state["people"][heavenhr_id]
    response = client.post("/1.0/people", json=payload)
    response.raise_for_status()
    officient_id = response.json()["id"]
    state["people"][heavenhr_id] = officient_id
    save_state(state)
    return officient_id

Pre-flight existence check

Before creating a person, query Officient by employee number or email to detect duplicates created during previous partial runs or manual entry:

def find_existing_person(employee_number, client):
    resp = client.get("/1.0/people", params={"page": 0})
    # Paginate through all results
    for person in resp.json()["data"]:
        if person.get("employee_number") == employee_number:
            return person["id"]
    return None

Failure classification

Not all errors are equal. Classify failures into three categories:

Error Type Example Action
Transient HTTP 429, HTTP 503, network timeout Retry with exponential backoff (max 5 retries)
Fixable validation Contract date overlap, missing team Fix source data, re-run that entity
Data quality NISS modulus failure, unknown field value Log to error report, require HR sign-off before re-run

Do not retry fixable validation errors automatically — they require human review of the source data.

Field-by-Field Data Mapping

This is where most migrations silently break. HeavenHR and Officient use different field names, different enumerations, and different data structures for conceptually identical information.

Employee core fields

HeavenHR Field Type Officient Target Transformation
firstName string Person → first_name Direct map
lastName string Person → last_name Direct map
email string Person → personal_email Map as personal email
professionalEmail string Person → work_email Direct map
gender string (MALE/FEMALE) Person → gender Map: MALEmale, FEMALEfemale
dateOfBirth date Person → date_of_birth YYYY-MM-DD (compatible)
nationality string (ISO) Person → nationality Verify ISO 3166-1 alpha-2 compatibility
streetPersonal + streetNumberPersonal strings Person → address fields Concatenate or map to structured address
phonePersonal string Person → phone Direct map
jobTitle string Role → job_title Direct map
occupation string Function → name Must pre-create function in Officient
departmentId string Role → team_id Requires ID translation table
locationId string Person → office/location Requires ID translation table
costCenterId string Cost center assignment Requires pre-creation in Officient
employeeNumber string Person → employee_number Direct map (verify uniqueness)

Officient applies modulus-97 validation on Belgian national numbers (NISS/INSZ) but accepts alphanumeric strings for other countries. Pass the correct country code to trigger the right validation path — omitting it defaults to Belgian validation rules and will fail for non-Belgian employees.

Contract and compensation mapping

This is the highest-risk area. HeavenHR embeds contract data inside the employee object. Officient treats contracts and wages as separate entities with their own lifecycle.

In HeavenHR, an employee might have a single start date and a current salary. If their working hours changed from 40 to 32 hours last year, HeavenHR might just log a change in the working time model. Officient requires a new contract or contract amendment for significant changes in working hours or legal status — you must reconstruct this history from HeavenHR's audit log or payroll period records.

HeavenHR Field Officient Target Risk Level Notes
contract.grossSalary Wage → amount High Plain decimal in account currency (e.g., 62000 = €62,000/year)
payroll.fixedSalary Wage → amount High Millicent encoding: divide by 100,000 to get euros
contract.settlementPeriodSalary Wage → period Medium perYearannual, perMonthmonthly
contract.holidaysPerYear Days Off → budget Medium Must create corresponding leave type first
contract.workingHoursPerWeek Weekly Schedule Medium Split into daily breakdown; preserve minutes
contract.probationPeriod Contract → probation Low Map enum (e.g., 6Months) to calculated end date
permanentOrTemporary Contract → type High Belgian payroll implications — see employment type table
startDate Contract → start_date Medium Direct map (ISO 8601)
endOfContract Contract → end_date Medium Only for temporary contracts

Salary encoding summary: Use contract.grossSalary (from /employees/{id}) as your wage source — this is plain decimal. Use payroll period salary data only for historical audit purposes, and always divide fixedSalary by 100,000 before comparison.

Contract date rule: Ensure start_date and end_date do not overlap between sequential contracts. If Contract A ends on 2022-12-31, Contract B must start on 2023-01-01. A gap of even one day will cause Officient to classify the employee as inactive during that gap, potentially affecting leave accrual calculations.

Employment type mapping

HeavenHR supports: FULL_TIME, PART_TIME, CONTRACT, TEMPORARY, WORKING_STUDENT, INTERNSHIP, TRAINEE, FREELANCER, SHORT_TERM_EXTERNAL_LOAN, OTHER. Officient's taxonomy is Belgium-oriented. The mapping below is a starting point — every entry must be reviewed with your HR team because employment type drives DIMONA declaration codes, ONSS contribution rates, and social secretariat reporting:

{
  "FULL_TIME": "full_time",
  "PART_TIME": "part_time",
  "FREELANCER": "freelancer",
  "INTERNSHIP": "intern",
  "WORKING_STUDENT": "student",
  "TRAINEE": "intern",
  "CONTRACT": "fixed_term",
  "TEMPORARY": "fixed_term",
  "SHORT_TERM_EXTERNAL_LOAN": "contractor",
  "OTHER": "other"
}

Getting employment type wrong in a Belgian context means incorrect DIMONA declarations, wrong ONSS contribution calculations, and potential fines from the social inspectorate. WORKING_STUDENT in Germany (geringfügig Beschäftigte) is not equivalent to a Belgian student contract (student arbeid/travail étudiant), which has its own ONSS reduction regime (up to 475 hours/year at reduced rates). Confirm with your Belgian social secretariat before mapping student-type contracts.

Absence type mapping

HeavenHR defines absence types with a vacationType enum: HOLIDAY, UNPAIDLEAVE, OVERTIME, ILLNESS, SPECIALLEAVE. Officient uses its own category structure. Before loading any absence data, query Officient's available leave types and build a mapping:

HeavenHR vacationType Officient Leave Category Belgian Equivalent Notes
HOLIDAY Paid leave / Vacation Wettelijk verlof / Congé légal Belgian minimum: 20 days for full-time
UNPAIDLEAVE Unpaid leave Onbetaald verlof May need to be created in Officient
OVERTIME Comp time / Recuperatie Compensatieverlof Verify if Officient has equivalent category
ILLNESS Sick leave Ziekteverlof Belgian: 30 days guaranteed pay, then mutualiteit
SPECIALLEAVE Special leave Klein verlet Map to specific Belgian leave types (marriage, birth, bereavement)
Tip

German and Belgian leave regulations differ in calculation method. German Urlaub accrues based on working days (minimum 20 for 5-day week, typically 24–30 by collective agreement). Belgian wettelijk verlof is calculated on the previous year's working days and paid at a "double holiday pay" rate (dubbel vakantiegeld) equivalent to roughly 15.38% of annual gross. Do not assume accrued German leave balances convert 1:1 to Belgian balances — consult your Belgian social secretariat on how to handle the transition-year calculation.

Migrate net balances as of your cutover date rather than day-by-day accrual rules. For historical absences, load them as approved calendar events with status: approved. One hard constraint: Officient requires all calendar events in a single API call to belong to the same calendar year — chunk historical absence loads by year.

HeavenHR absence statuses: PLANNED, REQUESTED, APPROVED, REJECTED, WITHDRAWN. Only APPROVED absences map cleanly to Officient calendar events. Handle the others as follows:

  • PLANNED / REQUESTED — create as pending requests or migrate manually
  • REJECTED / WITHDRAWN — exclude from migration; log for reference

Belgian Payroll Downstream: What Must Be Correct in Officient

If Officient feeds a Belgian social secretariat (SD Worx, Securex, or Partena Professional) via its native integration, data quality in Officient directly determines payroll correctness. These are the fields the integration reads:

Field Why It Matters Consequence of Error
national_number (NISS/INSZ) Primary key for ONSS declarations DIMONA rejected; employee not registered
contract.type Determines ONSS contribution category Wrong contribution rate; potential penalty
contract.start_date DIMONA declaration timing Late declaration fine (€30–€1,250 per incident)
wage.amount + wage.period Gross pay calculation base Incorrect net salary and tax withholding
schedule (weekly hours) Part-time reduction calculation Wrong ONSS base for part-time workers
employee_number Reconciliation key with secretariat Duplicate or mismatched employee files

Verify these six fields for 100% of employees before authorizing payroll secretariat sync. A single incorrect NISS on a DIMONA declaration triggers a manual correction process with ONSS that can delay an employee's social insurance activation.

Step-by-Step Migration Process

Step 1: Decide what must be structured on day one

Separate operational data from audit data. Operational data is whatever Officient users need immediately: current employees, managers, departments, wages, schedules, current leave balances, active contracts, and assigned assets. Audit data is everything you may need later for legal or payroll investigation: closed payroll periods, old payslips, older contracts, and withdrawn or rejected requests.

HeavenHR-era payroll outputs are usually better preserved as immutable archive files than re-modeled as live Officient wage records. Officient's wage surface is built around current compensation, not around replaying German payroll periods.

Step 2: Audit and extract from HeavenHR

  1. Retrieve custom attribute definitions from /company/ — you need these to decode custom field IDs.
  2. Extract the organization tree from /company/organizations and locations from /company/locations.
  3. Pull the full employee list from /employees with pagination.
  4. Fetch each employee's detail via /employees/{id} — this is where contract, schedule, and custom attribute values live.
  5. Extract absence records from /company/absences in yearly date-range chunks from each employee's start date to today.
  6. Pull payroll period data if you need salary history for audit purposes.
  7. Export documents manually via the HeavenHR UI — the API does not expose a document download endpoint.

Store everything as structured JSON with the HeavenHR employee ID as the primary key. This becomes your migration source of truth and your reconciliation reference after go-live.

Warning

If your compliance requirement is "every historical contract, payslip, and certificate must remain accessible," documents are not a tail-end task. They determine extraction method, storage cost, cutover timing, and what employees see on day one in Officient Self Service. Resolve the document strategy before writing any transformation code.

Step 3: Pre-create reference data in Officient

Officient enforces referential integrity. Before creating people, set up:

  • Teams (departments) — map from HeavenHR's organization tree
  • Functions — map from HeavenHR's occupation and jobTitle values
  • Cost centers — recreate from HeavenHR's cost center list
  • Leave types — ensure all HeavenHR absence types have Officient equivalents; create missing types before loading absence records
  • Custom field definitions — create any custom fields you need before loading people; the API expects the exact internal ID of the custom field, not the display name

Record the Officient IDs for each created entity in your translation table. Initialize your state file (migration_state.json) with these pre-created entities so the loading phase can reference them.

Step 4: Build the transformation layer

Create a mapping script that:

  1. Reads each HeavenHR employee record from your extracted JSON store
  2. Splits the embedded contract/schedule data into separate Officient entities
  3. Translates all IDs (department → team, location → office, cost center → cost center) using the translation table
  4. Normalizes field values (employment types, gender codes, date formats, encoding from ISO-8859-1 to UTF-8)
  5. Converts salary values: use contract.grossSalary directly; convert payroll fixedSalary by dividing by 100,000
  6. Validates Belgian-specific requirements (NISS/INSZ modulus-97 check for Belgian residents)
  7. Detects and resolves overlapping contract dates (truncate earlier contracts) and absence date conflicts (sick leave takes precedence)
  8. Chunks historical absence records by calendar year
  9. Outputs a loading manifest: an ordered list of API calls per employee, ready to execute with the rate limiter

Step 5: Dry run in Officient sandbox

Load 10–15 employees from diverse profile types (permanent full-time, part-time, temporary, student, terminated) into the Officient sandbox. Verify every field in the Officient UI — not just the API response. Common sandbox discoveries: custom field types that need adjustment, leave type mismatches that only appear in the calendar view, and document visibility settings that differ from expectation.

Fix transformation issues in the sandbox before loading production data. The sandbox mirrors production validation exactly.

Step 6: Load into Officient in dependency order

The loading sequence is non-negotiable. Officient will reject records that reference entities that don't yet exist.

  1. Custom fields (if not already created in Step 3)
  2. Teams and functions (if not already created in Step 3)
  3. People — core person records. Store the returned Officient id in your state file alongside the HeavenHR id.
  4. Manager relationships — set after all people exist
  5. Roles — job title, team assignment
  6. Wages — compensation data, effective-dated
  7. Contracts — employment terms with non-overlapping dates
  8. Weekly schedules — work hour distribution per day
  9. Days off — historical and current leave records, chunked by year
  10. Documents — uploaded via document API with visible_in_selfservice flag set appropriately
  11. Assets — company equipment

Apply the rate limiter throughout. Log every API response to a structured log file — Officient's 400 error messages (see Officient API Error Reference) are your primary debugging tool.

Step 7: Validate and reconcile

After loading, run a systematic comparison:

  • Headcount match: Total people in Officient equals total active employees extracted from HeavenHR.
  • Field-level spot checks: Sample 10–15% of employees and compare every field against the HeavenHR source record.
  • Compensation verification: Sum all annual wages in Officient and compare against HeavenHR's active payroll total. Variance should be zero — any discrepancy indicates salary encoding error or missing employees.
  • Leave balance audit: Verify that each employee's remaining leave days match the expected balance as of cutover date.
  • Manager hierarchy: Confirm reporting relationships render correctly in Officient's org chart.
  • Belgian payroll fields: Verify NISS, contract type, start date, and weekly schedule for all employees who will be in the first Belgian payroll run.
  • Self-service view: Test from the employee perspective. Verify calendars, documents, contracts, and wage information are visible and correct before issuing invitations.

Step 8: Run a final delta and cut over

Use a two-pass cutover. First pass: bulk load everything stable. Second pass: use HeavenHR's updatedAt filter to pull employees changed since the initial extract, recent approved absences, final contracts, and newly generated documents. Consult the state file — only load entities not already present in Officient.

Only after the second validation pass should you generate Officient self-service invitation links and announce the switch.

Warning

Do not invite employees into Officient before you validate managers, schedules, current leave balances, and document visibility. A technically successful load that exposes wrong balances or missing contracts is a failed migration from the employee's perspective — and creates support burden on day one.

Edge Cases and Failure Modes

Salary encoding mismatch between endpoints. contract.grossSalary (from /employees/{id}) is a plain decimal (€62,000 = 62000). fixedSalary (from payroll periods) is in millicents (62000000000 = €620,000 — clearly wrong if read as euros). Never mix salary values from these two endpoints without the correct conversion.

Terminated employees. For compliance and payroll audit reasons, migrate terminated employees with accurate contract end_date values. Officient classifies them as historical/inactive and does not bill for them as active headcount. Omitting terminated employees entirely creates gaps in employment history that can affect Belgian pension and social insurance reporting.

Overlapping leave requests. If an employee in HeavenHR has a sick leave record that overlaps with an approved vacation, Officient's API will reject the overlapping entry. Resolution: sick leave takes legal precedence; truncate the vacation record to end the day before sick leave begins, then insert the sick leave record. Log all truncations for HR review.

Work schedule minute precision. HeavenHR stores work time in minutes per day (worktimeInMinutes). Officient's schedule API accepts minutes. Do not convert to hours and back — 380 minutes is 6h20m, not 6.5h (390 minutes). Preserve minute values exactly.

Custom attribute type mismatches. A custom attribute stored as free text in HeavenHR may need to be a dropdown, date, or number in Officient. Audit every custom field's actual data values before defining the Officient field type. Redefining a custom field type after data is loaded requires deleting and re-creating the field and all associated values.

Missing reference data causing rejections. If you load a person referencing a team ID that does not exist in Officient, the API returns HTTP 400 (see error reference above). The person record is not created. Pre-create all reference entities in Step 3 and validate your translation table before loading any people.

Multi-year absence backfills. Officient requires all calendar events in one API call to belong to the same calendar year. Attempting to load a multi-year absence block in a single call returns a validation error. Chunk by year: one API call per year of absence history per employee.

Officient CSV import path. Officient does not offer a bulk CSV import for people, contracts, or wages via the standard UI as of current documentation. The API is the only programmatic loading path. For companies under 20 employees, manual UI entry may be faster than building the full extraction and transformation pipeline.

GDPR: Intra-EU Transfer Specifics

Both HeavenHR and Officient operate within the EU. HeavenHR stores data on servers in Germany; Officient operates within Exact's infrastructure in the Benelux. This is an intra-EU transfer, which means no Standard Contractual Clauses or adequacy decisions are required.

Key requirements that still apply:

  • Legal basis for processing: Your existing legal basis (contract performance or legitimate interest) covers the migration. Document the purpose and scope in your records of processing activities (RoPA).
  • Data minimization: Only migrate data you actually need in Officient. Historical records for terminated employees who left more than the applicable retention period ago should be deleted, not migrated.
  • Employee notification: GDPR Articles 13/14 require informing employees about the new data processor. Update your privacy notice to name Officient B.V. (a subsidiary of Exact Group B.V.) as a data processor, specifying the categories of data processed and the legal basis.
  • Data Processing Agreement: Exact provides a standard DPA covering Officient's processing of employee data. Ensure this DPA is executed before beginning any data transfer. Review whether it covers your specific data categories (national numbers, health data from sick leave records, bank account details if migrating payroll data).
  • Secure transfer: Use HTTPS for all API calls (both systems enforce TLS). If you stage data locally during transformation, encrypt the staging files at rest using AES-256 or equivalent.
  • Retention periods: Belgian statutory retention for employment records is generally 5 years after contract end for wage records and 30 years for pension-relevant data. Do not delete HeavenHR records immediately after migration — maintain the source system in read-only mode until retention obligations are clear.

When Not to Use Scripted Migration

A full API-to-API migration makes sense for companies with 50+ employees and structured data that maps cleanly between systems. Consider alternatives if:

  • You have fewer than 20 employees. Manual re-entry into Officient's UI may be faster than building extraction and transformation scripts. The scripted approach requires minimum 2–3 days of engineering time for a working pipeline; manual entry for 20 employees takes 4–6 hours.
  • Your HeavenHR data is mostly in documents, not structured fields. If the real value is in uploaded PDFs (contracts, certificates, ID copies), an API migration will not capture those — you need manual document transfer regardless, and the API script adds overhead without proportional benefit.
  • You are also changing payroll providers simultaneously. Coordinate the HRIS migration timeline with the Belgian social secretariat's onboarding requirements. SD Worx, Securex, and Partena Professional each have their own employee data intake formats — align the transformation logic with their requirements, not just Officient's schema.
  • Your HeavenHR data quality is too poor to migrate. If employee records are inconsistent, incomplete, or contradictory (a common finding in sub-50-employee companies that grew organically), a clean manual re-entry using Officient as the source of truth may produce a better result than migrating bad data.

Making the Move

A HeavenHR-to-Officient migration is a tractable engineering problem — the data volumes are SME-scale, both APIs are REST-based, and the GDPR picture is clean (EU-to-EU). The real risk lives in the details: salary encoding differences between HeavenHR endpoints, employment type payroll implications under Belgian law, absence balance translation across different statutory frameworks, contract date overlap rejection, NISS validation, and downstream social secretariat data requirements.

The teams that get this right invest time upfront in a complete data audit, build a thorough field mapping with HR sign-off on every enum translation, implement a state file for idempotent re-runs, and complete at least one full dry-run in the Officient sandbox before touching production data.

For teams moving off other European HR platforms, similar architectural challenges apply. See our guide to Humaans to Officient Migration for another example of navigating Officient's relational model, or our Hailey HR to PeopleStrong migration guide for a look at translating flat SME data into strict enterprise hierarchies.

Frequently Asked Questions

Can I migrate directly from HeavenHR to Officient?
No. There is no native migration path, import wizard, or direct integration between HeavenHR and Officient. You must extract data via HeavenHR's REST API (or manual CSV export), transform it to match Officient's data model, and load it through Officient's API.
What is Officient's API rate limit?
Officient allows a maximum of 30 API requests every 5 seconds. Exceeding this returns HTTP 429 with an empty response body and no Retry-After header. You must implement client-side rate limiting and backoff logic.
How long does a HeavenHR to Officient migration take?
For a typical SME (50–200 employees), the technical migration takes 3–7 days: 1 day for extraction and audit, 1–2 days for building the transformation layer and field mapping, 1 day for loading, and 1–2 days for validation and reconciliation. Complex scenarios with extensive custom fields or historical data take longer.
What data can't be migrated via the HeavenHR API?
HeavenHR's public API does not expose endpoints for downloading personnel documents (contracts, certificates, ID copies, payslips). These must be exported manually through the HeavenHR UI.
Can I migrate leave balances and absence history from HeavenHR to Officient?
Yes, but not as a blind copy. HeavenHR absences and Officient calendar events use different models and different statutory frameworks (German vs Belgian). Migrate net balances as of cutover, load approved historical absences chunked by year, and consult HR on how to translate accrued leave.

More from our Blog