Use this guide for the first load of an existing organization into Jadiroon, or any sync of thousands of records. It builds on the Bulk upsert endpoints: up to 1,000 rows per request, keyed by your own ids, processed in the background with a result for every row.
Before you start
- Create a dedicated integration for the migration (for example "Oracle HCM – migration") with only the write permissions it needs:
structure.org.actfor org units and positions,people.actfor employees,recruitment.actfor candidates, andstructure.functional.actif you create job descriptions. To import candidates' email, phone or national ID / Iqama, the integration also needsrecruitment.candidate.contact: without it every candidate row that carries contact details fails with403 forbidden(candidate_contact_required). - Agree on the
systemname you will use for every link (for exampleoracle-hcm). It is how later syncs find the same records. - Map the catalog. Select the workspace's sector, sub-sectors and job families in Jadiroon first (onboarding or Structure), then map your job families to them by
slug(see Conventions). - Decide about webhooks. Every migrated record sends its own event (one per record). If an endpoint is already subscribed to
*, a 300,000-employee load means 300,000 deliveries to it. Pause the endpoint for the migration, or subscribe only to what you need, and catch up afterwards withupdatedSince. - Try it on a test workspace with a few hundred rows before the production run.
Load order
References must exist before the rows that use them:
| Step | Endpoint | Notes |
|---|---|---|
| 1 | POST /api/v1/org-units/bulk | Parents before children. Rows of one job run in order, so a child may name a parent created earlier in the same job. |
| 2 | POST /api/v1/job-descriptions (one by one) | Only if your ERP holds job descriptions. Send externalRef to link your id. |
| 3 | POST /api/v1/positions/bulk | Each row names its orgUnit and jobDescription by your ids. |
| 4 | POST /api/v1/employees/bulk | Managers before their reports, or a second pass that only sets manager. |
| 5 | POST /api/v1/candidates/bulk | Optional: an existing talent pool, with an https cvUrl per row. |
Sending the jobs
- Split each step into chunks of up to 1,000 rows.
- Give every chunk its own
Idempotency-Key(for examplemigration-2026-11-employees-chunk-0042). If a network error hides the202, send the same chunk with the same key: you get the samejobIdback and nothing runs twice. - An integration can have 2 jobs queued or running at once. A third gets
429 too_many_jobs: poll a running job, and send the next chunk when one finishes. - A job starts right after the
202and works for up to about a minute. On the hosted service, the rest of a job that doesn't finish then (and a job held by a pause) is continued by the background dispatcher, which currently runs once a day (03:00 UTC). For a large migration, check each job after its first minute and plan for leftovers finishing at the next daily run. - A bulk request counts as one request against the rate limit (600 per minute per key), so polling is what you spend your budget on: poll each job every few seconds, not in a tight loop.
curl -X POST https://jadiroon.com/api/v1/employees/bulk \
-H "Authorization: Bearer $JADIROON_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: migration-2026-11-employees-chunk-0001" \
-d @employees-chunk-0001.json
# → 202 { "jobId": "cmv…", "statusUrl": "/api/v1/bulk-jobs/cmv…" }
curl "https://jadiroon.com/api/v1/bulk-jobs/cmv…?status=failed" -H "Authorization: Bearer $JADIROON_KEY"
Handling failed rows
A bad row fails alone; the rest of the job goes on. When a job is DONE:
- Read
GET /api/v1/bulk-jobs/{id}?status=failed(paginated) and fix each row by itsindex. - Send the fixed rows again as a new job with a new
Idempotency-Key. Upsert by external id makes this safe: rows that already went through are just updated with the same values. - Rows that failed with
internalhit a problem on our side: resubmit them unchanged in a new job. If they fail again, contact support with the job id.
Typical errors: unresolved_reference (a parent, position or manager isn't loaded yet; check the load order), validation_failed with details.reason (for example first_name_required), conflict (duplicate_person, position_full) and forbidden (the integration lacks a permission).
Things that affect running jobs
- Pausing the integration holds its jobs: a running job stops after the chunk of up to 50 rows it is on, and no job is picked up again while the integration is paused; they resume where they stopped when it is active again. Held jobs still count toward the 2-job limit.
- Revoking the key that posted a job does not stop the job: it finishes under the integration that posted it. Pause the integration to stop it.
- Removing a permission the job needs (for example
people.act) fails its remaining rows withforbiddenfrom the next chunk on. - Suspending the workspace holds its jobs the same way: a running job stops after the chunk it is on, and its jobs resume where they stopped when the workspace is active again.
- A job that can't run at all (for example its rows expired) ends as
FAILED. - The rows you sent are deleted when the job is
DONE(at the latest about a week after it was created); the per-row results stay readable.
After the migration
- Check counts:
GET /api/v1/employees?limit=100pages, or the Bulk jobs tab of the integration in Settings → Integrations, which shows every job with its counts and failed rows. - Resume or subscribe webhook endpoints, then switch to day-to-day sync: single
PUT …/by-external/…calls or small bulk jobs for changes, and webhooks orupdatedSincepolling for the way back. See the ERP sync guide. - Expect the audit log to hold one row per migrated record, with your integration as the actor.