Employee import
Load or sync up to 500 employees per request. xYago processes the file in the background and reports the result row by row. Ideal for the initial load from the ERP and for nightly syncs.
Flow
- Enqueue the import:
POST /employees/importwith{"rows": [...]}(1 to 500 rows). Response202withjob_id. - Poll
GET /employees/import/{job_id}every 2-5 seconds untilstatusiscompletedorfailed. - Check
results: each row sayscreated,updatedorfailedwith its error. Fix failed rows in the ERP and resend them.
Each row
A row has the shape of the POST /employees body (see the
reference) and external_id is required: it is
the upsert key.
- Unknown external_id → create. The row needs every required create field:
first_name,phone_number,job_title,contract_type,hire_date,work_shiftsandcontract_end_dateexcept for permanent contracts. - Known external_id → partial update: only the fields sent change;
work_shiftsandvacation_daysare ignored. - A failing row does not stop the others.
Example
curl
curl -X POST "https://api.xyago.com/v1/employees/import" \
-H "Authorization: Bearer $XYAGO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rows": [
{
"external_id": "SIESA-000123",
"first_name": "Valentina",
"last_name": "Rojas Castaño",
"phone_number": "+573005550123",
"email": "valentina.rojas@example.com",
"document_number": "1020304050",
"job_title": "Auxiliar de bodega",
"contract_type": "Full-time fixed-term",
"hire_date": "2026-02-01",
"contract_end_date": "2027-01-31",
"work_shifts": [{"day": "Monday", "start": "07:00", "end": "16:00"}]
},
{"external_id": "SIESA-000087", "job_title": "Conductor de reparto"}
]
}'
# => 202 {"job_id": "0b8c6d4e-2f1a-4c3b-9e5d-7a6b8c9d0e1f", "status": "queued"}
curl "https://api.xyago.com/v1/employees/import/0b8c6d4e-2f1a-4c3b-9e5d-7a6b8c9d0e1f" \
-H "Authorization: Bearer $XYAGO_API_KEY"
Python
import os
import time
import requests
BASE_URL = "https://api.xyago.com/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['XYAGO_API_KEY']}"
def import_employees(rows):
response = session.post(f"{BASE_URL}/employees/import", json={"rows": rows}, timeout=60)
response.raise_for_status()
job_id = response.json()["job_id"]
while True:
job = session.get(f"{BASE_URL}/employees/import/{job_id}", timeout=30).json()
if job["status"] in ("completed", "failed"):
return job
time.sleep(3)
job = import_employees(rows_from_erp) # list of dicts, max. 500 per call
for row in job["results"]:
if row["status"] == "failed":
print(row["external_id"], row["error_code"], row["error_message"])
C#
var accepted = await http.PostAsJsonAsync("employees/import", new { rows = rowsFromErp });
accepted.EnsureSuccessStatusCode();
var jobId = (await accepted.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("job_id").GetString();
JsonElement job;
do
{
await Task.Delay(TimeSpan.FromSeconds(3));
job = await http.GetFromJsonAsync<JsonElement>($"employees/import/{jobId}");
} while (job.GetProperty("status").GetString() is "queued" or "processing");
foreach (var row in job.GetProperty("results").EnumerateArray())
if (row.GetProperty("status").GetString() == "failed")
Console.WriteLine($"{row.GetProperty("external_id")}: {row.GetProperty("error_code")}");
Result
{
"job_id": "0b8c6d4e-2f1a-4c3b-9e5d-7a6b8c9d0e1f",
"status": "completed",
"total_rows": 3,
"processed_rows": 3,
"created": 1,
"updated": 1,
"failed": 1,
"created_at": "2026-03-02T15:00:00Z",
"started_at": "2026-03-02T15:00:01Z",
"finished_at": "2026-03-02T15:00:04Z",
"results": [
{
"row_index": 0,
"external_id": "SIESA-000123",
"status": "created",
"employee_id": "2c9a4e71-5b3d-4f8e-a6c2-7d1e0f9b3a54",
"error_code": null,
"error_message": null
},
{
"row_index": 1,
"external_id": "SIESA-000087",
"status": "updated",
"employee_id": "5e7b1d93-2a4c-4b6e-8f0a-3c5d7e9f1b26",
"error_code": null,
"error_message": null
},
{
"row_index": 2,
"external_id": "SIESA-000150",
"status": "failed",
"employee_id": null,
"error_code": "validation_error",
"error_message": "phone_number: Field required; job_title: Field required"
}
]
}
status | queued → processing → completed. failed means the whole job could not be processed (not that some row failed): resend it. |
total_rows, processed_rows | Progress. |
created, updated, failed | Counters by outcome. |
results[] | One entry per row: row_index (position in rows, from 0), external_id, status, employee_id and, if it failed, error_code and error_message. |
Row errors
Besides any POST/PATCH /employees error
(employee_already_exists, email_already_registered,
invalid_employee_data, upstream_error... see errors):
invalid_row | The row is not a JSON object. |
invalid_external_id | external_id is missing or malformed. |
duplicate_external_id | The external_id appears more than once in the same import. |
validation_error | The row does not match the POST /employees (or PATCH) schema. |
Useful rules
- Resending is safe: the same file updates instead of duplicating. If the
identical job is still queued or running, you get the same
job_id. - More than 500 employees: split into batches and send them one after another (wait for each job to finish).
- Each created or updated row emits its
employee.created/employee.updatedwebhook. - A job is only visible to keys of the same company and environment (live/test); otherwise
404. - If the queue is unavailable you get
503 import_queue_unavailable: retry later.