Conventions

For HR administrators

These rules hold for every endpoint under /api/v1. Resource pages only describe what differs.

Shape

  • Paths are plural kebab-case (/org-units, /job-descriptions); fields are camelCase.
  • Bilingual text is { "ar": …, "en": … }. Arabic is the primary language: where a name is required, ar is required.
  • Times are ISO 8601 in UTC (2026-10-24T09:46:14.331Z).
  • Every object has id, externalRefs, createdAt and updatedAt (audit rows and tombstones have their own time fields).
  • Unknown fields in a request body are rejected (422 validation_failed), so a typo never goes unnoticed.

Ids, slugs and external ids

KeyWhat it isUse it to
idJadiroon's id (a cuid).Call the API within one environment.
slugA stable, immutable key on catalog rows: global sectors, sub-sectors, job families and skills. Workspace-owned rows have slug: null.Map catalog rows across Jadiroon environments.
externalRefs[{ system, externalId }]: your own ids, linked to the record.Map your records to Jadiroon records, in every environment.

Ids differ between Jadiroon environments (test, production, a dedicated deployment) and change when a demo or test workspace is reseeded. So:

  • Never copy ids between environments. Store them per environment if you store them at all.
  • Map catalog rows by slug.
  • Map workspace rows by externalRefs: create or update them with the PUT …/by-external/{system}/{externalId} endpoints, look them up with ?externalSystem=&externalId=, and name them in request bodies as { "externalSystem": "…", "externalId": "…" }. See the ERP sync guide.

system is free text up to 64 characters (for example oracle-hcm or sap-sf); externalId is up to 128.

Pagination

Every list is cursor-paginated:

curl "https://jadiroon.com/api/v1/employees?limit=100" -H "Authorization: Bearer $JADIROON_KEY"
{ "data": [ … ], "nextCursor": "Y212bTdydTBxMDAwNmtuZGxlMGZ4NGl2dA" }
  • limit is 1 to 100 (default 50).
  • Pass nextCursor back as cursor for the next page; nextCursor: null means the last page.
  • Cursors are opaque: don't build or decode them. A malformed cursor returns 422, but an unrecognised plain cursor may simply return a later page.
  • Without updatedSince, rows come in id order. With it, in updatedAt then id order.
  • A cursor holds its own position in that order, so a row edited or deleted while you page doesn't break the walk: no row after the cursor is skipped, and the next page never fails because the last row is gone. With updatedSince, a row edited during the walk moves to the end and you read it again, so deduplicate by id. Cursors issued before 2026-10-25 still work.

Incremental sync with updatedSince

Every list accepts updatedSince (ISO 8601). It returns the rows whose updatedAt is at or after that time, including archived rows (with archivedAt set), so you learn about archives without a separate call.

A safe sync loop:

  1. Note the current time T.
  2. Read every page with updatedSince = your last watermark.
  3. Upsert by id in your system (the same row can show up twice if it changed while you were paging).
  4. Read the deletion feed with since = the same watermark.
  5. Store T minus one minute as the new watermark.

Things to know:

  • updatedAt also moves when a record changes in the app, and when a related change touches it (for example a position becomes unplaced when its org unit is deleted).
  • First sync after the API launched: records that existed before the launch all carry updatedAt = the launch date, so the first updatedSince sync returns everything. Start with a full read.
  • The audit feed filters on occurredAt instead; audit rows never change.
  • Catalog changes made by Jadiroon (global sectors, sub-sectors, job families) send no webhook events. Re-read /sectors, /sub-sectors and /job-families periodically (daily is plenty).
  • Removing a sub-sector, job family or sector from your workspace's Functional Structure sends <resource>.deleted webhooks but leaves no tombstone (nothing was deleted). Compare against a full read of the list to catch it without webhooks.

Deletion feed

Hard deletes can be synced without webhooks:

curl "https://jadiroon.com/api/v1/deleted?since=2026-10-24T00:00:00Z&resource=position" \
  -H "Authorization: Bearer $JADIROON_KEY"
{
  "data": [
    { "resource": "position", "id": "cmv…", "externalRefs": [{ "system": "oracle-hcm", "externalId": "POS-310" }], "deletedAt": "2026-10-24T10:02:11.000Z" }
  ],
  "nextCursor": null
}
  • Every hard delete of a public record, in the app or through the API, writes a tombstone with the record's externalRefs as they were. Tombstones are kept 180 days.
  • since and resource (for example employee, org_unit, job_description) are optional; limit and cursor work as usual.
  • You only see tombstones of resources your key can read. ?resource= for a resource it can't read returns 403 forbidden; an unknown resource returns 422 validation_failed.
  • Offboarding an employee (DELETE /api/v1/employees/{id}) is not a delete: the record stays, with status FORMER_EMPLOYEE.

Writes

  • POST creates, PATCH partially updates, DELETE deletes, PUT …/by-external/… upserts.
  • PATCH never clears what you didn't send. Omitted fields stay as they are, including child arrays such as a job description's skills. Send null to clear a nullable field.
  • State changes have their own endpoints (…/archive, …/close, …/stage, …/hire), so the same rules as the app run.

Errors

Every error has the same body:

{ "error": { "code": "unresolved_reference", "message": "Could not resolve org_unit reference", "details": { "resource": "org_unit", "ref": { "externalSystem": "oracle-hcm", "externalId": "ORG-999" } } } }
  • Branch on code (and, where present, details.reason); never on message. Messages are in English and may change.
  • details is optional and depends on the error: issues (field-level problems with a path), reason (the specific rule, for example already_applied or duplicate_person), required (missing permission keys) and so on.
  • Error codes of the app's own screens never leak into the API; the list below is closed. New codes are announced in the changelog.
CodeStatusMeaning
unauthorized401Missing, unknown, revoked or expired key.
forbidden403The key lacks a permission (details.required), the integration is paused, the IP is not on the allowlist, or a deployment-level key tried to write.
not_found404No such record in your workspace (also for records of other workspaces).
validation_failed422The request is malformed or breaks a rule: the body is not JSON or is invalid (details.issues or details.reason), a bad limit, cursor, updatedSince or since, an unknown resource, or a missing Idempotency-Key on bulk.
conflict409The change clashes with the current state; details.reason says how (for example the external id is already linked to another record, already_applied, opening_not_open).
has_people409A position still has employees on it.
has_openings409A position still has openings or interview rubrics.
has_children409An org unit still has child units.
unresolved_reference422A reference in the body (parent, orgUnit, jobDescription, manager, …) doesn't exist in your workspace.
ssco_managed409The job description comes from the national SSCO catalog and is read-only.
idempotency_conflict409The Idempotency-Key was used for a different request, or the first request with it is still running.
rate_limited429Over 600 requests a minute; wait Retry-After seconds.
too_many_jobs429The integration already has 2 bulk jobs queued or running.
workspace_inactive403The workspace is suspended or deleted.
webhook_egress_not_configured422This deployment allows no outgoing webhooks (it has no host allowlist).
forbidden_host422The webhook URL's host is not public or not on this deployment's allowlist.
internal500 or 502Something failed on our side (502: a stored file could not be read). Retry with backoff; with an Idempotency-Key the retry is safe.

Idempotency

POST endpoints accept an Idempotency-Key header (any string up to 255 characters, for example a UUID). It is required on bulk upserts.

  • Keys are remembered for 24 hours per integration.
  • The same key with the same request (method, path and body) returns the stored response (same status and body) without running the request again. The replayed response carries Idempotent-Replayed: true; the original response headers are not replayed.
  • The same key with a different request returns 409 idempotency_conflict. So does a second request while the first is still running: retry it a moment later. If the first request never finished (for example, it was cut off), the key is freed 90 seconds after it was first used, and a retry then runs the request.
  • 4xx responses are stored too. After fixing a rejected request, send it with a new key, or you get the stored error back for 24 hours.
  • 5xx responses are not stored: retrying with the same key runs the request again.
  • PUT …/by-external/… doesn't need a key: it is idempotent by design.