Bulk migration

For HR administrators

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

  1. Create a dedicated integration for the migration (for example "Oracle HCM – migration") with only the write permissions it needs: structure.org.act for org units and positions, people.act for employees, recruitment.act for candidates, and structure.functional.act if you create job descriptions. To import candidates' email, phone or national ID / Iqama, the integration also needs recruitment.candidate.contact: without it every candidate row that carries contact details fails with 403 forbidden (candidate_contact_required).
  2. Agree on the system name you will use for every link (for example oracle-hcm). It is how later syncs find the same records.
  3. 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).
  4. 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 with updatedSince.
  5. 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:

StepEndpointNotes
1POST /api/v1/org-units/bulkParents before children. Rows of one job run in order, so a child may name a parent created earlier in the same job.
2POST /api/v1/job-descriptions (one by one)Only if your ERP holds job descriptions. Send externalRef to link your id.
3POST /api/v1/positions/bulkEach row names its orgUnit and jobDescription by your ids.
4POST /api/v1/employees/bulkManagers before their reports, or a second pass that only sets manager.
5POST /api/v1/candidates/bulkOptional: 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 example migration-2026-11-employees-chunk-0042). If a network error hides the 202, send the same chunk with the same key: you get the same jobId back 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 202 and 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:

  1. Read GET /api/v1/bulk-jobs/{id}?status=failed (paginated) and fix each row by its index.
  2. 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.
  3. Rows that failed with internal hit 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 with forbidden from 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=100 pages, 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 or updatedSince polling 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.