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¶
OAA runner installed (see How to Install uv and How to Run Integrations Locally)
API documentation for your HR system
API credentials stored in a
.envfileBasic familiarity with
config.yamlstructure (see What is an OAA config file?)
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 |
|---|---|---|
|
Display name for the provider in Veza |
|
|
Identifier for your HR system type |
|
|
URL of the HR system |
|
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 |
|---|---|---|
|
Globally unique employee identifier |
|
|
Internal employee number |
|
|
Full display name |
|
|
Given name |
|
|
Family name |
|
|
Whether the employee is active |
|
|
Employment status label |
|
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
identityorunique_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 |
|---|---|---|
|
Text values |
|
|
True/False values |
|
|
Numeric values |
|
|
Array of strings |
|
|
ISO 8601 date/time |
|
|
Windows AD timestamp |
|
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¶
How to Create an IDP Integration - Connect an identity provider
How to Create a Custom App Integration - Connect any application
How to Configure Field Mappings - Map source fields to OAA fields
How to Handle API Pagination - Work with large HR datasets
How to Use PRE_TRANSFORM Hooks - Modify data before field mapping