Skip to main content

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_id is the identifier, not email. Acme HR never reuses an employee number, while an email address changes when someone changes their name.
  • employee is the account entity, named by its entityKey, not by its feed name employees.
  • Lifecycle from employment_status. terminated makes the account deleted; active makes it active; any other status, such as leave, makes it disabled, because isActiveExpr is false.
  • is_contractor is a boolean. Its cells are true and false. A cell such as yes would make the row unreadable, so the export must keep to true, false, 1 or 0.
  • manager_id is text. It holds another employee's employee_id, and is synced as it is.
  • The relationship feed names its columns after the identifier columns of the two entity feeds, employee_id and department_id, and has a header row, which relationship files require.
  • Patterns in routing order. departments*.csv does not match department_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​

PartDerived
Account entity employeeIdentifier employee_id; a field per employee column
Entity departmentIdentifier department_id; name and cost_center
Relationship member_of_departmentNot in the declaration; read from its file at ingestion, from employee to department
Install fieldsEmployees file name (default employees*.csv) and Employee columns
Everything elseSync 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.