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 asnullfor keys withoutstructure.functional.salary: in lists and single reads, in the responses ofPOST,PATCH, archive, unarchive and by-externalPUT, and inposition.*andjob_description.*webhook payloads (redeliveries included). Pay fields are also left out ofdata.changedFields, and aposition.updatedorjob_description.updatedthat 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) andaudit.recorded, pay keys are left out ofmetadata.changeson position and job description rows, pay scale rows carry nometadata.changes(nor a grade'smetadata.rank), and pay ids and amounts stored at the top level ofmetadata(for example on a position or opening creation) andmetadata.gradeCodeare left out. Before, any key that could read positions or job descriptions saw their pay, and only writing pay needed the salary permission. Keys holdingstructure.functional.salaryand deployment-level keys are unchanged. Addstructure.functional.salaryto 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 coversexternalRefonPOST/PATCH /api/v1/job-descriptions. An id held by a record that still exists still returns409 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.createdandposition.createdfor each copy (or<resource>.bulk_changedbeyond 50), alongside thejob_family.updatedit already sent; only your workspace's own job descriptions and positions are copied. Applying a workforce plan headcount shortage now sendsposition.createdfor the draft position it creates. Editing a pay grade's amounts or step count now sendsposition.updated(changedFields: ["payStepNumber", "baseSalary"]) for each position it reprices andjob_description.updated(changedFields: ["salaryMin", "salaryMax"]) for each job description whose band moves (or<resource>.bulk_changedbeyond 50), alongsidepay_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}/archiveor in the app) or deleted from the org chart, each open opening it closes now sendsopening.closed(or oneopening.bulk_changedbeyond 50), alongside theposition.updatedit already sent for each position. Before, those openings changed toCLOSEDwith no event. An opening closed by deleting a job description now also gets itsclosedAt. -
GET /api/v1/pingis in the OpenAPI document:GET /api/v1/openapi.jsonand the API reference now listGET /api/v1/ping(any valid key, no permission) with its response:ok, the key's integration inactor(kindintegrationorplatform) andworkspaceId(nullfor 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 getuser.updated(changedFields: ["role"], or["status", "disabledAt"]for disable / enable) oruser.deleted, as they do for the same change in your Users & roles settings (before, onlyaudit.recordedreported 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 theirupdatedAt, so the family'supdatedAtmoves forward andupdatedSincepicks it up along with thejob_family.updated(changedFields: ["instances"]) it already sent. Before, the family'supdatedAtcould move backwards (to the remaining instances' older stamp) andupdatedSincemissed 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 ofX-Forwarded-For, so a value a client puts in that header no longer passes the allowlist. WithoutTRUSTED_PROXY, a self-hosted deployment trusts no forwarded header: keys of an integration with an allowlist get403 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}/hirenow returns409 conflictwithdetails.reasonconcurrent_hirefor the one that loses, instead of422 validation_failedcarrying a database message. An unexpected failure during/hire,/stageor/rejectnow returns500 internal(safe to retry with backoff) instead of422 validation_failedwith 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/candidateswith a non-emptyemail,phoneornationalIdnow returns403 forbidden(details.reasoncandidate_contact_required,details.required["recruitment.candidate.contact"]) for a key without the contact permission, before any duplicate check, so409 conflict(details.reasonduplicate_person) no longer tells such a key that a person with that email or national ID exists. InPOST /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 sendnull);PATCHstill 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(soupdatedSincepicks it up) and sendsjob_family.updated(changedFields: ["instances"]), orjob_family.bulk_changedbeyond 50, because itssubSectorschanged. 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}/cvandGET /api/v1/offers/{id}/filenow 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 withrecruitment.viewbut withoutrecruitment.candidate.contactnow gets403 forbidden(details.reasoncandidate_contact_required,details.required["recruitment.candidate.contact"]) where it used to get the file. Add the permission to the integration to keep downloading.hasCvandhasFileare 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(resourceapplication,offer,intervieworopening) with theexternalRefsit 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 sendsopening.bulk_changed(new event type); re-read openings withupdatedSinceand the deletion feed. -
An unfinished
Idempotency-Keyis 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 returns409 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 whosecurrentPositionIdwas that position now getcurrentPositionId: nullwith theirupdatedAtmoved (soupdatedSincepicks them up), and each sendsemployee.updated(changedFields: ["currentPositionId"]), or oneemployee.bulk_changedbeyond 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,/rejectand/hireon aHIREDapplication return409 conflictwithdetails.reasonalready_hired(before, a hired application could be moved back or rejected)./hireon aREJECTEDapplication returns409 conflict(already_rejected); move it back to an active stage with/stagefirst. Hiring an application that is already hired now reportsalready_hiredinstead ofnot_a_candidate. Rejecting an already rejected application is still a no-op. The app follows the same rules. -
Job description
PATCHkeeps the salary band: aPATCH /api/v1/job-descriptions/{id}withoutpayScaleandpayGradeno longer clearssalaryMinandsalaryMaxon 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. SendingpayScaleorpayGrade(withstructure.functional.salary) still sets the band from the grade, andpayScale: nullstill clears it. -
updatedSincepaging no longer skips rows or fails on a deleted row:nextCursornow carries the last row's position (updatedAtandid;occurredAtandidonGET /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 with422 validation_failed. Cursors stay opaque, and cursors issued before this change still work as before. A malformed cursor still returns422 validation_failed. Lists withoutupdatedSinceare unchanged. -
One
interview.completedper interview: the first panelist's scorecard sets the interview toCOMPLETEDand sendsinterview.completedonce; the other panelists can still submit (or edit) theirs, and each sendsinterview.updatedwithchangedFields: ["evaluation"](before, every scorecard sentinterview.completedand the status stayedCONFIRMED). Each scorecard now moves the interview'supdatedAt.PATCH /api/v1/interviews/{id}on aCOMPLETEDinterview returns409 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}/archivecloses the position's open opening, so, as in the app, it now also needsrecruitment.hiring.openwhen one is open; without it the response is403 forbidden(details.reasonhiring_permission_required,details.permissionrecruitment.hiring.open) and nothing changes. A position with no open opening still archives withstructure.org.actalone. -
Private file streams are workspace-checked:
GET /api/v1/candidates/{id}/cvandGET /api/v1/offers/{id}/filestream a stored file only when it belongs to the record's workspace; any other file answers404 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.recordedor a type from the event catalog (<resource>.<verb>, for exampleemployee.updated), as workspace endpoints already did; any other type is refused with422 unknown_event_type(details.invalidlists them) and nothing is saved. A deployment-level integration may still always pickaudit.recorded. As for workspace endpoints, removing event types from an endpoint marks its pending deliveries of those typesFAILED(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 itsupdatedAt(soupdatedSincepicks it up) and sendsjob_family.updated(changedFields: ["instances"]) andjob_description.updated(changedFields: ["skills"]), or<resource>.bulk_changedbeyond 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
cvUrlmust download within 20 seconds in all (before, the address lookup and the download had 20 seconds each), otherwise the row fails with422 validation_failed(cv_unreachable). A row repeated after an interrupted run no longer downloads its CV again, so it no longer sends a secondcandidate.updatedor 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 asDeployment-level integration · <name>; inaudit.recordedtheiractor.idis the deployment-level integration's id. Nothing else in/api/v1or the webhook payloads changed.
2026-10-24
- Pre-release fixes (before the API goes public):
validation_failedis always422(it was400for a badlimit,cursor,updatedSinceorsince, a non-JSON body or a missingIdempotency-Key).POST /api/v1/openingsacceptsposting: { ar, en }, written as the position's posting text, so positions created through the API can be opened (without one:422 validation_failedwithdetails.reasonno_job_description); opening a position whose job description is archived returns422 validation_failed(details.reasonarchived). Assigning an employee to a position on the org chart sendsemployee.updated(changedFields: ["currentPositionId"]); setting an employee'sstatustoFORMER_EMPLOYEEalso sendsemployee.offboarded; families added during workspace setup sendjob_family.createdthe first time. A save that changes nothing (org unit or position) no longer writes an audit row or sends.updated. Pay fields withoutstructure.functional.salaryare refused with403before 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 returns409 conflict(details.reasonrotation_in_progress). Audit feed andaudit.recorded: job description rows omit internal grading fields fromchanges; an integration actor keepstype: "integration"(withid: null) after the integration is deleted; platform rows use snake_casetarget.typeand 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 rowsforbiddenwhen a needed permission is removed, report a CV on a non-public host as a422row error, and drop their submitted rows whenDONE. - 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.recordedor a type from the event catalog (<resource>.<verb>, for exampleemployee.updated);audit.recordedcan be picked only while the integration holdsaudit.view, and*still never includes it. Sending a test event (ping) and redelivering an event now write the audit actionssettings.integration.endpoint_testedandsettings.integration.delivery_redelivered, which appear inGET /api/v1/audit-eventsandaudit.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
GETendpoint exceptGET /api/v1/bulk-jobs/{id}; writes return403. Every object it reads carriesworkspaceId, and lists accept?workspaceId=to narrow to one workspace (404for 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-eventsreturns 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 (422otherwise). Workspace keys are unchanged and ignore?workspaceId=. The OpenAPI document listsworkspaceIdon everyGETand marks each operation withx-deployment-level. See Entity-level read. - Bulk upsert:
POST /api/v1/employees/bulk,/api/v1/candidates/bulk,/api/v1/org-units/bulkand/api/v1/positions/bulktake 1 to 1,000 rows keyed by{ system, externalId }(Idempotency-Keyrequired) and answer202 { jobId, statusUrl };GET /api/v1/bulk-jobs/{id}returns the status, counts and per-row results (created,updatedorfailedwith an error code), to the integration that posted the job only. Each row follows the same rules as the single by-externalPUTand writes its own audit row and webhook event. Candidate rows may carry anhttpscvUrl(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}/cvandGET /api/v1/offers/{id}/filestream them unchanged (same permissions, samecontent-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/deletednow lists only the resources your key can read, with the same permission as each resource'sGETroute (for example, a key with onlystructure.org.viewseesorg_unitandpositiontombstones).?resource=for a resource the key can't read returns403 forbidden. Webhook endpoints already received events (including<resource>.deletedand<resource>.bulk_changed) only for resources their integration can read, with candidate contact masked withoutrecruitment.candidate.contact; deployment-level integrations see every resource. - Interviews, users, roles and the audit feed:
GET/POST /api/v1/interviewsandGET/PATCH /api/v1/interviews/{id}(read:recruitment.view; write:recruitment.act;PATCHproposes new times whileOFFERED, or cancels with{ "status": "CANCELLED" }), with the eventsinterview.scheduled,interview.updated,interview.cancelledandinterview.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) andGET /api/v1/roles(with their permission keys), both read-only withusers.view; andGET /api/v1/audit-events(audit.view), the audit feed for your SIEM: rows as stored, oldest first,updatedSinceonoccurredAt, candidate contact values inchangesmasked withoutrecruitment.candidate.contact. The interviewpersonis masked the same way. Users and roles carryupdatedAt(updatedSincepicks up role changes) and senduser.updated,user.deleted,role.created,role.updatedandrole.deleted; removed users and deleted roles are in the deletion feed. Cancelling an already cancelled interview is a no-op; a completed one returns409 conflict(details.reasoninterview_closed). - Openings, applications and offers:
GET/POST /api/v1/openings,GET /api/v1/openings/{id}andPOST /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}/stageandPOST /api/v1/applications/{id}/reject(read:recruitment.view; write:recruitment.act; adding follows the opening's hiring pathway, as on the careers page) andPOST /api/v1/applications/{id}/hire(recruitment.hireandrecruitment.candidate.contact);GET /api/v1/offers,GET /api/v1/offers/{id}andGET /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.updatedandapplication.hired; a hire also sendsemployee.created, and the candidate'sexternalRefsmove to the employee. Opening and closing hiring in the app now also sendposition.updated, and positions created through the openings flow sendposition.created.
2026-10-22
- Employees and candidates:
GET/POST /api/v1/employees,GET/PATCH/DELETE /api/v1/employees/{id},POST /api/v1/employees/{id}/archiveandPUT /api/v1/employees/by-external/{system}/{externalId}(read:people.view; write:people.act), andGET/POST /api/v1/candidates,GET/PATCH /api/v1/candidates/{id}andGET /api/v1/candidates/{id}/cv(read:recruitment.view; write:recruitment.act), with the eventsemployee.created,employee.updated,employee.offboarded,employee.archived,employee.deleted,employee.bulk_changed,candidate.created,candidate.updated,candidate.archivedandcandidate.deleted.DELETEon 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 withoutrecruitment.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}/archiveandPOST /api/v1/job-descriptions/{id}/unarchive(read:structure.functional.vieworrecruitment.view; write:structure.functional.act), with the eventsjob_description.created,job_description.updated,job_description.archived,job_description.unarchived,job_description.deletedandjob_description.bulk_changed.POSTandPATCHacceptexternalRefto link your own id;PATCHleaves omitted fields and skill arrays unchanged; job descriptions from the national SSCO catalog are read-only (409 ssco_managed); noDELETE. Removing sub-sectors or job families in the app now also sendsjob_description.deletedandposition.updatedfor 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) andGET /api/v1/pay-scaleswith grades and steps (structure.functional.salary), with the eventssector.*,sub_sector.*,job_family.*andpay_scale.*. Catalog rows carry a stableslug; 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}/archiveandPUT /api/v1/positions/by-external/{system}/{externalId}, with the eventsposition.created,position.updated,position.archived,position.deletedandposition.bulk_changed. Hiring fields are read-only; pay fields needstructure.functional.salary. - Org units:
GET/POST /api/v1/org-units,GET/PATCH/DELETE /api/v1/org-units/{id}andPUT /api/v1/org-units/by-external/{system}/{externalId}, with the eventsorg_unit.created,org_unit.updated,org_unit.deletedandorg_unit.bulk_changed. Deleting a unit sendsposition.updatedfor 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_limitedwithRetry-After),GET /api/v1/ping, the publicGET /api/v1/openapi.json, cursor pagination withupdatedSinceon every list, the deletion feedGET /api/v1/deleted(tombstones kept 180 days),Idempotency-KeyonPOST(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-inaudit.recorded). API writes appear in the audit log as Integration · (name). Records that existed before the launch carryupdatedAt= the launch date, so the firstupdatedSincesync returns everything.