REST API
Reference for the vakanssi/v1 REST API: authentication, error conventions, and every route with its capability, parameters, side effects, and copy-paste examples.
On this page(47)
Vakanssi exposes a versioned REST API under the vakanssi/v1 namespace. Every route lives at https://example.com/wp-json/vakanssi/v1/.... This is the same API the plugin’s own admin screens consume (the Applications board and candidate drawer are JavaScript islands talking to these routes), so anything the UI can do, your code can do too.
The free plugin has twenty-four endpoints. The Pro build adds more through its modules: assignment (Collaboration), interview proposals (Scheduling), evaluations (Scorecards), and the application tags and candidate profile routes (Sourcing). Those Pro routes do not exist in the free build at all. Every endpoint is admin-facing and capability-gated: there are no public, unauthenticated routes here.
(The candidate-facing surfaces, the careers page and the intake form, are not part of this API. See Related non-REST endpoints for the handful of nonce-protected admin-post URLs that sit alongside it.)
Authentication
Every route has a permission callback that checks a Vakanssi capability, so every request must be made as a logged-in WordPress user who holds that capability. This is standard WordPress REST authentication: no tokens or keys of Vakanssi’s own. Administrators hold all Vakanssi capabilities; the custom roles (vakanssi_recruiter, vakanssi_hiring_manager, vakanssi_hr) hold subsets. See Roles and permissions.
Cookie plus nonce (in-admin JavaScript)
This is what Vakanssi’s own admin screens use. Each admin island is localized with wp_create_nonce( 'wp_rest' ), and the nonce travels in the X-WP-Nonce header alongside the logged-in cookie. If your code runs inside wp-admin, the easiest client is wp-api-fetch, which attaches the nonce for you:
wp.apiFetch( { path: '/vakanssi/v1/stages' } ).then( ( stages ) => {
console.log( stages );
} );
With plain fetch, pass the nonce yourself:
fetch( '/wp-json/vakanssi/v1/applications?per_page=10', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': myLocalizedData.restNonce },
} ).then( ( r ) => r.json() );
Application Passwords (external clients)
The permission callbacks are plain capability checks with no nonce logic, so any authentication method WordPress core supports works, including Application Passwords. Create one under Users, Profile, Application Passwords for a user who holds the right capabilities, then send it as HTTP Basic auth over HTTPS:
curl 'https://example.com/wp-json/vakanssi/v1/health' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
The curl examples on this page all use this form. Cookie plus nonce works from curl too if you copy a logged-in session cookie:
curl 'https://example.com/wp-json/vakanssi/v1/health' \
-H 'X-WP-Nonce: 9f3b2a1c8d' \
--cookie 'wordpress_logged_in_abc123=admin%7C...'
Two authorization layers
Behind every route are one or two checks, built from two shared helpers on the base controller:
- Capability only (
require_cap( $cap )). The route requires one capability and nothing more. A failed check returnsvakanssi_forbidden, HTTP 401 for an anonymous request or 403 for a logged-in user without the capability. - Capability plus object-level authorization (
require_object_cap( $action )). The request also runs through the plugin’sAuthorizationPolicyfor the application in theidparameter, filterable viavakanssi/authz/can(see Hooks). Out of the box this adds nothing; the assignment visibility setting Pro feature, requires a Vakanssi Pro license uses it to deny applications assigned to someone else. The list route applies the matching collection filter, so scoped users only see their own applications.
WordPress validates a route’s declared parameters before it runs the permission callback, so a request that is missing a required parameter or sends one outside its schema gets rest_missing_callback_param or rest_invalid_param (400) even when it is anonymous or lacks the capability. Only a request whose parameters pass reaches the capability check and its 401/403.
Each route below states which helper guards it. The free GET .../interviews and GET .../notes sub-resources, and the Pro GET .../evaluations sub-resource, are all object-authorized.
Rate limiting
None, on any REST route. (The only rate limit in the plugin is on the non-REST CV download endpoint, 30 downloads per user per minute, filterable via vakanssi/intake/download_rate_limit. See Related non-REST endpoints.)
Conventions
- Base URL:
https://example.com/wp-json/vakanssi/v1. All requests and responses are JSON; sendContent-Type: application/jsonon writes. - Error shape: every error is a standard WordPress
WP_ErrorREST response:
{
"code": "vakanssi_forbidden",
"message": "You do not have permission to perform this action.",
"data": { "status": 403 }
}
- Error codes used across the API:
| Code | HTTP status | Returned by |
|---|---|---|
vakanssi_forbidden | 401 (anonymous) / 403 (no capability or no object access) | every route |
vakanssi_not_found | 404, but 400 from move and assign (see below) | missing application or interview |
vakanssi_bad_stage | 400 | move with an unknown stage_id |
vakanssi_invalid_email | 400 | details with a malformed email (see the route for the edge cases) |
vakanssi_email_conflict | 409 | details with an email that already belongs to a different candidate |
vakanssi_update_failed | 500 | details or rating when the database write fails (the transaction is rolled back) |
vakanssi_invalid_url | 400 | details (or the Pro candidate profile PUT) with a linkedin_url that is not an http:// or https:// URL |
vakanssi_invalid_country | 400 | details (or the Pro candidate profile PUT) with a country that is not an ISO 3166-1 alpha-2 code |
vakanssi_no_recipient | 400 | emails/{email_id}/resend when the resolved recipient (the candidate’s current address for a candidate-facing row, the logged address for a staff-facing one) has no valid email; emails when the candidate has no email on file |
vakanssi_ambiguous_recipient | 409 | emails/{email_id}/resend on an interview invitation or cancellation row, logged once per attendee so it cannot say who to resend to; use interviews/{id}/resend instead |
vakanssi_not_resendable | 409 | emails/{email_id}/resend on a logged email whose content nothing can rebuild (for example a one-off custom message) |
vakanssi_email_disabled | 409 | emails and emails/{email_id}/resend while the Email & Notifications module is switched off under Hiring, Tools, Features |
vakanssi_send_failed | 500 | emails/{email_id}/resend and emails when wp_mail fails |
vakanssi_empty_message | 400 | emails with a blank subject or body |
vakanssi_interview_no_time | 400 | scheduling a fixed-time interview with no usable start time (see the route) |
vakanssi_invalid | 400 | proposing slots when none are usable |
vakanssi_invalid_time | 400 | reschedule with a missing or invalid time |
vakanssi_invalid_interviewer | 400 | scheduling, update, or the Pro propose route with an interviewers id that is not on the hiring roster (Administrator, Recruiter, Hiring Manager, or HR); the error message names the rejected numeric ids |
vakanssi_invalid_interview | 400 | the Pro evaluations POST with an interview_id that cannot be scored on this application |
vakanssi_bad_state | 409 | interviews/{id}/resend on an interview that is not scheduled |
vakanssi_conflict | 409 | a stale optimistic-concurrency precondition on move, details, rating, a note edit, assign, or an interview cancel / complete / reschedule / update; status or move when concurrent writes exhaust its retry; complete, reschedule, or update on an interview no longer in an editable state; interview schedule when the per-application scheduling lock cannot be acquired; emails/{email_id}/resend while the same email is already being resent (see Optimistic-concurrency conflicts below) |
vakanssi_delete_failed | 404 | DELETE /applications/{id} when the application no longer exists |
vakanssi_delete_incomplete | 409 | DELETE /applications/{id} when a stored file’s bytes could not be removed; the application is kept intact so the delete can be retried |
vakanssi_empty_note | 400 | posting or editing a note with an empty body |
vakanssi_bad_assignee | 400 | assigning a user who is not on the hiring team |
vakanssi_no_candidate | 400 | applications/{id}/tags Pro feature, requires a Vakanssi Pro license when the application has no linked candidate |
vakanssi_empty_tag | 400 | applications/{id}/tags Pro feature, requires a Vakanssi Pro license POST with a blank tag |
vakanssi_tag_limit | 400 | applications/{id}/tags Pro feature, requires a Vakanssi Pro license POST once the candidate already has 30 tags |
vakanssi_db | 400 / 500 | a write that failed at the database layer: 400 from posting a note or scheduling or proposing an interview; 500 from editing a note, saving a scorecard Pro feature, requires a Vakanssi Pro license, or adding or removing a tag Pro feature, requires a Vakanssi Pro license |
rest_missing_callback_param | 400 | a required parameter (e.g. body on a note) is absent |
rest_invalid_param | 400 | a parameter fails the route’s declared schema, e.g. a status action other than withdraw or reopen |
rest_no_route | 404 | any route whose module is not running: a Pro route on the free build or with its module off, or a free route whose module is switched off |
Optimistic-concurrency conflicts (409)
Several write routes accept an optional precondition parameter:
| Route | Precondition |
|---|---|
move | expected_from_stage_id |
details | expected_details_version |
rating | expected_rating |
note edit (PATCH .../notes/{note_id}) | expected_body |
assign Pro feature, requires a Vakanssi Pro license | expected_assigned_to |
interview cancel / complete / reschedule / update | expected_status |
candidate profile PUT Pro feature, requires a Vakanssi Pro license | expected_details_version |
Omit it and the route behaves exactly as it always has, last write wins, safely: the write is still an atomic compare-and-swap under the hood, so two racing requests can never corrupt the row or double-fire the side effects. Neither request is rejected for a stale read, though the routes listed below can still report a conflict for other reasons.
Pass it, and a live mismatch (someone else changed the row since you last read it) is rejected before anything is written, with a vakanssi_conflict error carrying this shape:
{
"code": "vakanssi_conflict",
"message": "Someone else moved this application. It is shown in its current stage.",
"data": {
"status": 409,
"current": { "application": { "...": "the full single-application payload, as GET /applications/{id} returns it" } },
"changed_by": { "id": 5, "name": "Jane Recruiter" },
"changed_at": "2026-07-16 09:41:03"
}
}
current is the fresh state a caller would need to re-render without a second request, null if it could not be assembled. It is the single-application payload for move, details, rating, and status conflicts, and the collaboration block (the assign success response) for an assign conflict.
A note-edit conflict carries the refreshed { "notes": [...] } thread, and an interview-route conflict carries { "interviews": [...] }, the parent application’s refreshed interview list.
changed_by and changed_at are a best-effort attribution read from the newest audit-log entry for the affected application, note, or interview; changed_by is null when no attributable entry exists. This is a frozen response shape shared by every optimistic-concurrency conflict on the API, so a client can parse it identically regardless of which route produced it.
Some routes also report vakanssi_conflict for a reason other than a stale precondition, even when the caller passes none:
movere-reads the live row and retries when its compare-and-swap loses a race, up to 3 attempts in all. If every attempt loses, it reportsvakanssi_conflict, whether or notexpected_from_stage_idwas passed.assignretries once against the live row on a benign same-target collision (last-write-wins path), but reportsvakanssi_conflictif that retry is also lost, or immediately ifexpected_assigned_towas passed and the live row no longer carries it. Assigning to the assignee the application is already at is always a no-op success (no audit row, no email, no hook), never a conflict, whether or not a precondition was passed.status(withdraw/reopen) takes no precondition parameter at all. It always re-reads the application’s stage and status, computes the new value, and attempts a compare-and-swap keyed on what it just read, retrying up to 3 times before giving up. A live mismatch, most commonly areopenracing amoveto Hired or Rejected on the same application, exhausts the retry and reportsvakanssi_conflict.completeandreschedulereportvakanssi_conflictwhenever the interview is no longerscheduledat write time, since each is a compare-and-swap keyed on that status.updatedoes the same for an interview that is no longer in an editable state:scheduled, or alsoproposedon the Pro build Pro feature, requires a Vakanssi Pro license.cancelstays a silent idempotent no-op (200, not a conflict) only when the live row is alreadycanceled. Whenexpected_statusis passed and the row has moved to any other status since the caller’s last read, most notablycompleted, the mismatch is avakanssi_conflict(409) instead.- Since v0.29.8 that precondition is enforced inside the same compare-and-swap UPDATE, not checked against a stale read beforehand.
- Previously a cancel and a complete landing at the same instant could both read
scheduledand both proceed. The cancel’s predicate only excluded an already-canceledrow, so it overwrote the completion and emailed a cancellation for an interview a colleague had just marked done. - Passing no
expected_statusat all still carries this older, looser behavior; the fix only closes the race for callers that supply the precondition.
Wired into the shipped admin screens
The Applications board and drawer send expected_from_stage_id on every move and expected_details_version on every Edit details save. The Notes tab sends expected_body on every edit, and the interview panel sends expected_status on every cancel, complete, reschedule, and edit. With Pro Collaboration, every assignment change sends expected_assigned_to.
So those routes’ conflict handling is live for ordinary recruiter use, not just an opt-in for API integrations. The rating route’s expected_rating is the one precondition the shipped UI does not send.
The underlying compare-and-swap write is always active regardless of whether a precondition is sent (see the route sections below), which is what stops a race from corrupting data even without one. status is a further exception: its retry-and-report is unconditional (no precondition parameter exists to omit), so a Mark withdrawn / Reopen click in the drawer can surface this 409 whenever it genuinely races another change to the same application.
Some write routes return 400 where you would expect 404
The POST /applications/{id}/move and POST /applications/{id}/assign handlers stamp HTTP status 400 onto every error coming back from the service layer, including vakanssi_not_found for an application id that does not exist.
Do not use the HTTP status alone to distinguish “missing” from “invalid” on these two writes; branch on the error code instead. (The details route is the opposite: it preserves a real status per code, so a missing application there is a true 404.)
Pro routes 404 when the module is not running
The assign, interviews/propose, evaluations, tags, and candidate profile routes are registered by the Pro Collaboration, Scheduling, Scorecards, and Sourcing modules. The free build contains none of that code, so on a free site these routes do not exist.
On the Pro build, a Pro module boots only while the license is valid and the module is enabled under Hiring, Tools, Features (see Features). The same switch applies to the free Applications, Notes, and Interviews modules, which can also be turned off there and take their routes with them.
Whenever a module is not running, its boot() never runs, its routes are never registered, and requests return WordPress’s generic rest_no_route error with HTTP 404, not a Vakanssi error code. See Free vs Pro for the module split.
- Pagination: only
GET /applicationspaginates. It returns a bare JSON array plus the standardX-WP-TotalandX-WP-TotalPagesresponse headers. - Dates and times: request parameters that take a time (
start,slots,when) are Unix timestamps (integers, UTC epoch seconds). Each also has a_localtwin (start_local,slots_local,when_local) that takes a site-local wall-clock string such as2026-12-15T10:00, resolved against the site timezone on that date; when both are sent, the_localone wins. - Response times: fields like
created_atare MySQL-style datetime strings, and human-readable fields (when,slots, timelinedate) are pre-formatted strings in the site timezone. - Extending: third-party controllers can register under the same namespace via the
vakanssi/rest/register_routesaction. The single-application payload is filterable viavakanssi/rest/application_payload, and each page of the list route viavakanssi/rest/applications_payload. See Hooks and Extending Vakanssi.
Diagnostics
GET /vakanssi/v1/health
Diagnostic snapshot of the install: plugin and schema versions, database table status, active module ids, and candidate-file storage checks (plus the license status on the Pro build). Read-only, no side effects. Registered on every install.
Guard: require_cap with vakanssi_manage_settings.
Parameters: none.
curl 'https://example.com/wp-json/vakanssi/v1/health' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
{
"ok": true,
"plugin_version": "1.0.0",
"schema_version": "18",
"schema_target": "18",
"tables_ok": true,
"unique_indexes_ok": true,
"missing_unique_indexes": [],
"active_modules": ["core", "jobs", "intake", "pipeline", "notes", "interviews", "email", "privacy", "import", "csv_import"],
"storage": {
"guards_written": true,
"http_verified": true,
"http_state": "protected",
"outside_web_root": false
}
}
ok reports whether every Vakanssi database table exists and the UNIQUE indexes that prevent duplicate rows under concurrent writes are in place; tables_ok and unique_indexes_ok report those two conditions individually, and missing_unique_indexes names any index (for example application_meta) that is currently missing.
active_modules lists the ids of the modules currently booted, in registration order; the example is a free install with every module on. Pro ids like collaboration, scheduling, or scorecards appear only on the Pro build, with a valid license and those modules enabled. The storage keys report whether the protected upload directory’s guard files are in place and whether files live outside the web root.
On the Pro build only, the response also carries license_status (between active_modules and storage): paying, trial, or free, or not_applicable on a premium tree whose licensing layer is missing. The free build omits the key entirely.
The remaining two storage keys both describe the direct-HTTP-access check, at different resolutions. http_verified is a strict boolean, true only when that check positively confirmed access is blocked. http_state carries the same check’s full outcome as one of "protected", "exposed" (the check ran and the directory answered a direct request), or "unverified" (the check could not complete, so nothing was confirmed either way).
Errors: 401/403 vakanssi_forbidden.
Applications
Free. The application and stage routes are registered by the Applications (pipeline) module, which is free and enabled by default.
GET /vakanssi/v1/applications
Paginated list of applications, newest first (created_at DESC, id DESC). Read-only. If Collaboration’s assignment visibility Pro feature, requires a Vakanssi Pro license is on, the list is automatically scoped to applications the requesting user may see.
Guard: require_cap with vakanssi_view_applications.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
job | int | no | (none) | Only applications for this job (post id). |
stage | int | no | (none) | Only applications in this stage (stage id, see GET /stages). |
search | string | no | (none) | Substring match against the candidate’s first name, last name, email, or full name (matched in either order, e.g. “Dana Levi” or “Levi Dana”). Since v0.33.23. |
page | int | no | 1 | Page number; values below 1 are clamped to 1. |
per_page | int | no | 25 | Items per page; clamped to 1 through 100. |
curl 'https://example.com/wp-json/vakanssi/v1/applications?job=7&search=dana&per_page=10' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response, a bare array, with totals in the X-WP-Total and X-WP-TotalPages headers:
[
{
"id": 42,
"job_id": 7,
"job_title": "Senior PHP Engineer",
"candidate_id": 19,
"candidate_name": "Dana Levi",
"email": "dana@example.com",
"stage_id": 3,
"status": "active",
"rating": 0,
"source": "careers_page",
"source_label": "Careers page",
"created_at": "2026-06-28 14:03:22",
"updated_at": "2026-06-28 14:03:22",
"monogram": { "initials": "DL", "bg": "#E4F1E2", "fg": "#2C6A2E" },
"has_cv": true,
"is_new": false,
"related_count": 1
}
]
status is derived from the stage’s type (active for open stages, hired, rejected), except for withdrawn, which is set through the status route and survives a move to an open stage. monogram is the avatar the board draws: the initials plus a background and text color.
source stays the stored value, the one other consumers (the CSV export, the analytics source table) match on. source_label is the people-facing name shown in the drawer and on the printed sheet, the same name for every source the plugin itself writes (for example native and careers_page both label as “Careers page”); free text typed into a candidate’s source is shown as typed.
has_cv, is_new (never opened by the team), and related_count (the candidate’s other applications) are present only on this list route; the single-application route omits them because its underlying row is not joined.
Each page passes through the vakanssi/rest/applications_payload filter before it is returned. With Screening Pro feature, requires a Vakanssi Pro license active, that adds a screening_knockout boolean to every item, true when the applicant failed a knockout question.
Errors: 401/403 vakanssi_forbidden.
GET /vakanssi/v1/applications/{id}
Full detail for one application: the application row, the candidate, uploaded files, custom form meta, remote-role location eligibility, and a merged activity plus email timeline (newest first, capped at 30 entries).
Guard: require_object_cap with vakanssi_view_applications (assignment visibility Pro feature, requires a Vakanssi Pro license applies here).
A GET with a deliberate side effect
Opening the drawer is the product definition of “viewing”, so this GET records the team-wide first view (it clears the is_new flag). The write runs only after the permission callback has authorized this user for this object. It is idempotent after the first view.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. Digits only. |
curl 'https://example.com/wp-json/vakanssi/v1/applications/42' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
{
"application": {
"id": 42, "job_id": 7, "job_title": "Senior PHP Engineer",
"candidate_id": 19, "candidate_name": "Dana Levi", "email": "dana@example.com",
"stage_id": 3, "status": "active", "rating": 0, "source": "careers_page",
"source_label": "Careers page",
"created_at": "2026-06-28 14:03:22", "updated_at": "2026-06-28 14:03:22",
"monogram": { "initials": "DL", "bg": "#E4F1E2", "fg": "#2C6A2E" },
"job_url": "https://example.com/wp-admin/post.php?post=7&action=edit"
},
"candidate": {
"id": "19", "first_name": "Dana", "last_name": "Levi",
"email": "dana@example.com", "phone": "+972501234567", "location": "Tel Aviv",
"updated_at": "2026-06-28 14:03:22"
},
"files": [
{
"id": 12, "filename": "dana-levi-cv-v2.pdf", "mime": "application/pdf",
"download_url": "https://example.com/wp-admin/admin-post.php?action=vakanssi_download_cv&file=12&_wpnonce=fed456cba9",
"uploaded_at": "June 29, 2026 · 9:12 am", "is_latest": true
},
{
"id": 11, "filename": "dana-levi-cv.pdf", "mime": "application/pdf",
"download_url": "https://example.com/wp-admin/admin-post.php?action=vakanssi_download_cv&file=11&_wpnonce=abc123def4",
"uploaded_at": "June 28, 2026 · 2:03 pm", "is_latest": false
}
],
"meta": { "first_viewed_at": "2026-06-28 14:05:10", "linkedin_url": "https://linkedin.com/in/danalevi", "country": "IL" },
"location_eligibility": { "status": "na" },
"timeline": [
{ "label": "Moved to Interview", "actor": "Site Admin", "date": "June 28, 2026 · 14:05", "ts": 1782655522 },
{ "label": "Confirmation email sent to dana@example.com", "actor": "", "date": "June 28, 2026 · 14:03", "ts": 1782655402, "kind": "email", "id": 17, "subject": "Thanks for applying, Dana!", "status": "sent", "resendable": true, "resend_to": "dana@example.com" },
{ "label": "Application received", "actor": "", "date": "June 28, 2026 · 14:03", "ts": 1782655402 }
],
"related": [],
"related_summary": { "count": 0, "total": 1, "jobs_count": 1, "active_count": 0, "has_reapplied": false, "first_applied": "2026-06-28 14:03:22" },
"print_url": "https://example.com/wp-admin/admin-post.php?action=vakanssi_application_print&id=42&autoprint=1&_wpnonce=9a8b7c6d5e",
"details_version": "3.5",
"details_updated_at": "2026-06-28 14:03:22"
}
Notes on the payload:
application.candidate_nameandapplication.emailare merged in from the candidate record at response time (the application row does not store them), so they always reflect the candidate’s current details and match the shape of the list route’s items.application.job_urllinks the job title in the drawer through to the job: the post editor when the current user can edit it, otherwise its public permalink. The key is omitted when the job no longer resolves to either.details_versionis the opaque token to echo back as thedetailsroute’sexpected_details_versionprecondition (see Optimistic-concurrency conflicts). It changes on every details edit to the application or its candidate. It is an empty string when the application has no linked candidate.application.updated_atandcandidate.updated_atare the underlying rows’ last-write timestamps, anddetails_updated_atis the greater of the two. It is for display and attribution only, not a precondition: a timestamp cannot tell two edits in the same second apart.candidateisnullwhen the application has no linked candidate. Its values come straight from the database row, soidis a string. Acandidate.profile_urlkey is added only when the Pro Sourcing module Pro feature, requires a Vakanssi Pro license is active and the user holdsvakanssi_manage_talent_pool; it deep-links to the person’s Candidates profile.metais the application’s custom-field meta (form answers such as the cover letter, plus internal keys likefirst_viewed_at).linkedin_urlandcountryare overlaid from the candidate’s own record, which is where thedetailsroute stores them.files[].download_urlpoints to the non-REST, nonce-protected CV download endpoint (see Related non-REST endpoints); you cannot fetch the file through the REST API itself.filesis newest first. An application can hold more than one CV when a candidate reapplies to the same job with a corrected file (seevakanssi/intake/max_files_per_applicationin Hooks);files[0].is_latestis alwaystrueand every other entryfalse.files[].uploaded_atis that file’screated_atas a human-readable site-timezone string, in the same format as a timeline entry’sdate(not the MySQL-stylecreated_atof the application row), or an empty string when the file has no recorded upload time.location_eligibility.statusisnafor non-remote jobs,unknownwhen the applicant declared no country, oreligible/ineligiblefor a remote job (withcountryandcountry_nameadded in those two cases).relatedandrelated_summarydescribe the candidate’s other applications, the free “candidate history” bridge (object-scoped inside the service, newest first, at most 25). Inrelated_summary,countandactive_countcover the other applications only, whiletotal,jobs_count, andfirst_appliedcover all of them. Both keys are absent when the application has no linked candidate.timelineentries for a logged email carry six extra keys beyondlabel/actor/date/ts:kind(always"email"),id(the logged email row id, theemail_idresendneeds),subject, andstatus(sent,failed, orsending).- The other two email keys drive the Resend link:
resendablesays whether a resend of that row can work, andresend_tois the address a resend would actually go to (empty when none resolves). Stage-change and other activity entries carry none of the six. print_urlis the nonce-protected link to the application’s printable sheet, also a non-REST endpoint. It carriesautoprint=1, so opening it pops the browser’s print dialog on load.- Module additions: the drawer payload is assembled through the
vakanssi/rest/application_payloadfilter. The free Notes and Interviews modules addcollaboration(the object fromGET .../notes) andinterviews(the array fromGET .../interviews). - On the Pro build, Scorecards adds
evaluations(the object fromGET .../evaluations), Sourcing addstags(the candidate’s alphabetical tag list, for users withvakanssi_manage_talent_pool), and Screening addsscreening(answersplus aknockoutflag) when the applicant answered screening questions. Collaboration and Scheduling layer their own keys onto thecollaborationandinterviewsblocks.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found.
POST /vakanssi/v1/applications/{id}/move
Moves an application to another stage, the API equivalent of dragging a card on the Applications board.
Guard: require_object_cap with vakanssi_move_application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
stage_id | int | yes | (none) | Target stage id (see GET /stages). |
notify | boolean | no | (none) | Omit it to leave the stage’s automatic-sending setting in charge. true forces the candidate email even if that setting is off (the board/drawer’s “Send email & move”). false suppresses it even if that setting is on (“Move without emailing”). |
expected_from_stage_id | int | no | (none) | Optimistic-concurrency precondition: the stage id you last saw this application in. Omit it for the legacy last-write-wins behavior; pass it and a live mismatch is rejected as a vakanssi_conflict (409) instead of moving the application out from under the stage you expected. See Optimistic-concurrency conflicts. |
Side effects:
- Updates the application’s
stage_idand re-derives itsstatus(active,hired, orrejected) from the target stage’s type. Since v0.15.0, awithdrawnapplication moved to an open stage stayswithdrawn; a won or lost stage still setshiredorrejected. Use thestatusroute’sreopento reactivate someone.- The write is an atomic compare-and-swap keyed on the row still being at the stage this request last read, so two requests racing to move the same application cannot both silently apply, regardless of whether either passes
expected_from_stage_id. - A request that loses the race re-reads the live row and retries, up to 3 attempts in all. If every attempt loses, it reports
vakanssi_conflict(409) even without a precondition.
- The write is an atomic compare-and-swap keyed on the row still being at the stage this request last read, so two requests racing to move the same application cannot both silently apply, regardless of whether either passes
- Writes a
stage_changedaudit entry (visible in the drawer timeline), and firesvakanssi/application/stage_changed, only for the one request that actually changes the row; a request that finds the application already at the target stage and status (a no-op, or a race it loses to an identical concurrent move) returns success without writing or firing anything twice. - Fires the
vakanssi/application/stage_changedaction (see Hooks). - Sends email: the Email module listens on that action and, for moves into
rejectedorhired, sends the matching template followingnotify: omitted follows the template’s automatic-sending setting,trueforces the send via thevakanssi/email/force_stage_notificationfilter even when that setting is off, andfalsesuppresses it via thevakanssi/email/stage_notificationfilter even when that setting is on. Moves between open stages send nothing.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/move' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"stage_id": 6, "notify": false}'
Response 200: the full single-application payload, identical in shape to GET /applications/{id} (including any module additions), reflecting the new stage.
Errors: 401/403 vakanssi_forbidden; 400 vakanssi_not_found (“Application not found.”, note 400, not 404); 400 vakanssi_bad_stage (“Unknown pipeline stage.”); 409 vakanssi_conflict when expected_from_stage_id was passed and no longer matches the live row, or when 3 compare-and-swap attempts all lose to concurrent writes, see Optimistic-concurrency conflicts.
POST /vakanssi/v1/applications/{id}/details
Edits an application’s contact details and source, the API behind the drawer’s Edit details form. Name, email, phone, linkedin_url, and country live on the shared candidate record, so an edit here updates that person across all their applications. Only source belongs to this one application. The cover letter is deliberately not editable.
Guard: require_object_cap with vakanssi_edit_application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. Digits only. |
first_name | string | no | (none) | Candidate first name. |
last_name | string | no | (none) | Candidate last name. |
email | string | no | (none) | Candidate email. Validated server-side; must not collide with a different candidate (see errors). |
phone | string | no | (none) | Candidate phone. |
source | string | no | (none) | Application source, e.g. referral. |
linkedin_url | string | no | (none) | The candidate’s LinkedIn profile URL. Must be http:// or https:// (see errors); posting "" clears it. |
country | string | no | (none) | The candidate’s declared country as an ISO 3166-1 alpha-2 code, e.g. US. Case-insensitive on input, normalized to uppercase; posting "" clears it. |
expected_details_version | string | no | (none) | Optimistic-concurrency precondition: the details_version value you last read from GET /applications/{id}, echoed back untouched. Omit it (or send "") for the legacy last-write-wins behavior; pass it and a stale or malformed value is rejected as a vakanssi_conflict (409) instead of overwriting a concurrent edit. See Optimistic-concurrency conflicts. |
All body parameters are optional, and only the ones actually present in the request are applied; omitted fields are left untouched.
At the route level email passes through sanitize_text_field, not sanitize_email, so the raw string reaches the service intact; the service then runs sanitize_email and rejects any address that sanitization would have altered (a mangled unicode address such as daña@example.com silently becoming daa@example.com would otherwise be saved as a value the caller never entered).
Two email edge cases: posting email: "" for a candidate who already has no email (for example one added by hand on the Candidates screen) is treated as a no-op, while blanking an existing email is rejected as invalid.
A new email is compared by its normalized identity; if it already belongs to a different candidate, the request is rejected with 409, never silently merged. Merging stays an explicit action on the Candidates screen.
Side effects:
- Updates the candidate record (name, email, and phone on the row, LinkedIn URL and country in its meta) and/or the application row (source) inside one transaction; a failed write rolls back and returns 500.
- Writes one
details_updatedaudit entry recording the changed field keys only (for example{"fields": ["email", "phone"]}), never the values, preserving the audit log’s PII-free guarantee. A save where nothing actually changed returns success without writing an audit entry. - Fires the
vakanssi/application/updatedaction with the same list of changed field keys (not on a no-op save). No emails are sent.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/details' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"email": "dana.levi@example.com", "phone": "+972501234567"}'
Response 200: the full single-application payload, identical in shape to GET /applications/{id}, reflecting the new details.
Errors (unlike move and assign, this route returns a real HTTP status per code):
| Code | HTTP status | When |
|---|---|---|
vakanssi_forbidden | 401 / 403 | No vakanssi_edit_application capability, or object-level access denied. |
vakanssi_not_found | 404 | The application does not exist, has no linked candidate, or its candidate record is gone. A true 404. |
vakanssi_invalid_email | 400 | ”Please enter a valid email address.” The email is malformed, blanks an existing address, or would be altered by sanitization. |
vakanssi_email_conflict | 409 | ”Another candidate already uses this email.” With ” You can merge them in Candidates.” appended when the Pro Sourcing module is active. |
vakanssi_invalid_url | 400 | ”Enter a valid http(s) LinkedIn URL.” The linkedin_url value is not an http:// or https:// URL. |
vakanssi_invalid_country | 400 | ”Choose a valid country.” The country value is not a recognized ISO 3166-1 alpha-2 code. |
vakanssi_conflict | 409 | ”These details were changed by someone else while you were editing. Review the current values and try again.” expected_details_version was passed and no longer matches, see Optimistic-concurrency conflicts. |
vakanssi_update_failed | 500 | ”Could not save the changes.” The database write failed and was rolled back. |
POST /vakanssi/v1/applications/{id}/rating
Sets the star rating on an application, the API behind the clickable Team rating stars in the candidate drawer. The rating shows on board cards, in the sortable List view column, and in the CSV export, for every tier; this route is its only writer.
Guard: require_object_cap with vakanssi_evaluate_application. Rating a candidate counts as an evaluation, like a scorecard. Since v0.37.0, that means the Hiring Manager role (which can evaluate but not edit) can set it too.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
rating | int | yes | (none) | Desired rating. Clamped to 0 through 4; 0 clears it back to unrated. |
expected_rating | int | no | (none) | Optimistic-concurrency precondition: the rating you last read (0 means “expect unrated”). Omit it for the legacy last-write-wins behavior; pass it and a live mismatch is rejected as a vakanssi_conflict (409). Re-sending the same value as the expected one is a no-op success. |
Side effects: updates the application’s rating column; writes a rating_set audit entry recording the new value; fires the vakanssi/application/updated action with ["rating"].
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/rating' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"rating": 3}'
Response 200:
{ "rating": 3 }
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found; 409 vakanssi_conflict (“Someone else rated this application. It is shown with the current rating.”) when expected_rating no longer matches; 500 vakanssi_update_failed (“Could not save the rating.”).
POST /vakanssi/v1/applications/{id}/status
Withdraws an application, or reopens a withdrawn one, the API behind the drawer’s Mark withdrawn / Reopen link. withdrawn is a manual status overlay for a candidate who pulls out or declines an offer, distinct from the stage-derived active, hired, and rejected values documented on GET /stages, so a withdrawal is never miscounted as a rejection.
Guard: require_object_cap with vakanssi_edit_application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
action | string | yes | (none) | One of withdraw or reopen (an enum in the route schema, so anything else is rejected before the handler runs). |
Side effects:
withdrawsetsstatustowithdrawnoutright.reopenre-derivesstatusfrom the application’s current stage type (anopenstage yieldsactive, awonstagehired, aloststagerejected), the same rulemoveuses, so the application lands back in a coherent state.- Writes a
status_setaudit entry recording the new status and fires thevakanssi/application/updatedaction with["status"]. A request that finds the application already in the target status returns it without writing either.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/status' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"action": "withdraw"}'
Response 200:
{ "status": "withdrawn" }
The write is a compare-and-swap keyed on the stage and status this request read the application in (reopen derives its new value from that stage, so a stale read would otherwise reopen into a stage the row had already left).
There is no expected_* precondition parameter to pass, unlike move, details, and rating: the route re-reads and retries on its own, up to 3 attempts, and only reports a conflict once every attempt loses. See Optimistic-concurrency conflicts.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found; 400 rest_invalid_param (an action other than withdraw or reopen); 409 vakanssi_conflict (“Someone else changed this application while you were working on it. It is shown in its current state.”) when the retry is exhausted, carrying the same current / changed_by / changed_at shape as the other conflict routes.
DELETE /vakanssi/v1/applications/{id}
Permanently deletes the application, the API behind the drawer’s Delete button and the Applications list’s “Delete permanently” bulk action; both route through the same hard-delete service.
Deletes the whole cascade: CV files (bytes removed first, strictly before any database row; when there is more than one file, they are removed all-or-nothing), notes, interviews, scorecard evaluations, email history, custom-field meta, and stage history, then writes a final audit row and fires vakanssi/application/deleted.
If a stored file’s bytes cannot be removed, the whole delete aborts with every row intact rather than removing rows the bytes can no longer be found for; see the vakanssi_delete_incomplete error below. There is no undo; jobs get a soft archive, applications do not.
Guard: require_object_cap with vakanssi_delete_application (Administrator and HR / Compliance in the shipped role map, not Recruiter or Hiring Manager).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. Digits only. |
curl -X DELETE 'https://example.com/wp-json/vakanssi/v1/applications/42' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response 200:
{ "deleted": true, "id": 42 }
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_delete_failed (“The application could not be deleted; it may have already been removed.”); 409 vakanssi_delete_incomplete when the application still exists but a stored file’s bytes could not be removed (“The application could not be fully deleted (a stored file could not be removed) and was kept so you can retry after checking file permissions.”).
POST /vakanssi/v1/applications/{id}/files
Attaches a CV or document to an existing application, the API behind the drawer’s Upload CV / Add file button. A multipart upload, not JSON. Runs through the exact same validation as the public apply form (real upload check, size cap, extension allowlist, magic-byte content check), then stores the file in protected storage with a randomised filename, bound to both the application and its candidate.
Guard: require_object_cap with vakanssi_edit_application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
file | file | yes (multipart) | (none) | The uploaded file, field name file. Accepted types and the size cap are the Settings, then Application form values (PDF, DOC, DOCX by default, up to 5 MB, and never above the server’s own upload limit). |
Side effects: stores the file, writes a cv_uploaded audit entry, and fires the vakanssi/cv/stored action, the same action an applicant’s upload fires.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/files' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-F 'file=@dana-levi-cv.pdf;type=application/pdf'
Response 201, the application’s refreshed file list, in the same shape as the files key of GET /applications/{id}:
{
"files": [
{
"id": 11, "filename": "dana-levi-cv.pdf", "mime": "application/pdf",
"download_url": "https://example.com/wp-admin/admin-post.php?action=vakanssi_download_cv&file=11&_wpnonce=abc123def4",
"uploaded_at": "June 28, 2026 · 2:03 pm", "is_latest": true
}
]
}
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found (application does not exist); 500 vakanssi_file (the file was stored but its database row could not be saved, so the bytes are removed again).
400 for every other upload failure:
- The validation codes
vakanssi_no_file(nothing was uploaded),vakanssi_upload_error(PHP reported an upload error),vakanssi_file_size,vakanssi_file_type, andvakanssi_file_content(fails the type or magic-byte check), plusvakanssi_file_rejectedwhen thevakanssi/storage/validate_filefilter vetoes the file (a veto returned as its ownWP_Errorkeeps its code first, withvakanssi_file_rejectedadded behind it). - The storage failures
vakanssi_storage(the upload directory is not writable) andvakanssi_move(the file could not be moved into storage). These are server-side problems, but they still come back as 400, so branch on the code.
DELETE /vakanssi/v1/applications/{id}/files/{file_id}
Removes one attached file: deletes the stored bytes, then the file row. The file must belong to the given application, an id pair mismatch returns 404 rather than deleting another application’s file, which closes the same IDOR class of bug /details guards against.
Guard: require_object_cap with vakanssi_edit_application. Deliberately the same capability as attaching and editing details, not vakanssi_delete_application: managing an application’s own attachments is treated as an edit, not the wholesale record erasure that capability governs. The removal itself is irreversible and writes a cv_deleted audit entry.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
file_id | int | yes (path) | (none) | File id to remove (from the files[].id of GET /applications/{id}). |
curl -X DELETE 'https://example.com/wp-json/vakanssi/v1/applications/42/files/11' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response 200: the application’s refreshed file list, same shape as POST .../files above.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found (no such file, or it belongs to a different application); 500 vakanssi_file (the stored bytes could not be deleted, so the row is kept rather than orphaning the file).
POST /vakanssi/v1/applications/{id}/emails
Sends a one-off message to the candidate, the API behind the drawer’s Activity tab Send email composer. Unlike the four templates in Emails, this is not a template lookup: the subject and body are recruiter-authored on the spot.
Guard: require_object_cap with vakanssi_edit_application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
subject | string | yes | (none) | Message subject. |
body | string | yes | (none) | Message text (plain text; merge tags allowed, see below). |
Side effects:
- Resolves the candidate’s current email from the application’s candidate record; an application with no candidate email is rejected (see errors).
- Renders
subjectandbodythrough the same merge-tag pass and branded HTML shell as a templated email (personalization tags like{first_name}work), but skips both the per-template automatic-send gate and the once-only dedup guard that templated mail uses, since every ad-hoc message is deliberately unique. - Sends immediately and logs it to the email log with template key
custom(shown as Message in the Activity tab), so it appears in the timeline like any other sent email. Firesvakanssi/email/sentlike any other send.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/emails' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"subject": "Quick update", "body": "Hi {first_name}, just checking in on your application."}'
Response 200, the application’s refreshed timeline (same shape as the timeline key of GET /applications/{id}, now including the new message):
{ "timeline": [ /* ... */ ] }
Errors: 401/403 vakanssi_forbidden; 400 rest_missing_callback_param (no subject or body); 400 vakanssi_empty_message (“A subject and a message are required.”); 404 vakanssi_not_found; 400 vakanssi_no_recipient (“This candidate has no email address to message.”); 409 vakanssi_email_disabled (the Email & Notifications module is switched off under Hiring, Tools, Features); 500 vakanssi_send_failed.
POST /vakanssi/v1/applications/{id}/emails/{email_id}/resend
Resends a previously-logged email, the API behind the Resend link on an email row in the drawer’s Activity tab. Templated emails were otherwise unrecoverable once sent, the once-only dedup guard in Mailer::send hard-blocked re-sending, so a bounced, failed, or lost message had no way back. This route is an explicit, one-off override of that guard for a single logged message.
Only a row the plugin can rebuild can be resent: the four templated emails, plus (on the Pro build) a Scheduling booking link or an Email Automation message. A one-off custom message stores only its subject, so it cannot be resent. The resendable flag on each timeline row tells you in advance.
The recipient is resolved fresh, not read off the logged row. A candidate-facing message (confirmation, reject, offer, a booking link, an automated email) goes to the candidate’s current email address on their profile, so correcting a mistyped address before resending does not resend to the old, wrong one.
A staff-facing message (the recruiter_notification team email) keeps going to the address on the logged row, since that address was never the candidate’s.
An interview invitation or cancellation is logged once per attendee, so the row cannot say who to address, and this route refuses it: use Resend invite in the Interviews panel instead, which replays the real message to the real attendee list.
Guard: require_object_cap with vakanssi_edit_application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
email_id | int | yes (path) | (none) | Logged email row id (the id of a "kind": "email" entry in the timeline array, see below). |
Side effects:
- Looks up the logged email row; it must belong to the given application (IDOR guard, otherwise 404).
- Resolves the recipient for the row’s audience, as described above; a
booking_linkresend also issues a fresh booking token, so the previous link stops working (see Interviews). - Rebuilds the merge context fresh from the application’s and candidate’s current records, not a snapshot of what they were at the original send.
- Sends immediately to the resolved recipient, bypassing both the template’s automatic-send setting and the once-only dedup guard. This is a genuine second send, not an edit: it inserts a new row in the email log rather than touching the original, so the Activity tab ends up with both.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/emails/17/resend' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response 200, the application’s refreshed timeline (same shape as the timeline key of GET /applications/{id}, now including the new resend entry):
{ "timeline": [ /* ... */ ] }
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found (no such logged email, or it belongs to a different application); 400 vakanssi_no_recipient (no valid address to resolve, staff or candidate); 409 vakanssi_ambiguous_recipient (an interview invitation or cancellation row); 409 vakanssi_not_resendable (nothing can rebuild the message); 409 vakanssi_conflict (“This email is already being resent.”, a concurrent resend of the same row); 409 vakanssi_email_disabled; 500 vakanssi_send_failed.
GET /vakanssi/v1/stages
The ordered stage list. Use it to resolve stage_id values for filtering and moving. Read-only, with one exception: on a fresh install where no stages exist yet, the first call seeds the six default stages. There is no REST route for editing stages; an administrator or HR / Compliance edits them at Hiring, then Settings, then Pipeline (an admin-post form, not a REST endpoint). See Settings reference.
Guard: require_cap with vakanssi_view_applications.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
job | int | no | (none) | Return the stage set for this job. Absent or unknown job ids fall back to the global default template. |
curl 'https://example.com/wp-json/vakanssi/v1/stages' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
[
{ "id": 1, "name": "New", "slug": "new", "type": "open", "color": "#0f766e", "position": 1, "is_interview": false },
{ "id": 2, "name": "In Review", "slug": "in-review", "type": "open", "color": "#b45309", "position": 2, "is_interview": false },
{ "id": 3, "name": "Interview", "slug": "interview", "type": "open", "color": "#0b2e29", "position": 3, "is_interview": true },
{ "id": 4, "name": "Offer", "slug": "offer", "type": "open", "color": "#0a6b4a", "position": 4, "is_interview": false },
{ "id": 5, "name": "Hired", "slug": "hired", "type": "won", "color": "#1a7f37", "position": 5, "is_interview": false },
{ "id": 6, "name": "Rejected", "slug": "rejected", "type": "lost", "color": "#b42318", "position": 6, "is_interview": false }
]
type is one of open, won, or lost; it drives the derived application status (won stages mark applications hired, lost stages mark them rejected). is_interview flags the one stage (if any) the plugin treats as the Interview stage for that job, resolved server-side, so clients never have to match on a slug a user can rename.
Errors: 401/403 vakanssi_forbidden.
Interviews
Free. Registered by the Interviews module (recruiter-driven, fixed-time scheduling with calendar invites). Candidate self-service booking (proposing slots) is the Pro interviews/propose route.
GET /vakanssi/v1/applications/{id}/interviews
All interviews for one application, soonest-scheduled first with unscheduled interviews last. Read-only.
Guard: require_object_cap with vakanssi_view_applications.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
curl 'https://example.com/wp-json/vakanssi/v1/applications/42/interviews' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
{
"interviews": [
{
"id": 4,
"title": "Final interview",
"mode": "onsite",
"location": "HQ, Floor 3",
"status": "scheduled",
"duration": 60,
"updated_at": "2026-07-01 09:30:00",
"when": "Jul 10, 2026 · 11:00 am",
"interviewers": ["Site Admin"],
"interviewer_ids": [1],
"interviewer_options": [{ "id": 1, "name": "Site Admin" }],
"notes": "Focus on system design.",
"can_reschedule": true
}
]
}
mode is one of video, phone, onsite; status is one of proposed, scheduled, canceled, completed. when is a formatted site-timezone string, empty while unscheduled. can_reschedule is true only for scheduled interviews. interviewers are display names; interviewer_ids are the same people as WordPress user ids, the shape the update route’s interviewers parameter expects back.
On the Pro build each item also carries slots Pro feature, requires a Vakanssi Pro license, the offered times of a proposed interview as formatted strings (empty for any other status). The free build omits the key entirely.
notes is the internal interviewer notes, returned so an edit form can seed the field. It is staff-only and never reaches the candidate.
interviewer_options is the subset of interviewer_ids still on the hiring roster (Administrator, Recruiter, Hiring Manager, or HR), each as {id, name}. Seed an edit form’s interviewer picker from this list, not from interviewer_ids.
A teammate who was invited but has since lost their hiring role stays listed in interviewers for the record, but is missing from interviewer_options, so a form seeded from it cannot re-submit them and trip vakanssi_invalid_interviewer.
booking_url is a Pro addition
The public self-booking link (booking_url) for a proposed interview is added by the Pro Scheduling module, not by this free route. It appears on the drawer payload and on the interviews/propose response, but this standalone GET .../interviews never carries it.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found (application).
POST /vakanssi/v1/applications/{id}/interviews
Creates a fixed-time interview and sends calendar invites. Requires a start time; proposing a set of candidate-pickable slots is the Pro interviews/propose route.
Guard: capability vakanssi_schedule_interview plus object-level authorization on the application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
title | string | no | "Interview" | Interview title, e.g. “Phone screen”. |
mode | string | no | video | One of video, phone, onsite. Any other value is stored as video, not rejected. |
location | string | no | "" | Meeting link or address. |
duration | int | no | 5 | Length in minutes, from 5 to 480. The drawer always sends the Interviews settings default (45 out of the box), but an API call that leaves this out gets a 5-minute interview; a value above 480 is refused with vakanssi_invalid_duration rather than shortened. |
notes | string | no | "" | Internal notes for interviewers. Never reaches the candidate: their .ics carries no DESCRIPTION at all, even when this is set. |
interviewers | int[] | no | [] | WordPress user ids; duplicates removed. Every id must be on the hiring roster (Administrator, Recruiter, Hiring Manager, or HR), an id that isn’t rejects the whole request with vakanssi_invalid_interviewer before anything is persisted. |
start | int | yes, unless start_local is sent | 0 | Unix timestamp for the interview time. A value before 2000-01-01 or more than ten years ahead is treated as no time at all. |
start_local | string | no | (none) | The same time as a site-local wall-clock string, e.g. 2026-12-15T10:00, resolved with the site timezone’s offset on that date. Wins over start when both are sent. This is what the drawer sends. |
The candidate’s name and email are resolved server-side from the application’s candidate record; they are never taken from the request.
Side effects:
- Inserts the interview with an unguessable 32-character booking token and a stable iCalendar UID, status
scheduled. - Emails all attendees (candidate plus interviewers) an HTML invite carrying a
text/calendar; method=REQUEST.ics part, so the event lands in Google, Outlook, and Apple calendars. The interviewers’ .ics carriesnotesas its DESCRIPTION; the candidate’s carries none. Both share one UID and SEQUENCE, so they land on the same calendar entry. Each send is logged to the email log asinterview_invite. - Writes an
interview_scheduledaudit entry and fires thevakanssi/interview/scheduledaction.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/interviews' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"title":"Final interview","mode":"onsite","location":"HQ, Floor 3","duration":60,"interviewers":[5,7],"start":1783508400}'
Response 201: the full refreshed interview list for the application (same shape as the GET), plus invites_sent and invites_failed integers reporting how many of the attendees in step 2 above the send succeeded or failed for.
A mail failure never fails the request itself, every failure is already logged per-recipient to the email log; these two counts just summarize that in the response instead of making the caller poll for it.
Doubled submits do not create a duplicate interview
A double-submit or a client retrying a request whose response it never saw is deduplicated server-side: the create sequence runs under a short-lived, per-application lock, and a second call for the same application and start time within a 2-minute window returns the existing interview (a 201 response with the same interview list) instead of inserting, and inviting, a second one.
Nothing is re-sent, so that repeat response reports invites_sent as 0.
If the lock itself cannot be acquired within 3 seconds (another create for the same application is genuinely still in flight), the route returns vakanssi_conflict (409) rather than risking a duplicate.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found (application); 400 vakanssi_interview_no_time (“An interview needs a start time.”, also for a start outside the accepted range or an unparseable start_local); 400 vakanssi_invalid_interviewer (an interviewers id is not on the hiring roster, the message names the rejected ids); 400 vakanssi_db; 409 vakanssi_conflict (“Another interview is being scheduled for this application right now. Try again in a moment.”).
400 vakanssi_invalid_duration (“An interview can last at most 480 minutes.”) when duration is above that bound.
POST /vakanssi/v1/interviews/{id}/cancel
Cancels an interview. Note the path parameter is the interview id, not the application id.
Guard: capability vakanssi_schedule_interview plus object-level authorization on the interview’s parent application. A missing interview is rejected with 404 by the permission callback itself.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Interview id. |
expected_status | string | no | (none) | Optimistic-concurrency precondition: the interview status you last read (proposed, scheduled, canceled, or completed). Omit it for the legacy last-write-wins behavior; pass it and a live mismatch is rejected as a vakanssi_conflict (409) before anything is written. See Optimistic-concurrency conflicts. |
Side effects:
- Sets the status to
canceledand bumps the iCalendar sequence. - If the interview had a scheduled time, emails all attendees a
method=CANCEL.ics so the event is removed from their calendars (logged asinterview_cancel). - On the Pro build Pro feature, requires a Vakanssi Pro license, if it was still
proposed(no time booked yet), emails the candidate a plain cancellation notice instead, telling them the booking link no longer works (also logged asinterview_cancel). - Writes an
interview_canceledaudit entry. - Fires the
vakanssi/interview/canceledaction.
The write is an atomic compare-and-swap keyed on the row not already being canceled, so two nearly-simultaneous cancels can never both send the calendar cancellation or both bump the sequence. Cancelling an already-canceled interview is a silent no-op that still returns 200.
When expected_status is passed, that precondition is bound into the same compare-and-swap statement (v0.29.8), so a row that moved to any other status under a racing request, most notably completed, is caught at write time and reported as vakanssi_conflict (409) instead of silently overwritten.
Cancel only checks a completed interview when you pass expected_status
The admin UI hides the Cancel link once an interview is marked completed, and the drawer’s own cancel calls always send expected_status (the status it last read), so this exact race cannot happen inside the shipped drawer.
But this route’s own guard only rejects an already-canceled interview by default: a direct POST .../cancel with no expected_status still succeeds on a completed interview, flipping the status back to canceled, bumping the sequence, and emailing everyone a cancellation. Pass expected_status: "completed" (or whatever status you last read) if you need the request rejected instead.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/interviews/4/cancel' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response 200: the refreshed interview list for the parent application, plus invites_sent and invites_failed integers reporting the cancellation emails in the side effects above, same shape as the create route’s response. Both are 0 when the cancel was a no-op on an already-canceled interview, when a proposed interview’s candidate has no valid email address, or for any proposed interview on the free build (which sends no notice).
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found; 409 vakanssi_conflict when expected_status was passed and the live row no longer matches it, whether at your initial read or by the time the write runs, see Optimistic-concurrency conflicts.
POST /vakanssi/v1/interviews/{id}/reschedule
Moves a scheduled interview to a new time. Proposed, canceled, and completed interviews cannot be rescheduled.
Guard: capability vakanssi_schedule_interview plus object-level authorization on the parent application (missing interview: 404 from the permission callback).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Interview id. |
when | int | yes, unless when_local is sent | (none) | New start as a Unix timestamp, in the same accepted range as the create route’s start (2000-01-01 to ten years ahead). |
when_local | string | no | (none) | The new start as a site-local wall-clock string, e.g. 2026-12-15T10:00. Wins over when when both are sent; the drawer sends this one. |
expected_status | string | no | (none) | Optimistic-concurrency precondition: the interview status you last read. Omit it for the legacy last-write-wins behavior; pass it and a live mismatch is rejected as a vakanssi_conflict (409) before anything is written. See Optimistic-concurrency conflicts. |
Side effects: updates the scheduled time, bumps the iCalendar sequence, and re-sends method=REQUEST invites to all attendees, each still getting the notes-in-interviewers’-copy-only split described under the create route above (because the UID is stable, calendars update the existing event instead of creating a duplicate; logged as interview_invite). Writes an interview_rescheduled audit entry and fires the vakanssi/interview/rescheduled action (see Hooks).
The write is an atomic compare-and-swap that only applies while the row is still scheduled, with the sequence bump in the same statement, so a reschedule racing a concurrent cancel (or another reschedule) for the same interview cannot apply on top of a row that changed underneath it.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/interviews/4/reschedule' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"when": 1784016000}'
Response 200: the refreshed interview list, plus invites_sent and invites_failed integers reporting the re-invite send in the side effect above, same shape as the create route’s response.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found; 400 vakanssi_invalid_time (“Choose a valid date and time.”, for a missing, out-of-range, or unparseable time); 409 vakanssi_conflict when the interview is no longer scheduled at write time (“This interview is no longer in a scheduled state, so it cannot be rescheduled.”), or when expected_status was passed and no longer matches the live row, see Optimistic-concurrency conflicts.
POST /vakanssi/v1/interviews/{id}/update
Edits a scheduled interview’s details (everything except its time), the API behind the drawer’s Edit link. On the Pro build Pro feature, requires a Vakanssi Pro license, a proposed interview is editable too; the free build refuses one with 409. Moving the time is the separate reschedule route above, and a proposed interview’s offered slots cannot be changed here.
Guard: capability vakanssi_schedule_interview plus object-level authorization on the parent application, the same permission callback reschedule uses (missing interview: 404 from the permission callback).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Interview id. |
title | string | no | (current value) | Interview title. Omit to leave unchanged. |
mode | string | no | (current value) | One of video, phone, onsite. Omit to leave unchanged. |
duration | int | no | (current value) | Length in minutes, from 5 to 480. Omit to leave unchanged. Resending the current value is always accepted, even one stored above 480 before the maximum existed (editing another field must not shorten or refuse it); only a changed value is checked, and a changed value above 480 is refused with vakanssi_invalid_duration rather than shortened. |
location | string | no | "" | Meeting link or address. |
interviewers | int[] | no | [] | WordPress user ids; duplicates removed. Every id must be on the hiring roster (Administrator, Recruiter, Hiring Manager, or HR), an id that isn’t rejects the whole request with vakanssi_invalid_interviewer before anything is written. |
notes | string | no | (current value) | Internal notes for interviewers. Omit to leave unchanged; send "" to clear. |
expected_status | string | no | (none) | Optimistic-concurrency precondition: the interview status you last read. Omit it for the legacy last-write-wins behavior; pass it and a live mismatch is rejected as a vakanssi_conflict (409) before anything is written. See Optimistic-concurrency conflicts. |
location and interviewers are not partial fields
Unlike title, mode, duration, and notes, omitting location or interviewers from the request does not leave them unchanged, it clears them (an empty location, no interviewers). The drawer’s Edit form always submits the interview’s current values for every field, so this never bites the UI, but a direct API caller sending a partial payload needs to resend the current location and interviewers alongside whatever it is changing.
Side effects: bumps the iCalendar sequence and writes an interview_updated audit entry. No action hook fires. The write is an atomic compare-and-swap keyed on the row still being in the status this request read, with the sequence bump in the same statement.
For a scheduled interview, it re-sends method=REQUEST invites to all attendees, same as reschedule, including the notes-in-interviewers’-copy-only split (logged as interview_invite). A proposed interview Pro feature, requires a Vakanssi Pro license has no calendar entry anywhere, so nothing is sent; the candidate’s booking link keeps working and shows the corrected details.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/interviews/4/update' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"title":"Onsite loop","mode":"onsite","location":"HQ, 5th floor","duration":90,"interviewers":[5,7]}'
Response 200: the refreshed interview list for the parent application, plus invites_sent and invites_failed integers reporting the re-send in the side effect above (both 0 for a proposed interview), same shape as the create route’s response.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found; 400 vakanssi_invalid_interviewer (an interviewers id is not on the hiring roster, the message names the rejected ids); 409 vakanssi_conflict when the interview is canceled or completed (“This interview is no longer in a state that can be edited.”), when its status changed between the read and the write, or when expected_status was passed and no longer matches the live row, see Optimistic-concurrency conflicts.
400 vakanssi_invalid_duration (“An interview can last at most 480 minutes.”) when the sent duration differs from the stored value and is above that bound.
POST /vakanssi/v1/interviews/{id}/resend
Re-sends a scheduled interview’s calendar invite without changing its time, the API behind the drawer’s Resend invite link, for when a candidate (or interviewer) loses the original .ics.
Guard: capability vakanssi_schedule_interview plus object-level authorization on the parent application, the same permission callback cancel uses (missing interview: 404 from the permission callback).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Interview id. |
Side effects: re-sends each attendee their original method=REQUEST .ics invite (the candidate’s and the interviewers’ copies still differ as described under the create route above), logged to the email log as interview_invite, identical to the original send. Only a scheduled interview qualifies, a proposed or canceled one is rejected (see errors). Unlike schedule, cancel, and reschedule, this route writes no audit entry and fires no action.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/interviews/4/resend' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response 200: the refreshed interview list for the parent application, plus invites_sent and invites_failed integers reporting the resend in the side effect above, same shape as the create route’s response.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found; 409 vakanssi_bad_state (“Only a scheduled interview can have its invite resent.”).
POST /vakanssi/v1/interviews/{id}/complete
Marks a scheduled interview as completed, the API behind the drawer’s Mark completed link, so a finished interview stops showing as “Scheduled” indefinitely.
Guard: capability vakanssi_schedule_interview plus object-level authorization on the parent application, the same permission callback cancel and resend use (missing interview: 404 from the permission callback).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Interview id. |
expected_status | string | no | (none) | Optimistic-concurrency precondition: the interview status you last read. Omit it for the legacy last-write-wins behavior; pass it and a live mismatch is rejected as a vakanssi_conflict (409) before anything is written. See Optimistic-concurrency conflicts. |
Side effects: sets the status to completed; writes an interview_completed audit entry; fires the vakanssi/interview/completed action. Sends no email and no calendar update: completion is an internal state change, not something the candidate is notified about.
The write is an atomic compare-and-swap keyed on the row still being scheduled, so a request racing a concurrent cancel or reschedule for the same interview cannot complete a row that just changed under it. A canceled, proposed, or already-completed interview is rejected (see errors).
curl -X POST 'https://example.com/wp-json/vakanssi/v1/interviews/4/complete' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response 200: the refreshed interview list for the parent application.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found; 409 vakanssi_conflict when the interview is no longer scheduled at write time (“This interview is no longer in a scheduled state.”), or when expected_status was passed and no longer matches the live row, see Optimistic-concurrency conflicts.
Notes
Free. Registered by the Notes module: private, body-only notes on a candidate. @mentions, assignment, and the team roster are Pro deltas added by the Collaboration module.
GET /vakanssi/v1/applications/{id}/notes
The note thread for one application, newest first. Read-only.
Guard: require_object_cap with vakanssi_view_applications.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
curl 'https://example.com/wp-json/vakanssi/v1/applications/42/notes' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
{
"notes": [
{
"id": 9,
"user_id": 5,
"body": "Great phone screen, moving to the onsite loop.",
"author": "Jane Recruiter",
"mentions": [],
"when": "2026-07-01 09:12:04"
}
]
}
This route returns only notes. Each note’s user_id is its author, the only user who can edit it. The assigned_to, assignee_name, team, mentionable, and canCollaborate keys are Pro Collaboration additions; they appear on the candidate-drawer payload and on the assign response, but never on this standalone route.
mentions is the raw mentioned user ids; on the free build it is always an empty array (see the POST route), and there is no mentioned key.
On the Pro build each note also carries mentioned Pro feature, requires a Vakanssi Pro license, which pairs each id with a display name ({ "id": 5, "name": "Jane Recruiter" }), falling back to “Former teammate” for a user who no longer exists, so the UI never has to look up a name itself.
Errors: 401/403 vakanssi_forbidden.
POST /vakanssi/v1/applications/{id}/notes
Adds an internal note.
Guard: capability vakanssi_collaborate plus object-level authorization.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
body | string | yes | (none) | Note text. An empty (after trimming) body is rejected with 400. |
mentions | int[] | no | [] | User ids to @mention. Free tier: ignored. Resolved to an empty list unless the Pro Collaboration module is active, which keeps only teammates allowed to view this application (the mentionable list, see assign) and notifies each one. |
Side effects: inserts the note; writes a note_added audit entry; fires the vakanssi/note/added action (Pro Collaboration listens here to email mentioned teammates). On the free tier nothing is emailed.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/notes' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"body":"Great phone screen, moving to the onsite loop.","mentions":[5]}'
Response 200: the refreshed { "notes": [...] } payload, the new note first.
Errors: 401/403 vakanssi_forbidden; 400 rest_missing_callback_param (no body); 400 vakanssi_empty_note (“The note is empty.”); 404 vakanssi_not_found (“Application not found.”) if the application no longer exists, including one deleted in the instant between your last read and this write, or one the automatic retention purge has already anonymised, since v0.29.8 an anonymised application no longer accepts new notes; 400 vakanssi_db.
PATCH /vakanssi/v1/applications/{id}/notes/{note_id}
Edits a note’s body and, on Pro Collaboration, its @mentions; the API behind the inline Edit action in the drawer’s Notes tab. The route also answers POST and PUT on the same path. Author-only: you may edit only your own notes, there is no moderator override for editing (only for deleting below).
Guard: capability vakanssi_collaborate plus object-level authorization (the same check as posting a note).
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. The note must belong to it, or the request 404s (IDOR guard). |
note_id | int | yes (path) | (none) | Note id. |
body | string | yes | (none) | New note text. An empty (after trimming) body is rejected with 400. |
mentions | int[] | no | keeps stored mentions | Same free-tier-ignored resolution as the POST route, but the default differs: omit the key entirely and the note’s existing mentions are left untouched (what a free-tier edit sends, since there is no team to mention); include it, even as [], and it replaces them (what the drawer’s Pro edit form sends, seeded from the note’s current chips, so only a chip you actually removed drops out). On Pro, a stored mention of someone the editor can no longer @mention is kept, since the editor was never offered it to remove. |
expected_body | string | no | (none) | Optimistic-concurrency precondition: the note text your editor loaded. Omit it for the legacy last-write-wins behavior; pass it and a note changed elsewhere since (say, by you in a second tab) is rejected as a vakanssi_conflict (409). The drawer sends it on every edit. |
Side effects: updates the note’s body; re-resolves and replaces mentions only when the request includes a mentions key at all; writes a note_edited audit entry; fires the vakanssi/note/updated action (Pro Collaboration listens here to email only the teammates the edit newly mentioned, not the ones already on the note). On the free tier nothing is emailed.
curl -X PATCH 'https://example.com/wp-json/vakanssi/v1/applications/42/notes/9' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"body": "Great phone screen, strong on architecture, verify SQL depth next round."}'
Response 200: the refreshed { "notes": [...] } payload.
Errors: 401/403 vakanssi_forbidden (no vakanssi_collaborate, no object access, or you are not the note’s author); 404 vakanssi_not_found (no such note, or it belongs to a different application); 400 vakanssi_empty_note; 409 vakanssi_conflict (“This note was changed somewhere else while you were editing. Review the current text and try again.”) when expected_body no longer matches, with the refreshed note thread as current; 500 vakanssi_db.
DELETE /vakanssi/v1/applications/{id}/notes/{note_id}
Deletes a note, the API behind the inline Delete action in the drawer’s Notes tab. The author may delete their own note; a user who additionally holds vakanssi_delete_application (a moderator, in this context) may delete anyone’s, for cleanup.
Guard: capability vakanssi_collaborate plus object-level authorization (the same check as posting a note); the author-or-moderator ownership check itself happens in the service, not the route guard.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. The note must belong to it, or the request 404s (IDOR guard). |
note_id | int | yes (path) | (none) | Note id. |
Side effects: deletes the note row; writes a note_deleted audit entry.
curl -X DELETE 'https://example.com/wp-json/vakanssi/v1/applications/42/notes/9' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
Response 200: the refreshed { "notes": [...] } payload.
Errors: 401/403 vakanssi_forbidden (no vakanssi_collaborate, no object access, or you are neither the author nor a moderator); 404 vakanssi_not_found (no such note, or it belongs to a different application).
Scorecards routes Pro feature, requires a Vakanssi Pro license
Registered only while the Scorecards module is active; otherwise 404 rest_no_route. A reviewer can hold one scorecard per application for each scoreable interview, plus one general scorecard (interview id 0) that is not tied to any interview.
GET /vakanssi/v1/applications/{id}/evaluations
The scorecard state for one application: the current rubric (criteria, scale, recommendation options), the interviews that can be scored, the requesting user’s own saved scorecards, and the aggregated team summary. Read-only.
Guard: capability vakanssi_view_applications plus object-level authorization, the same shape as the free notes and interviews sub-resource GETs.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
curl 'https://example.com/wp-json/vakanssi/v1/applications/42/evaluations' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx'
{
"template": {
"scale": 4,
"criteria": [
{ "key": "skills", "label": "Skills" },
{ "key": "experience", "label": "Experience" },
{ "key": "communication", "label": "Communication" },
{ "key": "culture_add", "label": "Culture add" }
],
"recommendations": [
{ "key": "strong_no", "label": "Strong no" },
{ "key": "no", "label": "No" },
{ "key": "yes", "label": "Yes" },
{ "key": "strong_yes", "label": "Strong yes" }
]
},
"contexts": [
{ "id": 0, "label": "General scorecard", "when": "", "status": "", "is_mine": false, "scoreable": true },
{ "id": 4, "label": "Final interview", "when": "Jul 10, 2026", "status": "completed", "is_mine": true, "scoreable": true }
],
"default": 4,
"mine": {
"4": {
"scores": { "skills": 3, "communication": 4 },
"recommendation": "yes",
"comment": "Strong on architecture; verify SQL depth in the next round.",
"saved_at": "2026-07-10 12:15:00",
"edited_at": ""
}
},
"summary": {
"reviewers": 2,
"scorecards": 2,
"averages": { "skills": 3.5, "experience": 3, "communication": 4, "culture_add": null },
"overall": 3.5,
"recommendations": { "yes": 1, "strong_yes": 1 },
"comments": [
{ "author": "Jane Recruiter", "recommendation": "yes", "comment": "Strong on architecture; verify SQL depth in the next round.", "edited_at": "", "interview_id": 4, "interview": "Final interview" }
]
},
"blind": false,
"can_evaluate": true
}
The template mirrors the rubric configured under Settings, Scorecards; scale is clamped to 2 through 10.
contexts lists what can be scored: the general scorecard first, then each scheduled or completed interview (and any other interview that already holds a scorecard, with scoreable telling them apart). is_mine marks interviews the requesting user is an interviewer on. default is the context a form should open on: the user’s own latest scoreable interview, else the latest scoreable one, else 0.
mine maps an interview id (0 for the general scorecard) to the requesting user’s scorecard for it. saved_at is the first save and edited_at the last revision, empty when never revised. When the user has saved nothing, mine is an empty array, and with only a general scorecard it encodes as a one-item array, so read it by key rather than assuming an object.
In summary, reviewers counts distinct reviewers and scorecards counts scorecards. averages are per-criterion means rounded to two decimals (null when nobody scored that criterion), and overall is the mean of the non-null averages (or null). recommendations is a tally map.
comments lists every scorecard with a non-empty comment, in evaluation order: the author’s display name, their recommendation key, the text, edited_at, and which interview it belongs to (interview is blank for the general scorecard). A scorecard with no comment is omitted, not shown with an empty string.
can_evaluate says whether the requesting user may score this application, object-level authorization included.
blind is true when Independent scoring is on under Settings, Scorecards and this user can score but has saved nothing here yet. The averages, tally, and comments are then emptied ([]) and overall is null, so the values never reach the client (not just hidden). The keys stay in place, and only the two counts keep their numbers.
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found.
POST /vakanssi/v1/applications/{id}/evaluations
Saves (upserts) the requesting user’s scorecard for one context: one row per application, evaluator, and interview, so posting again for the same interview overwrites your previous scores. Sends no emails.
Guard: capability vakanssi_evaluate_application plus object-level authorization.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
interview_id | int | no | 0 | The interview this scorecard belongs to (an id from contexts), or 0 for the general scorecard. It must belong to this application and be scheduled, completed, or already scored; anything else is refused, never coerced. |
scores | object | no | (none) | Map of criterion key to integer score. Unknown keys are dropped; values are clamped to 1 through the template scale; zero or blank values are dropped. |
recommendation | string | no | (none) | Must be one of the template’s recommendation keys; anything else is stored as an empty string. |
comment | string | no | (none) | Free text. |
Side effects:
- Writes the scorecard, unless it is identical to the one already stored, in which case nothing is written.
- When something changed, writes an
evaluation_saved(first save) orevaluation_revisedaudit entry on the application, carrying the interview id but no scores or comment. - Fires the
vakanssi/evaluation/savedaction with the application id, the reviewer id, and the interview id, on every successful request. - Caches the application’s overall score and reviewer count in its meta, for list columns and the CSV export.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/evaluations' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"interview_id":4,"scores":{"skills":3,"communication":4},"recommendation":"yes","comment":"Strong on architecture; verify SQL depth in the next round."}'
Response 200 (not 201), with the same payload shape as the GET, refreshed to include your saved scorecard in mine and the updated summary.
Errors: 401/403 vakanssi_forbidden; 400 vakanssi_invalid_interview (“That interview cannot be scored on this candidate.”); 404 vakanssi_not_found if the application no longer exists, or (since v0.29.8) if the automatic retention purge has already anonymised it, an anonymised application no longer accepts a new or updated scorecard; 500 vakanssi_db (“Could not save the changes.”) when the scorecard write does not land, so nothing is audited or fired.
Scheduling route Pro feature, requires a Vakanssi Pro license
Registered only while the Scheduling module is active; otherwise 404 rest_no_route. Recruiter-driven fixed-time scheduling, cancel, and reschedule are free (the Interviews routes above); this Pro route adds candidate self-service booking.
POST /vakanssi/v1/applications/{id}/interviews/propose
Creates a proposed interview from a set of candidate-pickable slots and emails the candidate a tokenised booking link. The candidate picks a slot on the public booking page, which schedules the interview and sends the same .ics invite a recruiter-scheduled interview would.
Guard: capability vakanssi_schedule_interview plus object-level authorization on the application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
title | string | no | "Interview" | Interview title. |
mode | string | no | video | One of video, phone, onsite. Any other value is stored as video, not rejected. |
location | string | no | "" | Meeting link or address. |
duration | int | no | 5 | Length in minutes, from 5 to 480. As with the create route, the drawer sends the settings default, but an API call that leaves this out gets 5 minutes; a value above 480 is refused with vakanssi_invalid_duration rather than shortened. |
notes | string | no | "" | Internal notes for interviewers. Never reaches the candidate: their .ics carries no DESCRIPTION at all, even when this is set. |
interviewers | int[] | no | [] | WordPress user ids. Every id must be on the hiring roster (Administrator, Recruiter, Hiring Manager, or HR), an id that isn’t rejects the whole request with vakanssi_invalid_interviewer before anything is persisted, same guard as the free schedule/update routes. |
slots | int[] | yes, unless slots_local is sent | [] | Unix timestamps for proposed start times. Past or malformed entries are dropped, duplicates removed, and the rest sorted and capped at 12; at least one usable slot is required. |
slots_local | string[] | no | (none) | The same slots as site-local wall-clock strings, e.g. 2026-12-15T10:00. Wins over slots when it yields at least one valid time; the drawer sends this one. |
Side effects:
- Inserts a
proposedinterview with an unguessable 32-character booking token and a stable iCalendar UID. - Emails the candidate the tokenised public booking link (
/vakanssi-booking/<token>on sites with pretty permalinks,?vakanssi_book=<token>otherwise) so they can pick a time (logged asbooking_link). - Writes an
interview_proposedaudit entry and fires thevakanssi/interview/scheduledaction.
Like the create route, this runs under the per-application scheduling lock, and a repeat of the same proposal within a short window returns the existing interview instead of creating and emailing a second one.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/interviews/propose' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"title":"Phone screen","mode":"video","location":"https://meet.example.com/abc","duration":45,"interviewers":[5,7],"slots":[1783407600,1783513800]}'
Response 201: the full refreshed interview list, with each proposed interview carrying its public booking_url, plus booking_link_sent, whether the booking-link email actually reached the candidate. The interview is created either way, so a false here does not mean the request failed: it means the link needs resending, from the Interviews panel, or the Activity tab’s Resend, which can rebuild a booking_link message for as long as the interview stays proposed.
A resend issues a fresh token before rebuilding the message, so the token in the original email stops working the moment the resend goes out.
{
"interviews": [
{
"id": 5,
"title": "Phone screen",
"mode": "video",
"location": "https://meet.example.com/abc",
"status": "proposed",
"duration": 45,
"updated_at": "2026-07-01 09:30:00",
"when": "",
"interviewers": ["Site Admin", "Jane Recruiter"],
"interviewer_ids": [5, 7],
"interviewer_options": [{ "id": 5, "name": "Site Admin" }, { "id": 7, "name": "Jane Recruiter" }],
"slots": ["Jul 7, 2026 · 10:00 am (Asia/Jerusalem)", "Jul 8, 2026 · 3:30 pm (Asia/Jerusalem)"],
"notes": "",
"can_reschedule": false,
"booking_url": "https://example.com/vakanssi-booking/Xy9zQ2wV8kR4tN7mB1cJ6dF3hL5pG0aS"
}
],
"booking_link_sent": true
}
Errors: 401/403 vakanssi_forbidden; 404 vakanssi_not_found (application); 400 vakanssi_invalid (“Provide an interview time or at least one proposed slot.”, also returned when every proposed slot is in the past); 400 vakanssi_invalid_interviewer (an interviewers id is not on the hiring roster, the message names the rejected ids); 400 vakanssi_invalid_duration (“An interview can last at most 480 minutes.”); 400 vakanssi_db; 400 vakanssi_conflict when the scheduling lock cannot be acquired.
This route stamps 400 on every service error, including that conflict, so branch on the error code, not the status.
Collaboration route Pro feature, requires a Vakanssi Pro license
Registered only while the Collaboration module is active; otherwise 404 rest_no_route. The note thread itself is free (the Notes routes above); this Pro route adds application assignment (and the module also layers @mentions and the team roster onto the note payload).
POST /vakanssi/v1/applications/{id}/assign
Assigns the application to a teammate, or unassigns it.
Guard: capability vakanssi_collaborate plus object-level authorization.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
assigned_to | int | yes | (none) | User id of the new assignee. Must be on the team roster. 0 unassigns. |
expected_assigned_to | int | no | (none) | Optimistic-concurrency precondition: the assignee id you last saw this application carry (0 for “expect unassigned”). Omit it for the legacy last-write-wins behavior; pass it and a live mismatch is rejected as a vakanssi_conflict (409) before anything is written. See Optimistic-concurrency conflicts. |
Side effects:
- Updates the application’s assignee.
- Writes an
assignedaudit entry. - Emails the new assignee a “You were assigned …” notification (skipped when users assign themselves or unassign), logged to the email log as
assignment. - Fires the
vakanssi/application/assignedaction.
The write is an atomic compare-and-swap, so two teammates racing to assign the same application cannot both silently apply, land the audit row, or send the email, regardless of whether either passes expected_assigned_to.
Assigning to the assignee the application is already at is always a no-op: nothing is written, audited, emailed, or fired, so a retried request or a re-run bulk assign over an already-assigned batch never double-logs or double-emails.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/assign' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"assigned_to": 5}'
Response 200: the full collaboration block (the note thread plus the Pro assignment and team keys), so the drawer panel can re-render without a second request:
{
"notes": [],
"assigned_to": 5,
"assignee_name": "Site Admin",
"team": [
{ "id": 1, "name": "Jane Recruiter", "email": "jane@example.com" },
{ "id": 5, "name": "Site Admin", "email": "admin@example.com" }
],
"mentionable": [1, 5],
"canCollaborate": true
}
team lists up to 200 users holding a hiring role (administrator, vakanssi_recruiter, vakanssi_hiring_manager, vakanssi_hr). assigned_to is 0 and assignee_name an empty string when unassigned. canCollaborate reflects whether the requesting user holds vakanssi_collaborate.
mentionable is the subset of team ids allowed to be @mentioned on this application: everyone who holds vakanssi_view_applications for it, the whole team with assignment scoping off, or just admins/HR plus the assignee with it on. The note composer’s @mention picker (assets/js/notes.js) filters team against this list, and vakanssi/notes/resolve_mentions enforces it server-side.
Errors: 401/403 vakanssi_forbidden; 400 vakanssi_not_found (note 400, not 404, see Conventions); 400 vakanssi_bad_assignee (“That user is not on the hiring team.”); 409 vakanssi_conflict (“Someone else assigned this application first.”) when expected_assigned_to was passed and no longer matches the live row, or when a legacy (no-precondition) retry against the live value still loses, see Optimistic-concurrency conflicts.
Bulk assign never sends a precondition
The Applications list’s Assign to: … bulk action (Collaboration Pro feature, requires a Vakanssi Pro license) assigns each selected application independently with no expected_assigned_to, so it stays the deliberate legacy last-write-wins path: a row someone else assigned mid-batch is simply overwritten for that row, and the batch as a whole is not one atomic operation.
It still inherits the same-assignee no-op above, so re-running a bulk assign over an already-assigned selection does not re-audit or re-email those rows.
Sourcing routes Pro feature, requires a Vakanssi Pro license
Registered only while the Candidates (Sourcing & CRM) module is active; otherwise 404 rest_no_route. Tags belong to the candidate, not the application: they are shared across every application from the same person, whether you edit them here (from an application’s drawer) or on the Candidates screen itself. Both routes share one path and return the candidate’s fresh, alphabetical tag list.
POST /vakanssi/v1/applications/{id}/tags
Adds one tag to the application’s candidate, the API behind the Candidate tags section on the drawer’s Details tab.
Guard: capability vakanssi_manage_talent_pool plus object-level view_applications authorization on the application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
tag | string | yes | (none) | The tag to add. Trimmed, has inner whitespace collapsed, and is capped at 64 characters. |
Side effects: adds the tag to the candidate_tags store (the same store the Candidates screen reads and writes) and fires the vakanssi/candidate/tagged action. Adding a tag the candidate already has (case-insensitive) is a no-op success, not an error, and fires nothing.
curl -X POST 'https://example.com/wp-json/vakanssi/v1/applications/42/tags' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"tag": "PHP"}'
Response 200:
{ "tags": ["PHP", "Senior"] }
Errors: 401/403 vakanssi_forbidden; 400 vakanssi_no_candidate (the application has no linked candidate, e.g. a walk-in); 400 vakanssi_empty_tag; 400 vakanssi_tag_limit (“A candidate can have at most 30 tags.”); 500 vakanssi_db (“Could not save the changes.”) when the tag write does not land.
DELETE /vakanssi/v1/applications/{id}/tags
Removes one tag from the application’s candidate.
Guard: capability vakanssi_manage_talent_pool plus object-level view_applications authorization on the application.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | int | yes (path) | (none) | Application id. |
tag | string | yes | (none) | The tag to remove. Removing a tag the candidate does not have is a harmless no-op, not an error. |
Side effects: removes the tag and fires the vakanssi/candidate/untagged action, only when the candidate actually had it.
curl -X DELETE 'https://example.com/wp-json/vakanssi/v1/applications/42/tags' \
--user 'admin:AbCd EfGh IjKl MnOp QrSt UvWx' \
-H 'Content-Type: application/json' \
-d '{"tag": "PHP"}'
Response 200: the fresh tag list, same shape as the POST route above.
Errors: 401/403 vakanssi_forbidden; 400 vakanssi_no_candidate; 500 vakanssi_db (“Could not save the changes.”) when the removal does not land.
Candidate profile (Pro Sourcing)
Pro feature, requires a Vakanssi Pro licenseThe Sourcing & CRM module adds a set of person-scoped routes for the Candidates database, separate from the application-scoped routes above. A candidate is one person, shared across all their applications, so these operate on the candidate record itself. They register only when the Sourcing module is active; otherwise requests return rest_no_route (404).
| Method + path | Capability | Purpose |
|---|---|---|
GET /vakanssi/v1/candidates/{id} | view_candidate | Read one candidate’s full profile. |
PUT /vakanssi/v1/candidates/{id} (also POST, PATCH) | edit_candidate | Edit profile fields (see below). |
DELETE /vakanssi/v1/candidates/{id} | delete_candidate | Permanently delete the candidate and cascade to their applications, CVs, and interview history. |
GET /vakanssi/v1/candidates/{id}/tags | view_candidate | List the person’s tags. |
POST /vakanssi/v1/candidates/{id}/tags | edit_candidate | Add one or more tags (tag, required; a comma- or newline-separated list is accepted). The 30-tag cap is checked against the whole batch before anything is written (400 vakanssi_tag_limit); 500 vakanssi_db if a write does not land. |
DELETE /vakanssi/v1/candidates/{id}/tags | edit_candidate | Remove one or more tags (tag, required; same list format). 500 vakanssi_db if a removal does not land. |
GET /vakanssi/v1/candidate-tags | vakanssi_manage_talent_pool | Tag suggestions across visible candidates, for autocomplete. |
POST /vakanssi/v1/candidates/{id}/attach | edit_candidate | Attach the candidate to a job (job_id, required), creating a fresh application in that job’s first stage. Idempotent: the response’s attach block (job_id, job_title, application_id, created) says whether a new application was made. |
Each of view_candidate, edit_candidate, and delete_candidate is an object capability: the caller needs the mapped capability and the candidate must be within the scope their role can see. Reading resolves to vakanssi_manage_talent_pool; editing resolves to vakanssi_edit_application, so a Recruiter or HR user can write to a person’s record on the strength of the same capability that lets them edit an application.
delete_candidate resolves to vakanssi_delete_application, so deleting a candidate needs the same permission as deleting an application: HR / Compliance and Administrator only, never a plain Recruiter (see Roles & permissions).
The editable fields on PUT /candidates/{id} are first_name, last_name, email, phone, location, source, linkedin_url, country, website, headline, and note. Name, email, phone, location, and source are columns on the candidate row; the rest are stored in the record’s JSON meta. A new email is checked against the normalized identity of every other candidate and rejected with 409 vakanssi_email_conflict if it already belongs to someone else, never silently merged.
It takes an optional expected_details_version precondition (the profile payload’s details_version), the same counter the drawer’s details route advances, so the two screens conflict with each other. A stale value is rejected with 409 vakanssi_conflict before anything is written.
Related non-REST endpoints
Six HTTP endpoints are often mistaken for REST routes but live outside vakanssi/v1:
- Public interview booking Pro feature, requires a Vakanssi Pro license :
https://example.com/vakanssi-booking/<token>(orhttps://example.com/?vakanssi_book=<token>on sites without pretty permalinks) renders the candidate’s standalone slot-picker page (HTML, unauthenticated; the unguessable 32-character token from the booking email is the credential). See Interviews. - CV download:
https://example.com/wp-admin/admin-post.php?action=vakanssi_download_cv&file=<id>&_wpnonce=<nonce>streams a candidate file to logged-in users who pass a nonce check and object-level authorization on the owning application, rate-limited to 30 downloads per user per minute. This is whatfiles[].download_urlin the single-application payload points to. - Printable application sheet:
https://example.com/wp-admin/admin-post.php?action=vakanssi_application_print&id=<id>&_wpnonce=<nonce>renders a self-contained printable HTML page for one application. Requiresvakanssi_view_applicationsplus a nonce and object-level authorization, so access mirrors the drawer. Withautoprint=1appended, the page opens the browser’s print dialog on load; that variant is whatprint_urlpoints to. With Screening Pro feature, requires a Vakanssi Pro license active, the sheet also carries the candidate’s screening answers and the knockout flag, added through thevakanssi/sheet/sectionsaction (see Hooks). - Applications CSV export:
https://example.com/wp-admin/admin-post.php?action=vakanssi_applications_export&_wpnonce=<nonce>(plus optionaljob,stage, andsquery parameters, identical in meaning to the List view’s filters) streams applications as CSV, newest first, for users withvakanssi_export_data. This is the Export CSV button on the Applications List view.- Stops at 50,000 rows by default (filterable with
vakanssi/export/applications_max_rows, see Hooks); if the result set is actually bigger than the cap, the file’s last row is aTRUNCATED: this export stopped at ... rowsmarker rather than an ordinary record, so a partial export is never mistaken for a complete one. A denied request (missing capability) answers 403, not 500.
- Stops at 50,000 rows by default (filterable with
- Analytics CSV export Pro feature, requires a Vakanssi Pro license :
https://example.com/wp-admin/admin-post.php?action=vakanssi_analytics_export&_wpnonce=<nonce>(plus an optionalrangequery parameter:30,90, orall, matching the dashboard’s date range control and defaulting to30) streams the Hiring HQ analytics (summary, source effectiveness, applications by job, funnel, time in stage) as a multi-section CSV for users withvakanssi_export_data. This is the Export CSV button on Hiring HQ analytics. A denied request answers 403, not 500. - Candidates CSV export Pro feature, requires a Vakanssi Pro license :
https://example.com/wp-admin/admin-post.php?action=vakanssi_candidates_export&_wpnonce=<nonce>(plus optionaltag,source, andsquery parameters, identical in meaning to the Candidates screen’s filters) streams the talent pool as CSV, newest first, for users with bothvakanssi_manage_talent_poolandvakanssi_export_data. This is the Export CSV button on the Candidates screen. Same 50,000-row default cap andTRUNCATEDmarker as the applications export, filterable withvakanssi/export/candidates_max_rows. A denied request answers 403, not 500.
For the plugin’s actions and filters, including the REST extension points mentioned above, see Hooks; for the full capability list, see Capabilities.