An application is one candidate on one opening. It moves through the hiring stages (applied, screened, interviewing, offered) until the candidate is hired or rejected. Use these endpoints to add candidates from a job board or another ATS, to follow and move them through the stages, and to hire them, which makes them employees.
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/applications | List applications (cursor pagination, updatedSince, ?openingId=&candidateId=&stage=) | recruitment.view |
POST /api/v1/applications | Add a candidate to an opening | recruitment.act |
GET /api/v1/applications/{id} | Get one application | recruitment.view |
POST /api/v1/applications/{id}/stage | Move it to a stage | recruitment.act (OFFERED also needs recruitment.offer.issue) |
POST /api/v1/applications/{id}/reject | Reject it | recruitment.act |
POST /api/v1/applications/{id}/hire | Hire the candidate | recruitment.hire and recruitment.candidate.contact |
recruitment.act includes recruitment.view. An id from another workspace returns 404 not_found.
The application object
| Field | Type | Notes |
|---|---|---|
id | string | The application's id. |
openingId, positionId | string | The opening and the position it hires for. |
personId | string | The candidate; after the hire, the same id is the employee. |
candidate | object | { id, firstName, lastName, email, phone }. email and phone are masked without recruitment.candidate.contact, also after the hire. |
stage | string | APPLIED, SCREENED, ASSESSMENT, INTERVIEWING, OFFERED, HIRED or REJECTED. |
screening | object | AI CV screening: { status, score }. status is PENDING while it runs (only when a CV is on file), then DONE, FAILED or QUOTA_EXCEEDED; score is 0–100 or null. |
rejectionReason | string or null | When REJECTED with a documented reason (see below). |
hasOffer | boolean | An offer letter was issued; read it from Offers with the same id. |
appliedAt | ISO 8601 | |
externalRefs | array | [{ system, externalId }] links to your systems. |
createdAt, updatedAt | ISO 8601 |
Adding a candidate
POST /api/v1/applications takes { "openingId": "…", "candidate": { "id": "…" } }; the candidate can also be given as { "externalSystem", "externalId" }. The candidate must already exist (create them with Candidates), and the opening must be OPEN (409 conflict, opening_not_open).
- The opening's hiring pathway must be open to external candidates, as on your careers page: an
INTERNAL_ONLYopening returns409 conflict(opening_internal_only), and anINTERNAL_FIRSTopening returns409 conflict(not_yet_external, withdetails.externalAt) until its internal window ends. - A position whose job description is archived takes no new applications (
422 validation_failed,archived). - A candidate already on that opening returns
409 conflictwithdetails.reasonalready_appliedanddetails.applicationId. - A Saudi-only job description needs a Saudi candidate (
422 validation_failed,saudi_only), and the candidate's nationality must be known to check it (nationality_required). - When the candidate has a CV on file, the AI screening runs after the response; watch for
application.updatedwithscreeninginchangedFields.
Moving, rejecting, hiring
- Stage:
POST …/stagewith{ "stage": "INTERVIEWING" }. Allowed targets areAPPLIED,SCREENED,INTERVIEWINGandOFFERED; use/rejectand/hirefor the others. Moving to the current stage changes nothing. Moving toSCREENEDassigns the position's assessments, as in the app, and the application can then move on toASSESSMENTby itself. The candidate receives the stage email your workspace has set up. - Reject:
POST …/rejectwith an optional{ "reason": "withdrew", "note": "…" }. Reasons:not-shortlisted,not-saudi-saudization-restricted,withdrew,over-qualified,under-qualified,failed-interview,other. - Hire:
POST …/hirewith an optional{ "startDate": "2026-11-01", "manager": { "id": "…" } }(manageris an employee, by id or external id). The candidate becomes an employee on the opening's position, with the same id, and appears inGET /api/v1/employees. The candidate'sexternalRefsmove with them to the employee. A full position returns409 conflict(position_full); a person who is no longer a candidate returns409 conflict(not_a_candidate); an application whose opening is closed returns409 conflict(opening_not_open); when two hires of the same candidate run at the same time (for example through two of their applications), one goes through and the other returns409 conflict(concurrent_hire). Hiring needsrecruitment.candidate.contactas well, because it reveals the person's contact details (403 forbidden,candidate_contact_required). - Hired and rejected applications: a
HIREDapplication is final. Moving it, rejecting it or hiring it again returns409 conflictwithdetails.reasonalready_hired, and nothing changes. AREJECTEDapplication can't be hired:/hirereturns409 conflict(already_rejected). Move it back to an active stage with/stagefirst if you want to reconsider the candidate. Rejecting an already rejected application still changes nothing. The same rules apply in the app.
Webhook events
| Event | When | data.object |
|---|---|---|
application.created | A candidate applies on the careers page, or is added through the API | The application |
application.stage_changed | The stage changes (app, API or automatic screening) | The application |
application.rejected | The application is rejected (sent with application.stage_changed) | The application |
application.updated | The AI CV screening finishes or is re-run (changedFields: ["screening"]) | The application |
application.hired | The candidate is hired (app or API) | The application |
A hire also sends employee.created for the person (same id). Payloads follow the API's masking rule: an endpoint whose integration lacks recruitment.candidate.contact receives a masked candidate.email and candidate.phone.
Errors
| Status and code | Meaning |
|---|---|
403 forbidden | The key lacks a permission in the table above, or candidate_contact_required on hire. |
404 not_found | No application with that id in your workspace. |
409 conflict | already_applied, opening_not_open, opening_internal_only, not_yet_external, position_full, not_a_candidate, concurrent_hire, already_hired or already_rejected. |
422 unresolved_reference | The opening, candidate or manager reference didn't match. |
422 validation_failed | The body is invalid, or saudi_only / nationality_required / archived. |
Example
curl -X POST https://jadiroon.com/api/v1/applications \
-H "Authorization: Bearer $JADIROON_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: jobboard-APP-5531" \
-d '{ "openingId": "cmvm6inad0002kncr1pytpf2x", "candidate": { "externalSystem": "jobboard", "externalId": "C-881" } }'
The response is 201 Created. For a key without recruitment.candidate.contact it reads:
{
"id": "cmvm6j7au000jkncrm8jc4e3a",
"openingId": "cmvm6inad0002kncr1pytpf2x",
"positionId": "cmvikf8270086knoq85hjhu3u",
"personId": "cmvm6it2n0008kncr3n9kpkkx",
"candidate": { "id": "cmvm6it2n0008kncr3n9kpkkx", "firstName": "Faisal", "lastName": "Alqahtani", "email": "f•••@example.com", "phone": "05•• ••• •12" },
"stage": "APPLIED",
"screening": { "status": "DONE", "score": null },
"rejectionReason": null,
"hasOffer": false,
"appliedAt": "2026-10-24T09:11:32.023Z",
"externalRefs": [],
"createdAt": "2026-10-24T09:11:32.023Z",
"updatedAt": "2026-10-24T09:11:32.023Z"
}