Positions are the seats of your Organizational Structure: each one sits in an org unit, follows a job description, and has a capacity (how many people it holds). Use these endpoints to keep positions in Jadiroon in sync with your ERP or HR system.
All endpoints sit under /api/v1 and need an API key (Authorization: Bearer jdr_live_…) created by your Workspace Admin in Settings → Integrations.
Endpoints
| Method and path | What it does | Permission |
|---|---|---|
GET /api/v1/positions | List positions (cursor pagination, updatedSince, external-id lookup) | structure.org.view |
POST /api/v1/positions | Create a position | structure.org.act |
GET /api/v1/positions/{id} | Get one position | structure.org.view |
PATCH /api/v1/positions/{id} | Update a position; omitted fields stay unchanged | structure.org.act |
DELETE /api/v1/positions/{id} | Delete a position with no employees and no hiring history | structure.org.act |
POST /api/v1/positions/{id}/archive | Archive a position | structure.org.act (plus recruitment.hiring.open when it has an open opening) |
PUT /api/v1/positions/by-external/{system}/{externalId} | Create or update by your own system's id | structure.org.act |
POST /api/v1/positions/bulk | Upsert up to 1,000 positions by external id, async (see Bulk upsert) | structure.org.act |
structure.org.act includes structure.org.view. Setting or changing pay (payScale, payGrade, payStepNumber) also needs structure.functional.salary; without it the request returns 403 forbidden. Reading pay needs it too: without structure.functional.salary, the pay fields (payScaleId, payGradeId, payStepNumber, baseSalary, payPeriod) are null in every response (lists, single reads and the responses of writes) and in webhook payloads.
The position object
| Field | Type | Notes |
|---|---|---|
id | string | Jadiroon id. Ids differ between Jadiroon environments; link your records with externalRefs. |
title | { ar, en } | The position's own title. When empty, the app shows the job description's name. |
jobDescriptionId | string or null | The job description the position follows. null only when that job description was deleted. |
orgUnitId | string or null | The org unit it sits in; null when unplaced. |
capacity | integer | Number of seats (1 or more). |
measurementMode | string | SPECIALIZED or ROUTINE (workload measurement), read-only. |
hiringStatus | string | OPEN or CLOSED. Read-only: it changes when hiring is opened or closed. |
hiringPathway | string or null | INTERNAL_ONLY, INTERNAL_FIRST or EXTERNAL while open. Read-only. |
hiringOpenedAt, externalAt | ISO 8601 or null | When hiring opened and when the posting becomes external. Read-only. |
posting | { ar, en } | The public posting text, read-only. |
payScaleId, payGradeId | string or null | The pay scale and grade, when set. null without structure.functional.salary, as are the three pay fields below. |
payStepNumber | integer or null | The step within the grade; null shows the grade's whole range. |
baseSalary | integer or null | Base pay at that step, in payPeriod units (SAR). Read-only, computed. |
payPeriod | string or null | MONTHLY or ANNUAL, from the pay scale. |
archivedAt | ISO 8601 or null | Set when the position is archived. |
externalRefs | array | [{ system, externalId }] links to your systems. |
createdAt, updatedAt | ISO 8601 | updatedAt also moves when the position changes through the app, or when its org unit or job description is deleted. |
Creating and updating
POST takes jobDescription and optionally title, orgUnit, capacity, payScale, payGrade and payStepNumber. Unknown fields are rejected with 422 validation_failed, and so are the read-only hiring fields (hiringStatus, hiringPathway, externalAt): those change when an opening is opened or closed.
jobDescription, orgUnit and payScale name the record by Jadiroon id or by your own id; payGrade takes a Jadiroon id:
{
"jobDescription": { "externalSystem": "oracle-hcm", "externalId": "JOB-12" },
"orgUnit": { "id": "cmv…" },
"payScale": { "id": "cmv…" },
"payGrade": { "id": "cmv…" },
"payStepNumber": 3
}
To name a job description by your own id, link it first with externalRef when you create or update it (see Job descriptions). The job description must be one of your workspace's own job descriptions, and the org unit and pay scale must belong to your workspace; otherwise the response is 422 unresolved_reference. The grade must belong to the scale and the step must be within the grade's steps (422 validation_failed, details.reason invalid_pay_grade or invalid_pay_step). A grade or step sent without a pay scale (none in the body and none on the position, or payScale: null) is rejected the same way.
An archived job description can't be chosen for a new position, or as a position's new job description: the response is 422 validation_failed with details.reason archived. A position already linked to an archived job description keeps it.
On PATCH, orgUnit: null unplaces the position and payScale: null removes the whole pay link. Changing the scale needs a grade of the new scale; changing the grade clears the step unless you send one. Lowering capacity below the number of employees in the position returns 409 conflict with details.reason capacity_below_filled and details.filled.
Upsert by external id
PUT /api/v1/positions/by-external/oracle-hcm/POS-310 looks up the position linked to oracle-hcm / POS-310:
- Linked position found: the body is applied like a
PATCHand the response is200. - No linked position: a position is created from the body (send at least
jobDescription) and linked to the external id, and the response is201.
If the linked position was deleted in the app, the old link is dropped and the call creates a new position with a new link. To find a position by your id without changing it, call GET /api/v1/positions?externalSystem=oracle-hcm&externalId=POS-310 (send both parameters).
Archiving and deleting
POST /api/v1/positions/{id}/archive closes hiring on the position and its open openings, and sets archivedAt. Archiving an archived position changes nothing and returns it as is.
Closing an open opening needs recruitment.hiring.open, as in the app. When the position has an open opening and your key lacks it, the response is 403 forbidden with details.reason hiring_permission_required and details.permission recruitment.hiring.open, and nothing changes. A position with no open opening archives with structure.org.act alone.
DELETE is checked before anything is removed:
409 has_people(details.assigned) when employees are assigned to the position.409 has_openings(details.pipelines,details.rubrics) when the position has ever had an opening or an interview rubric. Archive it instead.
When a position is deleted, its workload tasks go with it, its external links are removed, and the deletion appears in GET /api/v1/deleted?resource=position for 180 days. Former employees whose currentPositionId was that position get currentPositionId: null, their updatedAt moves, and each sends employee.updated (changedFields: ["currentPositionId"]), or one employee.bulk_changed beyond 50.
Positions can also be deleted in the app through the data editor. Those deletions appear in GET /api/v1/deleted?resource=position the same way; a data editor commit sends one position.bulk_changed rather than position.deleted per position, so read the deletion feed after it. When a job description is deleted in the app (for example by deselecting its sub-sector), its positions stay with jobDescriptionId: null and their updatedAt moves.
Webhook events
| Event | When | data.object |
|---|---|---|
position.created | A position is created or duplicated (app or API), including a position created and opened in one step from the openings flow, a position copied when a job family is duplicated with its positions, and a draft position created by applying a workforce plan headcount shortage | The position |
position.updated | A position changes, including its hiring fields when an opening is opened, closed or made external, and its payStepNumber / baseSalary when an edit to its pay grade reprices it; data.changedFields lists the fields when known. Without structure.functional.salary, pay fields are left out of changedFields, and an update that changed only pay is not sent | The position |
position.archived | A position is archived | The position |
position.deleted | A position is deleted | { id, externalRefs } |
position.bulk_changed | Positions were imported in the app, or many changed at once | { resource: "position" } |
Deleting an org unit sends position.updated for each position it unplaces. After position.bulk_changed, re-read the list with updatedSince set to the event's occurredAt, and read GET /api/v1/deleted?resource=position&since=… for removed positions.
Errors
| Status and code | Meaning |
|---|---|
403 forbidden | The key lacks the permission in the table above, or pay fields were sent without structure.functional.salary. |
404 not_found | No position with that id in your workspace. |
409 has_people | Employees are assigned to the position. |
409 has_openings | The position has an opening or an interview rubric. |
409 conflict | The external id is already linked to another position, or capacity is below the employees in it. |
422 validation_failed | The body is invalid, or a read-only field was sent; details explains why. |
422 unresolved_reference | jobDescription, orgUnit or payScale could not be found in your workspace. |
Example
curl -X PUT https://jadiroon.com/api/v1/positions/by-external/oracle-hcm/POS-310 \
-H "Authorization: Bearer $JADIROON_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": { "ar": "أخصائي موارد بشرية", "en": "HR Specialist" },
"jobDescription": { "externalSystem": "oracle-hcm", "externalId": "JOB-12" },
"orgUnit": { "externalSystem": "oracle-hcm", "externalId": "ORG-200" },
"capacity": 2
}'
The first call creates and links the position and answers 201 Created; repeating it updates the same position and answers 200 OK with the same body shape:
{
"id": "cmvk1q2w30004kn8x7d1f0abc",
"title": { "ar": "أخصائي موارد بشرية", "en": "HR Specialist" },
"jobDescriptionId": "cmvjx0h1t0001kn3o9c2a7def",
"orgUnitId": "cmvjmf15k0008kn4q404rqwgw",
"capacity": 2,
"measurementMode": "SPECIALIZED",
"hiringStatus": "CLOSED",
"hiringPathway": null,
"hiringOpenedAt": null,
"externalAt": null,
"posting": { "ar": null, "en": null },
"payScaleId": null,
"payGradeId": null,
"payStepNumber": null,
"baseSalary": null,
"payPeriod": null,
"archivedAt": null,
"externalRefs": [{ "system": "oracle-hcm", "externalId": "POS-310" }],
"createdAt": "2026-10-22T15:02:11.204Z",
"updatedAt": "2026-10-22T15:02:11.204Z"
}