How to Create an HRIS Integration

TL;DR

Build an OAA integration that connects a workforce/HR system (Workday, BambooHR, Personio, etc.) to Veza using destination_type: HRIS. This guide covers Employee and Group destinations, required fields, custom properties, and end-to-end configuration.

Prerequisites

When to Use HRIS vs CUSTOM_APP

Use destination_type: HRIS when your source system is a workforce/HR system of record — it manages employees, their employment status, organizational structure (departments, groups), and related HR data. Veza uses this data for identity lifecycle management, access reviews, and segregation-of-duties analysis.

Use destination_type: CUSTOM_APP instead when your system manages its own application users, roles, or resources (e.g., a SaaS app with local accounts). For a full comparison, see What Are the Runner Types?.

Steps

Step 1: Set Up the HRIS Configuration

Create a config.yaml with destination_type: HRIS. The app block must include name, hris_type, and url:

version: v2
log_level: INFO
destination_type: HRIS

app:
  name: My HR System
  hris_type: workday          # your HR system type
  url: https://hr.example.com  # HR system URL

module_directory: ./modules

Required app fields for HRIS:

Field

Description

Example

name

Display name for the provider in Veza

Workday HR

hris_type

Identifier for your HR system type

workday, bamboohr, personio

url

URL of the HR system

https://hr.example.com

Step 2: Configure a Connection

Define a custom connection that fetches data from your HR API:

connections:
  hr_api:
    connection_type: custom
    custom_module: hr_client
    required_env:
      - HR_API_KEY
    params:
      base_url: https://hr.example.com/api/v1

Store your API key in .env:

# .env
HR_API_KEY=your_api_key_here

For more details on API connections, see How to Add a REST API Source and How to Authenticate with API Keys.

Step 3: Define an Employee Source

The Employee destination is the primary object for HRIS integrations. It requires seven mandatory fields:

Required Field

Description

Example

unique_id

Globally unique employee identifier

EMP001

employee_number

Internal employee number

10042

name

Full display name

Jane Smith

first_name

Given name

Jane

last_name

Family name

Smith

is_active

Whether the employee is active

true

employment_status

Employment status label

active, terminated

sources:
  employees:
    destination: Employee
    source_type: custom
    connection: hr_api
    params:
      resource: /employees
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      employee_number:
        destination_field: employee_number
      full_name:
        destination_field: name
        transforms:
          - name: jinja_render
            options:
              template: "{{ row.first_name }} {{ row.last_name }}"
      first_name:
        destination_field: first_name
      last_name:
        destination_field: last_name
      email:
        destination_field: email
        transforms:
          - name: null_to_blank
          - name: lowercase
      employment_status:
        destination_field: employment_status
      is_active_status:
        destination_field: is_active
        transforms:
          - name: jinja_render
            options:
              template: "{{ 'true' if row.employment_status == 'active' else 'false' }}"
          - name: validate_boolean
      hire_date:
        destination_field: hire_date
        transforms:
          - name: date_convert
      termination_date:
        destination_field: termination_date
        custom_type: STRING
        transforms:
          - name: null_to_blank

Step 4: Add a Group Source (Optional)

HRIS integrations can include a Group source for departments, teams, or other organizational units. The GROUP_TYPE config field (defaults to Department) sets the group type:

# At the top level of config.yaml (optional, defaults to "Department"):
# GROUP_TYPE: Department

sources:
  groups:
    destination: Group
    source_type: custom
    connection: hr_api
    params:
      resource: /groups
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      group_type:
        destination_field: group_type

Group required fields:

  • name (required)

  • One of identity or unique_id (required — exactly one must be mapped)

Step 5: Add Custom Properties

Fields that are not standard OAA Employee properties can be added as custom properties. Use custom_type to specify the data type:

field_mapping:
  # Standard fields (above)...

  # Custom properties:
  department:
    destination_field: department
    custom_type: STRING
  employment_type:
    destination_field: employment_type
    custom_type: STRING
  is_full_time:
    destination_field: is_full_time
    custom_type: BOOLEAN
  hire_date:
    destination_field: hire_date
    custom_type: TIMESTAMP
    transforms:
      - name: date_convert
  certifications:
    destination_field: certifications
    custom_type: STRING_LIST

Available custom_type values:

Type

Description

Example

STRING

Text values

"Engineering"

BOOLEAN

True/False values

true

NUMBER

Numeric values

42

STRING_LIST

Array of strings

["Python", "SQL"]

TIMESTAMP

ISO 8601 date/time

"2024-01-15T10:30:00Z"

TIMESTAMP_AD

Windows AD timestamp

133456789012345678

For complete details, see What are Veza Custom Properties?.

Step 6: Write the Fetch Module

Create modules/hr_client.py with a fetch() function that returns a lazy_dict_table for memory efficiency:

import requests
import logging
from oaa.modules.lazy_dicts import lazy_dict_table

logger = logging.getLogger(__name__)


class HRAPIClient:
    def __init__(self, connection=None, source=None):
        params = connection.params if connection else {}
        self.base_url = params.get('base_url')
        self.api_key = params.get('api_key')
        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': f'Bearer {self.api_key}'
        })

    def fetch_paginated(self, resource, resource_key='data', page_size=100):
        """Fetch all records from a paginated HR API endpoint."""
        offset = 0

        while True:
            url = f"{self.base_url}{resource}"
            params = {
                'offset': offset,
                'limit': page_size
            }

            response = self.session.get(url, params=params, timeout=30)
            response.raise_for_status()
            data = response.json()
            items = data.get(resource_key, [])

            if not items:
                break

            for item in items:
                yield item

            offset += page_size
            logger.info(f"Fetched {offset} records from {resource}")


def fetch(connection=None, source=None, **kwargs):
    """
    Fetch the data. This is the function that is called by oaa.

    :param connection: Connection configuration from config.yaml
    :param source: Source configuration from config.yaml
    :returns: PETL table using lazy_dict_table for memory efficiency
    """
    api = HRAPIClient(connection=connection, source=source)
    resource_path = source.params.get('resource', '')
    resource_key = source.params.get('resource_key', 'data')
    results = api.fetch_paginated(resource_path, resource_key=resource_key)
    return lazy_dict_table(results)

For more details, see How to Write a Fetch Function and How to Handle API Pagination.

Step 7: Validate and Run

Validate your configuration:

uv tool run --from oaa-runner oaa app validate --config=config.yaml

Run a dry run (no push to Veza):

uv tool run --from oaa-runner oaa app run --config=config.yaml --dry_run=True --save-json=True

Inspect output.json to verify employees and groups are populated correctly:

# Check employee count
jq '.employees | length' output.json

# Check first employee
jq '.employees[0]' output.json

Run with push to Veza:

uv tool run --from oaa-runner oaa app run --config=config.yaml

End-to-End config.yaml Example

version: v2
log_level: INFO
destination_type: HRIS

app:
  name: BongaWonga HRIS
  hris_type: bongawonga
  url: https://hris.bongawonga.com

module_directory: ./modules

connections:
  bw_hris:
    connection_type: custom
    custom_module: hris_bw
    required_env:
      - BW_HRIS_API_KEY
    params:
      base_url: https://hris.bongawonga.com/api/v1

sources:
  employees:
    destination: Employee
    source_type: custom
    connection: bw_hris
    params:
      resource: /employees
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      employee_number:
        destination_field: employee_number
      full_name:
        destination_field: name
        transforms:
          - name: jinja_render
            options:
              template: "{{ row.first_name }} {{ row.last_name }}"
      first_name:
        destination_field: first_name
      last_name:
        destination_field: last_name
      email:
        destination_field: email
        transforms:
          - name: null_to_blank
          - name: lowercase
      employment_status:
        destination_field: employment_status
      is_active_status:
        destination_field: is_active
        transforms:
          - name: jinja_render
            options:
              template: "{{ 'true' if row.employment_status == 'active' else 'false' }}"
          - name: validate_boolean
      hire_date:
        destination_field: hire_date
        transforms:
          - name: date_convert
      termination_date:
        destination_field: termination_date
        custom_type: STRING
        transforms:
          - name: null_to_blank

  groups:
    destination: Group
    source_type: custom
    connection: bw_hris
    params:
      resource: /groups
      resource_key: data
    field_mapping:
      unique_id:
        destination_field: unique_id
      name:
        destination_field: name
      group_type:
        destination_field: group_type

A complete working example is available at docs/howtos/examples/06-multi-source-api/config-hris.yaml.

Examples

Real Integration: BongaWonga HRIS

The BongaWonga training series includes a full HRIS integration with:

  • Employee source with all 7 required fields

  • Group source for department data

  • Jinja transforms for name composition and boolean conversion

  • Custom properties for termination date

See the example config at docs/howtos/examples/06-multi-source-api/config-hris.yaml.

Next Steps