Bulk upsert loads up to 1,000 employees, candidates, org units or positions in one request, each row matched by your own system's id. Jadiroon works the rows in the background and you poll the job for the outcome of every row. Use it for the first load from your ERP and for large periodic syncs; for one record, use the PUT .../by-external/{system}/{externalId} endpoint of the resource.
Endpoints
| Method and path | What it does | Permission |
|---|---|---|
POST /api/v1/employees/bulk | Upsert up to 1,000 employees by external id | people.act |
POST /api/v1/candidates/bulk | Upsert up to 1,000 candidates by external id, with an optional CV link | recruitment.act |
POST /api/v1/org-units/bulk | Upsert up to 1,000 org units by external id | structure.org.act |
POST /api/v1/positions/bulk | Upsert up to 1,000 positions by external id | structure.org.act |
GET /api/v1/bulk-jobs/{id} | Job status, counts and per-row results | any key of the integration that posted the job |
Sending a job
Send { "rows": [ … ] } with 1 to 1,000 rows and an Idempotency-Key header (required). Each row is the body of the resource's by-external PUT, with the key inline as system and externalId:
{
"rows": [
{ "system": "oracle-hcm", "externalId": "EMP-1001", "firstName": "Sara", "lastName": "Alharbi" },
{ "system": "oracle-hcm", "externalId": "EMP-1002", "firstName": "Omar", "lastName": "Alqahtani",
"manager": { "externalSystem": "oracle-hcm", "externalId": "EMP-1001" } }
]
}
The response is 202 { "jobId": "…", "statusUrl": "/api/v1/bulk-jobs/…" }. Sending the same request again with the same Idempotency-Key (within 24 hours) returns the same jobId and starts nothing new.
- More than 1,000 rows, or none:
422 validation_failed. - An integration can have 2 jobs queued or running at a time; a third gets
429 too_many_jobs. Wait for one to finish and send again (the same key can be reused after a429). - A bulk request counts as one request for rate limiting.
Rows run in order, so a row can reference a record created by an earlier row of the same job (for example a child org unit naming its parent by external id).
Candidates: a row may carry cvUrl, an https link to the candidate's CV. The job downloads it (public hosts only, no redirects) and stores it privately; it must be a PDF of at most 5 MB, as in the app, and download within 20 seconds. A CV that can't be fetched fails that row only, with details.path cvUrl and a details.reason such as cv_not_pdf, cv_too_large, cv_unreachable or cv_forbidden_host. A row with a non-empty email, phone or nationalId needs recruitment.candidate.contact on the key, whether it creates or updates the candidate; without it that row alone fails with 403 forbidden (details.reason candidate_contact_required).
Same rules as single writes
Every row goes through the same service as the single by-external PUT: the same validation, permissions and checks. A row that would be rejected on its own fails alone; the other rows are still written. Each record written gets its own audit row (actor: your integration) and its own webhook event (employee.created, org_unit.updated, and so on), exactly as if you had sent it alone. Expect one event per record on large loads.
Reading the job
GET /api/v1/bulk-jobs/{id} returns:
| Field | Notes |
|---|---|
status | QUEUED, RUNNING, DONE (every row processed) or FAILED (the job could not run, for example its rows expired). |
total, processed, succeeded, failed | Row counts. |
createdAt, startedAt, finishedAt | ISO 8601. |
results | [{ index, status, id?, error? }]: index is the row's position in rows (from 0); status is created, updated or failed; id is the Jadiroon id; error is { code, message, details? } with a code from the API's error list. |
nextCursor | For the next page of results. |
results are paginated (limit up to 100, cursor), and ?status=failed lists only the failed rows. A job of another integration returns 404 not_found.
Typical row errors: validation_failed (with details.issues[].path for a malformed row, or details.reason such as first_name_required when a new record misses a required field), unresolved_reference (a referenced record doesn't exist), conflict (for example duplicate_person or position_full) and forbidden.
A job starts right after the 202 and works for up to about a minute; most jobs finish then. On the hosted service, the rest of a longer job is continued by the background dispatcher, which currently runs once a day (03:00 UTC). Results stay readable after the job finishes; the submitted rows themselves are deleted when the job is DONE (at the latest after about a week).