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,aris required. - Times are ISO 8601 in UTC (
2026-10-24T09:46:14.331Z). - Every object has
id,externalRefs,createdAtandupdatedAt(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
| Key | What it is | Use it to |
|---|---|---|
id | Jadiroon's id (a cuid). | Call the API within one environment. |
slug | A 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 thePUT …/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" }
limitis 1 to 100 (default 50).- Pass
nextCursorback ascursorfor the next page;nextCursor: nullmeans 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 inidorder. With it, inupdatedAtthenidorder. - 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 byid. 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:
- Note the current time
T. - Read every page with
updatedSince= your last watermark. - Upsert by
idin your system (the same row can show up twice if it changed while you were paging). - Read the deletion feed with
since= the same watermark. - Store
Tminus one minute as the new watermark.
Things to know:
updatedAtalso 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 firstupdatedSincesync returns everything. Start with a full read. - The audit feed filters on
occurredAtinstead; audit rows never change. - Catalog changes made by Jadiroon (global sectors, sub-sectors, job families) send no webhook events. Re-read
/sectors,/sub-sectorsand/job-familiesperiodically (daily is plenty). - Removing a sub-sector, job family or sector from your workspace's Functional Structure sends
<resource>.deletedwebhooks 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
externalRefsas they were. Tombstones are kept 180 days. sinceandresource(for exampleemployee,org_unit,job_description) are optional;limitandcursorwork as usual.- You only see tombstones of resources your key can read.
?resource=for a resource it can't read returns403 forbidden; an unknown resource returns422 validation_failed. - Offboarding an employee (
DELETE /api/v1/employees/{id}) is not a delete: the record stays, with statusFORMER_EMPLOYEE.
Writes
POSTcreates,PATCHpartially updates,DELETEdeletes,PUT …/by-external/…upserts.PATCHnever clears what you didn't send. Omitted fields stay as they are, including child arrays such as a job description's skills. Sendnullto 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 onmessage. Messages are in English and may change. detailsis optional and depends on the error:issues(field-level problems with apath),reason(the specific rule, for examplealready_appliedorduplicate_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.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | Missing, unknown, revoked or expired key. |
forbidden | 403 | The 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_found | 404 | No such record in your workspace (also for records of other workspaces). |
validation_failed | 422 | The 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. |
conflict | 409 | The 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_people | 409 | A position still has employees on it. |
has_openings | 409 | A position still has openings or interview rubrics. |
has_children | 409 | An org unit still has child units. |
unresolved_reference | 422 | A reference in the body (parent, orgUnit, jobDescription, manager, …) doesn't exist in your workspace. |
ssco_managed | 409 | The job description comes from the national SSCO catalog and is read-only. |
idempotency_conflict | 409 | The Idempotency-Key was used for a different request, or the first request with it is still running. |
rate_limited | 429 | Over 600 requests a minute; wait Retry-After seconds. |
too_many_jobs | 429 | The integration already has 2 bulk jobs queued or running. |
workspace_inactive | 403 | The workspace is suspended or deleted. |
webhook_egress_not_configured | 422 | This deployment allows no outgoing webhooks (it has no host allowlist). |
forbidden_host | 422 | The webhook URL's host is not public or not on this deployment's allowlist. |
internal | 500 or 502 | Something 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.