API changelog

For HR administrators

Every change to the public API (/api/v1) and its webhook events is listed here, newest first. Additive changes ship in v1; breaking changes would go to /api/v2 with a deprecation window.

2026-10-25

  • Webhook retries and bulk job continuation run once a day on the hosted service: the hosted service currently runs its background dispatcher once a day, at 03:00 UTC. A webhook's first attempt and a bulk job's first minute of work still run right away. Retries, the rest of longer bulk jobs, deliveries held by a pause and retention run at the next daily run, and an endpoint failing for 3 days is disabled at its next failed attempt (on the hosted service usually the next daily run). A retry goes out at the first daily run after its backoff delay has passed and the 10 attempts span 8 days or more (instead of about 3 days and 20 hours). The backoff schedule itself is unchanged, and a self-hosted deployment that runs the dispatcher every minute keeps the documented timing. See When retries run.

  • Breaking for keys without structure.functional.salary: pay is hidden on every read. Positions (payScaleId, payGradeId, payStepNumber, baseSalary, payPeriod) and job descriptions (payScaleId, payGradeId, salaryMin, salaryMax) now return these fields as null for keys without structure.functional.salary: in lists and single reads, in the responses of POST, PATCH, archive, unarchive and by-external PUT, and in position.* and job_description.* webhook payloads (redeliveries included). Pay fields are also left out of data.changedFields, and a position.updated or job_description.updated that changed only pay (for example a pay grade repricing) is no longer sent to those endpoints. In the audit feed (GET /api/v1/audit-events) and audit.recorded, pay keys are left out of metadata.changes on position and job description rows, pay scale rows carry no metadata.changes (nor a grade's metadata.rank), and pay ids and amounts stored at the top level of metadata (for example on a position or opening creation) and metadata.gradeCode are left out. Before, any key that could read positions or job descriptions saw their pay, and only writing pay needed the salary permission. Keys holding structure.functional.salary and deployment-level keys are unchanged. Add structure.functional.salary to an integration that needs pay.

  • Re-linking an external id after its record was deleted: linking an external id that is still held by a record that no longer exists (for example a job description, position, org unit or person deleted in the app) now drops the stale link and links the id to the new record, instead of returning 409 conflict. This covers externalRef on POST / PATCH /api/v1/job-descriptions. An id held by a record that still exists still returns 409 conflict.

  • Duplicated families, workforce plan drafts and pay grade edits send position and job description events: duplicating a job family instance into another mandate with its job descriptions and positions now sends job_description.created and position.created for each copy (or <resource>.bulk_changed beyond 50), alongside the job_family.updated it already sent; only your workspace's own job descriptions and positions are copied. Applying a workforce plan headcount shortage now sends position.created for the draft position it creates. Editing a pay grade's amounts or step count now sends position.updated (changedFields: ["payStepNumber", "baseSalary"]) for each position it reprices and job_description.updated (changedFields: ["salaryMin", "salaryMax"]) for each job description whose band moves (or <resource>.bulk_changed beyond 50), alongside pay_scale.updated. Before, these rows changed with no event. A grade edit that changes no pay (name, code or rank only) no longer moves those positions' and job descriptions' updatedAt.

  • Archiving or deleting a job description sends opening.closed: when a job description is archived (POST /api/v1/job-descriptions/{id}/archive or in the app) or deleted from the org chart, each open opening it closes now sends opening.closed (or one opening.bulk_changed beyond 50), alongside the position.updated it already sent for each position. Before, those openings changed to CLOSED with no event. An opening closed by deleting a job description now also gets its closedAt.

  • GET /api/v1/ping is in the OpenAPI document: GET /api/v1/openapi.json and the API reference now list GET /api/v1/ping (any valid key, no permission) with its response: ok, the key's integration in actor (kind integration or platform) and workspaceId (null for a deployment-level key). The endpoint itself is unchanged.

  • Platform Console user changes send user.* events: when Jadiroon's support team changes one of your staff users' role, disables or re-enables the account, or removes it from the Platform Console, your workspace's endpoints now get user.updated (changedFields: ["role"], or ["status", "disabledAt"] for disable / enable) or user.deleted, as they do for the same change in your Users & roles settings (before, only audit.recorded reported them). A removed staff user is also in the deletion feed.

  • Removing one instance of a job family moves its updatedAt: when your workspace removes one instance of a job family that has others (for example, one mandate), the remaining instances now move their updatedAt, so the family's updatedAt moves forward and updatedSince picks it up along with the job_family.updated (changedFields: ["instances"]) it already sent. Before, the family's updatedAt could move backwards (to the remaining instances' older stamp) and updatedSince missed the change.

  • The IP allowlist reads a client IP that can't be faked: the API-key IP allowlist, rate limits and the IP on audit rows now use one rule for the client IP. On Jadiroon's cloud nothing changes (the hosting edge sets the client IP). Self-hosted deployments set TRUSTED_PROXY (the number of reverse proxies, or their IPs / CIDR ranges) and the client IP is read from the right of X-Forwarded-For, so a value a client puts in that header no longer passes the allowlist. Without TRUSTED_PROXY, a self-hosted deployment trusts no forwarded header: keys of an integration with an allowlist get 403 forbidden. See IP allowlist.

  • Hire and stage-move errors no longer leak internal text: when two hires of the same candidate run at the same time (for example through two of their applications), POST /api/v1/applications/{id}/hire now returns 409 conflict with details.reason concurrent_hire for the one that loses, instead of 422 validation_failed carrying a database message. An unexpected failure during /hire, /stage or /reject now returns 500 internal (safe to retry with backoff) instead of 422 validation_failed with the internal error text. The documented reasons (position_full, not_a_candidate, opening_not_open, already_hired, already_rejected) are unchanged.

  • Breaking: creating candidates with contact details needs recruitment.candidate.contact: POST /api/v1/candidates with a non-empty email, phone or nationalId now returns 403 forbidden (details.reason candidate_contact_required, details.required ["recruitment.candidate.contact"]) for a key without the contact permission, before any duplicate check, so 409 conflict (details.reason duplicate_person) no longer tells such a key that a person with that email or national ID exists. In POST /api/v1/candidates/bulk, a row with contact details fails the same way, that row only, whether it creates or updates the candidate. Keys without the contact permission can still create candidates without contact (leave the fields out or send null); PATCH still ignores contact fields without it. Give migration and ERP integrations the contact permission to import contact details.

  • Selecting or deselecting a sub-sector updates the families under it: when your workspace selects or deselects sub-sectors (during setup or on the Functional Structure page), every job family it keeps that sits under one of those sub-sectors now moves its instances' updatedAt (so updatedSince picks it up) and sends job_family.updated (changedFields: ["instances"]), or job_family.bulk_changed beyond 50, because its subSectors changed. Before, only the families removed with a deselected sub-sector sent an event.

  • Breaking: CV and offer-letter downloads need recruitment.candidate.contact: GET /api/v1/candidates/{id}/cv and GET /api/v1/offers/{id}/file now also need the contact permission, because the CV shows the candidate's email and phone and the offer letter can print their national ID. A key with recruitment.view but without recruitment.candidate.contact now gets 403 forbidden (details.reason candidate_contact_required, details.required ["recruitment.candidate.contact"]) where it used to get the file. Add the permission to the integration to keep downloading. hasCv and hasFile are unchanged, and deployment-level keys are not affected.

  • Deleted applications, offers, interviews and openings are in the deletion feed: when the app deletes a person (People, or a candidate deleting their own account), their applications, the offers on them and their interviews are hard-deleted; deleting a person with cascade in the data editor deletes their applications and interviews too, and deleting a position with cascade there deletes its openings (a position whose openings have applications can't be deleted from the data editor). Each deleted row now gets a tombstone in GET /api/v1/deleted (resource application, offer, interview or opening) with the externalRefs it had, and its links are removed, so upserting the same external id later creates a new record. Before, these rows disappeared with no tombstone and their links stayed behind. A data editor commit that cascade-deletes a position also sends opening.bulk_changed (new event type); re-read openings with updatedSince and the deletion feed.

  • An unfinished Idempotency-Key is held for 90 seconds: a key whose first request never finished is now freed 90 seconds after it was first used (it was 60 seconds, as long as the longest request may run, so a slow request still running could have its key taken by a retry and run twice). Until then a retry with the same key returns 409 idempotency_conflict. A request whose key was freed this way no longer overwrites the response stored by the retry.

  • Deleting a position updates the former employees it held: when a position is deleted (DELETE /api/v1/positions/{id} or in the app), former employees whose currentPositionId was that position now get currentPositionId: null with their updatedAt moved (so updatedSince picks them up), and each sends employee.updated (changedFields: ["currentPositionId"]), or one employee.bulk_changed beyond 50. Before, the link was cleared silently. Deleting a position with current employees is still refused (409 has_people).

  • Hired and rejected applications are guarded: POST /api/v1/applications/{id}/stage, /reject and /hire on a HIRED application return 409 conflict with details.reason already_hired (before, a hired application could be moved back or rejected). /hire on a REJECTED application returns 409 conflict (already_rejected); move it back to an active stage with /stage first. Hiring an application that is already hired now reports already_hired instead of not_a_candidate. Rejecting an already rejected application is still a no-op. The app follows the same rules.

  • Job description PATCH keeps the salary band: a PATCH /api/v1/job-descriptions/{id} without payScale and payGrade no longer clears salaryMin and salaryMax on a job description with no pay grade (a band set in the data editor), nor re-reads the band of one that has a grade. Sending payScale or payGrade (with structure.functional.salary) still sets the band from the grade, and payScale: null still clears it.

  • updatedSince paging no longer skips rows or fails on a deleted row: nextCursor now carries the last row's position (updatedAt and id; occurredAt and id on GET /api/v1/audit-events), so a row edited between two page requests no longer makes the next page skip rows, and a deleted last row no longer makes the next page fail with 422 validation_failed. Cursors stay opaque, and cursors issued before this change still work as before. A malformed cursor still returns 422 validation_failed. Lists without updatedSince are unchanged.

  • One interview.completed per interview: the first panelist's scorecard sets the interview to COMPLETED and sends interview.completed once; the other panelists can still submit (or edit) theirs, and each sends interview.updated with changedFields: ["evaluation"] (before, every scorecard sent interview.completed and the status stayed CONFIRMED). Each scorecard now moves the interview's updatedAt. PATCH /api/v1/interviews/{id} on a COMPLETED interview returns 409 conflict (interview_closed) for new times as well as for cancelling.

  • Archiving a position with an open opening needs recruitment.hiring.open: POST /api/v1/positions/{id}/archive closes the position's open opening, so, as in the app, it now also needs recruitment.hiring.open when one is open; without it the response is 403 forbidden (details.reason hiring_permission_required, details.permission recruitment.hiring.open) and nothing changes. A position with no open opening still archives with structure.org.act alone.

  • Private file streams are workspace-checked: GET /api/v1/candidates/{id}/cv and GET /api/v1/offers/{id}/file stream a stored file only when it belongs to the record's workspace; any other file answers 404 not_found, as a missing one does. The data editor accepts only an http(s) link (or an empty value) in a person's CV, offer letter or avatar cell; any other value, such as a private file reference, fails the commit with a validation error. Files are attached through upload only.

  • Console Owners are emailed when a deployment-level endpoint is auto-disabled: when a Platform Console webhook endpoint is disabled after 3 days of continuous failure, every console Owner gets one email naming the integration, the endpoint host and where to re-enable it (Platform Console → Integrations). Workspace endpoints are unchanged (Workspace Admins get an in-app notification).

  • Deployment-level endpoint event types are checked against the catalog: creating or editing a Platform Console webhook endpoint now accepts only *, audit.recorded or a type from the event catalog (<resource>.<verb>, for example employee.updated), as workspace endpoints already did; any other type is refused with 422 unknown_event_type (details.invalid lists them) and nothing is saved. A deployment-level integration may still always pick audit.recorded. As for workspace endpoints, removing event types from an endpoint marks its pending deliveries of those types FAILED (scope changed).

  • Renaming or deleting a workspace skill updates the families and job descriptions that list it: when your workspace renames (nameAr / nameEn) or deletes one of its own skills in the app, every job family instance and job description in your workspace that lists it moves its updatedAt (so updatedSince picks it up) and sends job_family.updated (changedFields: ["instances"]) and job_description.updated (changedFields: ["skills"]), or <resource>.bulk_changed beyond 50. Editing only a skill's description, tag or level descriptions sends nothing (they are not in the v1 objects).

  • Bulk jobs stop when the workspace is suspended, and a candidate CV is fetched once: a running bulk job now stops after the chunk it is on when its workspace is suspended (before, only a job not yet started waited), and resumes where it stopped when the workspace is active again. A candidate row's cvUrl must download within 20 seconds in all (before, the address lookup and the download had 20 seconds each), otherwise the row fails with 422 validation_failed (cv_unreachable). A row repeated after an interrupted run no longer downloads its CV again, so it no longer sends a second candidate.updated or writes a second audit row.

  • The console Activity log names the integration behind an API change: in the Platform Console's Activity log (filtered to a workspace), changes made through a workspace API key now show as Integration · <integration name> instead of a generic workspace user. Rows written by a deployment-level integration (none today: deployment-level keys are read-only) are linked to it, belong to no workspace (so they show in the unfiltered log) and show as Deployment-level integration · <name>; in audit.recorded their actor.id is the deployment-level integration's id. Nothing else in /api/v1 or the webhook payloads changed.

2026-10-24

  • Pre-release fixes (before the API goes public): validation_failed is always 422 (it was 400 for a bad limit, cursor, updatedSince or since, a non-JSON body or a missing Idempotency-Key). POST /api/v1/openings accepts posting: { ar, en }, written as the position's posting text, so positions created through the API can be opened (without one: 422 validation_failed with details.reason no_job_description); opening a position whose job description is archived returns 422 validation_failed (details.reason archived). Assigning an employee to a position on the org chart sends employee.updated (changedFields: ["currentPositionId"]); setting an employee's status to FORMER_EMPLOYEE also sends employee.offboarded; families added during workspace setup send job_family.created the first time. A save that changes nothing (org unit or position) no longer writes an audit row or sends .updated. Pay fields without structure.functional.salary are refused with 403 before the pay scale is looked up. Webhooks: pending deliveries are dropped (scope changed) when the integration loses a permission or the endpoint drops their event type; redeliver re-renders with the current permissions and is refused (409 / 403) when out of scope, and needs the endpoint and integration active; a second secret rotation during the 24-hour overlap returns 409 conflict (details.reason rotation_in_progress). Audit feed and audit.recorded: job description rows omit internal grading fields from changes; an integration actor keeps type: "integration" (with id: null) after the integration is deleted; platform rows use snake_case target.type and include Jadiroon catalog edits (taxonomy.*); revealing a signing secret is audited (settings.integration.secret_revealed, admin.integration.endpoint_secret_revealed). Bulk jobs stop between chunks when the integration is paused, fail the remaining rows forbidden when a needed permission is removed, report a CV on a non-public host as a 422 row error, and drop their submitted rows when DONE.
  • Documentation: new guide pages (Overview, Conventions with the full error-code list, ERP sync, Bulk migration, Webhooks with Node and Python signature verification, the generated Event catalog, Audit feed for SIEM, one page per lifecycle stage) and an interactive API reference rendered from GET /api/v1/openapi.json. No API behaviour changed.
  • Webhook endpoints of workspace integrations: a Workspace Admin manages them in Settings → Integrations (with the integration's permissions and API keys). Endpoint event types must be *, audit.recorded or a type from the event catalog (<resource>.<verb>, for example employee.updated); audit.recorded can be picked only while the integration holds audit.view, and * still never includes it. Sending a test event (ping) and redelivering an event now write the audit actions settings.integration.endpoint_tested and settings.integration.delivery_redelivered, which appear in GET /api/v1/audit-events and audit.recorded.
  • Deployment-level read integration: on a dedicated deployment, a super admin can create a read-only integration in the Platform Console that reads every active workspace. Its keys work on every GET endpoint except GET /api/v1/bulk-jobs/{id}; writes return 403. Every object it reads carries workspaceId, and lists accept ?workspaceId= to narrow to one workspace (404 for an unknown or inactive one). The Functional Structure endpoints (/sectors, /sub-sectors, /job-families) require ?workspaceId= for these keys and return that workspace's view. Contact is never masked for it. GET /api/v1/audit-events returns every active workspace's rows plus platform rows (workspaceId: null). Its webhook endpoints receive events from every active workspace. Keys need an expiry date and an IP allowlist on the integration (422 otherwise). Workspace keys are unchanged and ignore ?workspaceId=. The OpenAPI document lists workspaceId on every GET and marks each operation with x-deployment-level. See Entity-level read.
  • Bulk upsert: POST /api/v1/employees/bulk, /api/v1/candidates/bulk, /api/v1/org-units/bulk and /api/v1/positions/bulk take 1 to 1,000 rows keyed by { system, externalId } (Idempotency-Key required) and answer 202 { jobId, statusUrl }; GET /api/v1/bulk-jobs/{id} returns the status, counts and per-row results (created, updated or failed with an error code), to the integration that posted the job only. Each row follows the same rules as the single by-external PUT and writes its own audit row and webhook event. Candidate rows may carry an https cvUrl (PDF, 5 MB). At most 2 jobs per integration at a time (429 too_many_jobs). See Bulk upsert.
  • CV and offer-letter streams serve private files: CVs, person documents, credential certificates, offer letters and application attachments are now stored privately, with no public link. GET /api/v1/candidates/{id}/cv and GET /api/v1/offers/{id}/file stream them unchanged (same permissions, same content-disposition: attachment); files uploaded before the change keep streaming until they are migrated. No response field changes: the API never returned a storage location.
  • Deletion feed and webhooks follow the key's read permissions: GET /api/v1/deleted now lists only the resources your key can read, with the same permission as each resource's GET route (for example, a key with only structure.org.view sees org_unit and position tombstones). ?resource= for a resource the key can't read returns 403 forbidden. Webhook endpoints already received events (including <resource>.deleted and <resource>.bulk_changed) only for resources their integration can read, with candidate contact masked without recruitment.candidate.contact; deployment-level integrations see every resource.
  • Interviews, users, roles and the audit feed: GET/POST /api/v1/interviews and GET/PATCH /api/v1/interviews/{id} (read: recruitment.view; write: recruitment.act; PATCH proposes new times while OFFERED, or cancels with { "status": "CANCELLED" }), with the events interview.scheduled, interview.updated, interview.cancelled and interview.completed (also for interviews scheduled, re-proposed, picked, cancelled and scored in the app); GET /api/v1/users (staff accounts, no passwords, tokens or MFA data) and GET /api/v1/roles (with their permission keys), both read-only with users.view; and GET /api/v1/audit-events (audit.view), the audit feed for your SIEM: rows as stored, oldest first, updatedSince on occurredAt, candidate contact values in changes masked without recruitment.candidate.contact. The interview person is masked the same way. Users and roles carry updatedAt (updatedSince picks up role changes) and send user.updated, user.deleted, role.created, role.updated and role.deleted; removed users and deleted roles are in the deletion feed. Cancelling an already cancelled interview is a no-op; a completed one returns 409 conflict (details.reason interview_closed).
  • Openings, applications and offers: GET/POST /api/v1/openings, GET /api/v1/openings/{id} and POST /api/v1/openings/{id}/close (read: recruitment.view; open and close: recruitment.hiring.open, as in the app); GET/POST /api/v1/applications, GET /api/v1/applications/{id}, POST /api/v1/applications/{id}/stage and POST /api/v1/applications/{id}/reject (read: recruitment.view; write: recruitment.act; adding follows the opening's hiring pathway, as on the careers page) and POST /api/v1/applications/{id}/hire (recruitment.hire and recruitment.candidate.contact); GET /api/v1/offers, GET /api/v1/offers/{id} and GET /api/v1/offers/{id}/file (recruitment.view, read-only, the letter is streamed). Events: opening.created, opening.opened, opening.closed, opening.archived, opening.updated, application.created, application.stage_changed, application.rejected, application.updated and application.hired; a hire also sends employee.created, and the candidate's externalRefs move to the employee. Opening and closing hiring in the app now also send position.updated, and positions created through the openings flow send position.created.

2026-10-22

  • Employees and candidates: GET/POST /api/v1/employees, GET/PATCH/DELETE /api/v1/employees/{id}, POST /api/v1/employees/{id}/archive and PUT /api/v1/employees/by-external/{system}/{externalId} (read: people.view; write: people.act), and GET/POST /api/v1/candidates, GET/PATCH /api/v1/candidates/{id} and GET /api/v1/candidates/{id}/cv (read: recruitment.view; write: recruitment.act), with the events employee.created, employee.updated, employee.offboarded, employee.archived, employee.deleted, employee.bulk_changed, candidate.created, candidate.updated, candidate.archived and candidate.deleted. DELETE on an employee offboards (FORMER_EMPLOYEE); the API never deletes a person. Candidate email, phone and national ID are masked, in responses and webhook payloads, for keys without recruitment.candidate.contact. The CV is streamed as a download; its storage location is never returned.
  • Job descriptions: GET/POST /api/v1/job-descriptions, GET/PATCH /api/v1/job-descriptions/{id}, POST /api/v1/job-descriptions/{id}/archive and POST /api/v1/job-descriptions/{id}/unarchive (read: structure.functional.view or recruitment.view; write: structure.functional.act), with the events job_description.created, job_description.updated, job_description.archived, job_description.unarchived, job_description.deleted and job_description.bulk_changed. POST and PATCH accept externalRef to link your own id; PATCH leaves omitted fields and skill arrays unchanged; job descriptions from the national SSCO catalog are read-only (409 ssco_managed); no DELETE. Removing sub-sectors or job families in the app now also sends job_description.deleted and position.updated for what it removed or closed, and data editor commits send <resource>.bulk_changed.
  • Functional Structure and pay scales (read-only): GET /api/v1/sectors, GET /api/v1/sub-sectors, GET /api/v1/job-families, GET /api/v1/job-families/{jobFamilyId} (structure.functional.view) and GET /api/v1/pay-scales with grades and steps (structure.functional.salary), with the events sector.*, sub_sector.*, job_family.* and pay_scale.*. Catalog rows carry a stable slug; job families list your workspace's instances (mandate, sub-sectors, skills).
  • Positions: GET/POST /api/v1/positions, GET/PATCH/DELETE /api/v1/positions/{id}, POST /api/v1/positions/{id}/archive and PUT /api/v1/positions/by-external/{system}/{externalId}, with the events position.created, position.updated, position.archived, position.deleted and position.bulk_changed. Hiring fields are read-only; pay fields need structure.functional.salary.
  • Org units: GET/POST /api/v1/org-units, GET/PATCH/DELETE /api/v1/org-units/{id} and PUT /api/v1/org-units/by-external/{system}/{externalId}, with the events org_unit.created, org_unit.updated, org_unit.deleted and org_unit.bulk_changed. Deleting a unit sends position.updated for each position it unplaces.
  • API v1 launch: integrations with permission scopes, API keys (Authorization: Bearer jdr_live_…, shown once, optional expiry), an optional IP allowlist, 600 requests per minute per key (429 rate_limited with Retry-After), GET /api/v1/ping, the public GET /api/v1/openapi.json, cursor pagination with updatedSince on every list, the deletion feed GET /api/v1/deleted (tombstones kept 180 days), Idempotency-Key on POST (24 hours), the closed error-code list, and signed webhooks (Jadiroon-Signature, 10 attempts with backoff over about 3 days and 20 hours when the retry dispatcher runs every minute, auto-disable after 3 days of failures, 30-day retention, opt-in audit.recorded). API writes appear in the audit log as Integration · (name). Records that existed before the launch carry updatedAt = the launch date, so the first updatedSince sync returns everything.