Example: CSV feed
A complete flat-file connector for the nightly export of Acme HR, a fictional HR system with no API. Every night the export writes three CSV files: the employees, the departments, and which employee belongs to which department. The connector turns them into an account entity, a department entity and a relationship between the two, with each employee's lifecycle taken from a status column. The definition derives without errors, and the sample files below read as shown.
The files
employees_2026-09-27.csv, one row per employee. The date in the name changes every night.
employee_id,email,first_name,last_name,job_title,manager_id,employment_status,is_contractor
E1001,maya.chen@acme.example,Maya,Chen,VP Engineering,,active,false
E1002,omar.haddad@acme.example,Omar,Haddad,Staff Engineer,E1001,active,false
E1003,lena.fischer@acme.example,Lena,Fischer,Support Specialist,E1001,leave,false
E1004,sam.okafor@acme.example,Sam,Okafor,Contract Designer,E1001,active,true
E1005,raj.patel@acme.example,Raj,Patel,Sales Engineer,E1001,terminated,false
departments.csv:
department_id,name,cost_center
D10,Engineering,CC-4100
D20,Customer Support,CC-4200
department_members_2026-09-27.csv, one row per membership:
employee_id,department_id
E1001,D10
E1002,D10
E1003,D20
E1004,D10
definition.json
{
"engine": 1,
"description": "Employees, departments and department membership from the Acme HR nightly CSV export.",
"accountEntityKey": "employee",
"config": {
"properties": {
"employeeFileMatch": {
"type": "string",
"title": "Employees file name",
"description": "An uploaded file whose name matches this pattern is the employees snapshot.",
"default": "employees*.csv"
},
"employeeColumns": {
"type": "object",
"title": "Employee columns",
"description": "The columns of your employees file, by header name, with their position. employee_id identifies an employee and stays as it is.",
"default": {
"employee_id": { "position": 0, "type": "string", "identifier": true },
"email": { "position": 1, "type": "string" },
"first_name": { "position": 2, "type": "string" },
"last_name": { "position": 3, "type": "string" },
"job_title": { "position": 4, "type": "string" },
"manager_id": { "position": 5, "type": "string" },
"employment_status": { "position": 6, "type": "string" },
"is_contractor": { "position": 7, "type": "boolean" }
}
}
}
},
"feeds": {
"employees": {
"kind": "entity",
"entityKey": "employee",
"fileMatch": { "$oxl": "config.employeeFileMatch" },
"parse": { "delimiter": ",", "header": true, "encoding": "utf-8" },
"columns": { "$oxl": "config.employeeColumns" },
"isActiveExpr": "employment_status == 'active'",
"isDeletedExpr": "employment_status == 'terminated'"
},
"departments": {
"kind": "entity",
"entityKey": "department",
"fileMatch": "departments*.csv",
"parse": { "delimiter": ",", "header": true, "encoding": "utf-8" },
"columns": {
"department_id": { "position": 0, "type": "string", "identifier": true },
"name": { "position": 1, "type": "string" },
"cost_center": { "position": 2, "type": "string" }
}
},
"department_members": {
"kind": "relationship",
"fileMatch": "department_members*.csv",
"parse": { "delimiter": ",", "header": true, "encoding": "utf-8" },
"edge": {
"edgeKey": "member_of_department",
"sourceEntityKey": "employee",
"targetEntityKey": "department",
"sourceColumn": "employee_id",
"targetColumn": "department_id"
}
}
}
}
The decisions
employee_idis the identifier, notemail. Acme HR never reuses an employee number, while an email address changes when someone changes their name.employeeis the account entity, named by itsentityKey, not by its feed nameemployees.- Lifecycle from
employment_status.terminatedmakes the account deleted;activemakes it active; any other status, such asleave, makes it disabled, becauseisActiveExprisfalse. is_contractoris a boolean. Its cells aretrueandfalse. A cell such asyeswould make the row unreadable, so the export must keep totrue,false,1or0.manager_idis text. It holds another employee'semployee_id, and is synced as it is.- The relationship feed names its columns after the identifier columns of the two entity feeds,
employee_idanddepartment_id, and has a header row, which relationship files require. - Patterns in routing order.
departments*.csvdoes not matchdepartment_members_2026-09-27.csv, so the two feeds never compete for a file. - Install-editable employees file and columns. Other customers of Acme HR rename the export and add columns. The departments and memberships files are fixed.
What Owlie derives
| Part | Derived |
|---|---|
Account entity employee | Identifier employee_id; a field per employee column |
Entity department | Identifier department_id; name and cost_center |
Relationship member_of_department | Not in the declaration; read from its file at ingestion, from employee to department |
| Install fields | Employees file name (default employees*.csv) and Employee columns |
| Everything else | Sync only, no credentials, no incremental Sync |
Read with this definition, the employees file gives five account records. Maya, Omar and Sam are
active, Lena is disabled, and Raj is deleted. Sam's is_contractor is true, and Maya's empty
manager_id is an empty string. The membership file links four employees to their departments.
Upload the files
On the integration's Connection tab, create an upload token labelled acme-hr-nightly in the
Upload tokens card and copy it. Keep it wherever your scheduler keeps secrets, and give the upload
job the token and the integration's id:
export OWLIE_URL="https://acme.owlie.com"
export INTEGRATION_ID="<integration id>"
export OWLIE_UPLOAD_TOKEN="<upload token>"
The job asks for an upload URL for each file, then sends the file to it. The order does not matter: a membership row that names an employee or department Owlie has not read yet gets a placeholder record, which the entity file fills in. Uploading the entity files first only means the placeholders are never needed.
#!/usr/bin/env bash
set -euo pipefail
upload() {
local file="$1"
local url
url=$(curl -sS --fail -X POST "$OWLIE_URL/api/integrations/$INTEGRATION_ID/upload-url" \
-H "Authorization: Bearer $OWLIE_UPLOAD_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"$(basename "$file")\"}" | jq -r '.url')
curl -sS --fail -T "$file" "$url"
}
upload "employees_$(date +%F).csv"
upload departments.csv
upload "department_members_$(date +%F).csv"
Each file routes to its feed by name. A file that matches no feed gets 422 with
"error": "no_matching_feed" and the list of feed names; to send such a file anyway, add
"feed": "employees" to the request body. After each upload, the File drops card shows the drop
moving from ARCHIVED to INGESTED.
The next night
Each file replaces its feed's contents. If the next employees file no longer contains Omar, the run
removes Omar's employee record from the integration, and the next membership file without Omar's row
removes his department membership. If a row has is_contractor set to maybe, that row is left out,
the other rows are ingested, and the run skips every removal until a clean file arrives.
An install with a different export
Another customer's export is called people_export.csv and has a ninth column, cost_center. Its
admin edits two values on the integration: Employees file name becomes people_export*.csv, and
Employee columns gains "cost_center": { "position": 8, "type": "string" }. Owlie derives that
install's employee entity with the extra field and routes its files by the new pattern. Moving
employee_id to another position, or marking email as an identifier, would be refused.
Build your own from Define flat-file feeds. Every field is in the flat-file definition reference.