Extending safely
The supported extension points: settings tabs, candidate-drawer panels, application-form fields, anti-spam providers, authorization filters, email filters, and lifecycle actions, each with a working example.
On this page(8)
Vakanssi is built around a small set of deliberate extension seams: a settings-tab registry, a JavaScript slot in the candidate drawer, application-form and anti-spam hooks, an authorization policy you can tighten, filterable emails, and lifecycle actions for every meaningful hiring event. This page shows a working example for each. The full annotated hook inventory, with stability markers, is the Hooks reference.
Every seam on this page ships in the free build except vakanssi/authz/application_scope Pro feature, requires a Vakanssi Pro license, which fires only in the premium build. The Pro modules in the premium build attach through the same seams, so anything you build this way coexists with them. To add a whole feature module of your own, see registering a custom module.
Adding a Settings tab
Hiring, then Settings, is one tabbed screen fed by a registry. Modules and extensions contribute tabs on the vakanssi/settings/tabs hook. Note that this is an action, not a filter: it passes you the SettingsRegistry object, and you call add() on it with a descriptor.
The descriptor shape:
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
id | string | yes | (none) | The tab slug, used in ?tab=. First registration for an id wins; a later add() with the same id is ignored. |
label | string | yes | (none) | The nav-tab text. Not validated, but the tab strip prints it, so always set it. |
render | callable | yes | (none) | Prints the tab body. Descriptors without it (or without id) are ignored. |
capability | string | no | vakanssi_manage_settings | Checked for tab visibility and re-checked before render. |
priority | int | no | 50 | Ascending sort. |
There is no license or feature key: a Pro tab exists only because its module boots in the premium build and registers it.
A complete tab that stores one option through the Settings API:
add_action( 'admin_init', function () {
register_setting(
'acme_hiring_group',
'acme_hiring_settings',
array(
'type' => 'array',
'default' => array( 'api_key' => '' ),
'sanitize_callback' => function ( $input ) {
$input = is_array( $input ) ? $input : array();
return array(
'api_key' => isset( $input['api_key'] ) ? sanitize_text_field( $input['api_key'] ) : '',
);
},
)
);
} );
add_action( 'vakanssi/settings/tabs', function ( $registry ) {
$registry->add(
array(
'id' => 'acme',
'label' => __( 'Acme Sync', 'acme-hiring' ),
'capability' => 'vakanssi_manage_settings',
'priority' => 55,
'render' => function () {
$settings = get_option( 'acme_hiring_settings', array( 'api_key' => '' ) );
?>
<h2><?php esc_html_e( 'Acme Sync', 'acme-hiring' ); ?></h2>
<form method="post" action="options.php">
<?php settings_fields( 'acme_hiring_group' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="acme-api-key"><?php esc_html_e( 'API key', 'acme-hiring' ); ?></label>
</th>
<td>
<input type="text" class="regular-text" id="acme-api-key"
name="acme_hiring_settings[api_key]"
value="<?php echo esc_attr( $settings['api_key'] ); ?>" />
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<?php
},
)
);
} );
// options.php checks manage_options by default. Let anyone who can see the
// tab (HR / Compliance holds vakanssi_manage_settings) also save it.
add_filter( 'option_page_capability_acme_hiring_group', function () {
return 'vakanssi_manage_settings';
} );
The action fires lazily, the first time the tab list is needed in a request (in practice, when the Settings screen renders). Your tab appears at admin.php?page=vakanssi-settings&tab=acme. The Settings reference documents the built-in tabs.
Adding a candidate-drawer panel
The candidate drawer on the Applications board has a JavaScript extension slot. When the drawer renders, the board runs:
wp.hooks.applyFilters( 'vakanssi.pipeline.drawer_panels', [], data, helpers );
It is a standard wp.hooks (@wordpress/hooks) filter over an array of panel descriptors. data is the drawer’s REST payload: the GET /vakanssi/v1/applications/{id} response after the vakanssi/rest/application_payload PHP filter has run, with keys such as application, candidate, files, meta, and timeline. helpers is an object holding reload, a function that re-fetches the payload (call it after your panel writes something), and setDirty( id, dirty ).
A panel that holds unsaved work must report it through setDirty, or closing the drawer or stepping to the next candidate discards that work without asking.
Each entry you return becomes a tab in the drawer’s workspace. Return a titled descriptor { id, title, node } to control the tab label (the pattern the built-in Notes and Interviews panels use), or return a bare React element, which the board wraps under a generic “Details” tab.
( function ( wp ) {
'use strict';
if ( ! wp || ! wp.hooks || ! wp.element ) {
return;
}
var el = wp.element.createElement;
function AcmePanel( props ) {
var app = props.data.application;
return el( 'div', { className: 'acme-panel' },
el( 'p', null, 'Application #' + app.id + ' for ' + app.job_title ),
el( 'button', {
className: 'button',
onClick: function () {
// Call your own endpoint here, then refresh the drawer so the
// payload (and every other panel) is up to date.
props.reload();
},
}, 'Sync to Acme' )
);
}
wp.hooks.addFilter(
'vakanssi.pipeline.drawer_panels',
'acme/drawer-panel',
function ( panels, data, helpers ) {
if ( ! data || ! data.application ) {
return panels;
}
return panels.concat( [ {
id: 'acme',
title: 'Acme CRM',
node: el( AcmePanel, {
data: data,
reload: helpers && helpers.reload,
} ),
} ] );
}
);
}( window.wp ) );
Enqueue it so it depends on the board island’s own script handle, vakanssi-pipeline:
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( false === strpos( (string) $hook, 'vakanssi-candidates' ) ) {
return; // Only the Applications screen (Board and List views).
}
wp_enqueue_script(
'acme-drawer-panel',
plugins_url( 'assets/acme-drawer-panel.js', __FILE__ ),
array( 'vakanssi-pipeline', 'wp-element', 'wp-hooks', 'wp-i18n' ),
'1.0.0',
true
);
} );
The screen check matches the Applications page slug, vakanssi-candidates, the same check the built-in Notes panel uses. The vakanssi-pipeline dependency is the safety net: WordPress never prints a script whose dependency is missing, and that handle is only enqueued on the Applications screen, so your panel script loads exactly where the drawer exists and nowhere else.
To ship server-side data with the drawer instead of fetching it separately, pair the panel with the payload filter:
add_filter( 'vakanssi/rest/application_payload', function ( array $payload, $application_id ) {
$payload['acme'] = array(
'synced' => acme_is_synced( (int) $application_id ), // Your own lookup.
);
return $payload;
}, 10, 2 );
Your panel then reads data.acme, the same single-request pattern the Pro panels use. (The filter also receives the WP_REST_Request as a third argument if you need it.)
Adding a field to the application form
The application form has three hooks for a field of your own, and a fourth to keep its value. vakanssi/intake/form_fields prints it, vakanssi/intake/posted_fields names it, vakanssi/intake/data attaches its value to the submission, and vakanssi/application/created hands you that value once the application is saved.
Since v0.37.6 the form handler reads only the request fields it is told about. A field you print but do not name on vakanssi/intake/posted_fields still reaches $_POST, but it is not passed to spam checks and is not refilled when the form is shown again after an error.
// 1. Print the field. Runs inside the <form>, before the consent checkbox.
add_action( 'vakanssi/intake/form_fields', function ( $post_id, $retained ) {
$value = isset( $retained['acme_portfolio'] ) ? (string) $retained['acme_portfolio'] : '';
?>
<p class="vakanssi-field">
<label for="acme-portfolio"><?php esc_html_e( 'Portfolio URL', 'acme-hiring' ); ?></label>
<input type="url" id="acme-portfolio" name="acme_portfolio" value="<?php echo esc_attr( $value ); ?>" />
</p>
<?php
}, 10, 2 );
// 2. Name it, so it is refilled after a validation error.
add_filter( 'vakanssi/intake/posted_fields', function ( $names ) {
$names[] = 'acme_portfolio';
return $names;
} );
// 3. Attach the sanitized value to the submission. Keep this pure: it also
// runs for submissions that are about to be rejected.
add_filter( 'vakanssi/intake/data', function ( $data ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- the apply handler has already checked its nonce.
$raw = isset( $_POST['acme_portfolio'] ) && is_string( $_POST['acme_portfolio'] ) ? wp_unslash( $_POST['acme_portfolio'] ) : '';
$data['acme_portfolio'] = esc_url_raw( $raw, array( 'http', 'https' ) );
return $data;
} );
// 4. Use it once the application exists.
add_action( 'vakanssi/application/created', function ( $application_id, $candidate_id, $data ) {
if ( ! empty( $data['acme_portfolio'] ) ) {
// Store it in your own table or service here.
}
}, 10, 3 );
Vakanssi does not store an added key: the capture step reads only its own named fields, so your value travels through to vakanssi/application/created and stops there. To make the field required, return an error string from vakanssi/intake/validate ( $errors, $data, $job_id ) when $data['acme_portfolio'] is empty.
Adding an anti-spam provider
The Application form settings tab offers the challenge providers registered on vakanssi/intake/spam_providers, an array of id => provider. A provider implements Vakanssi\Modules\Intake\Spam\SpamProvider: id(), label(), is_configured(), render_field( $post_id ), enqueue(), verify( $request ), settings_fields(), and sanitize_settings( $input ).
verify() receives only the request fields named by vakanssi/intake/posted_fields. Since v0.37.6, a provider whose widget posts a token names that field with a public request_field() method; the plugin adds it to the list while that provider is the active, configured one. Without it (or a name added on the filter yourself), the token never reaches verify().
For a widget-plus-siteverify CAPTCHA, extend Vakanssi\Modules\Intake\Spam\AbstractChallengeProvider, the base the built-in Turnstile provider uses. It already provides request_field(), is_configured(), a siteverify verify() that honors the fail-open setting, and site and secret key settings. You supply the widget specifics:
use Vakanssi\Modules\Intake\Spam\AbstractChallengeProvider;
use Vakanssi\Modules\Intake\Spam\AntispamSettings;
// Declared on plugins_loaded: the base class is autoloaded by Vakanssi, which a
// must-use plugin (or a plugin that loads before Vakanssi) runs ahead of.
add_action( 'plugins_loaded', function () {
if ( ! class_exists( AbstractChallengeProvider::class ) ) {
return; // Vakanssi is not active.
}
class Acme_Captcha_Provider extends AbstractChallengeProvider {
public function id() { return 'acme_captcha'; }
public function label() { return 'Acme CAPTCHA'; }
// The POST field the widget fills; request_field() returns this.
protected function field() { return 'acme-captcha-response'; }
protected function verify_url() { return 'https://captcha.example.com/siteverify'; }
// Decide from the decoded siteverify JSON.
public function interpret( array $decoded ) { return ! empty( $decoded['success'] ); }
public function render_field( $post_id ) {
return '<div class="acme-captcha" data-sitekey="' . esc_attr( $this->site_key() ) . '"></div>';
}
public function enqueue() {
wp_enqueue_script( 'acme-captcha', 'https://captcha.example.com/api.js', array(), null, true );
}
public function settings_fields() {
return array(
'label' => 'Acme CAPTCHA',
'fields' => array(
'site' => array( 'label' => 'Site key', 'type' => 'text' ),
'secret' => array( 'label' => 'Secret key', 'type' => 'password' ),
),
);
}
}
add_filter( 'vakanssi/intake/spam_providers', function ( $providers ) {
$providers['acme_captcha'] = new Acme_Captcha_Provider( AntispamSettings::for_provider( 'acme_captcha' ) );
return $providers;
} );
} );
The class is declared on plugins_loaded because its parent is loaded by Vakanssi’s own autoloader, which does not exist yet while a must-use plugin (or a plugin that loads before Vakanssi) runs. Declared at the top level of such a file, extends AbstractChallengeProvider stops the site with a class-not-found fatal error.
The captcha.example.com addresses stand in for your vendor’s real script and siteverify endpoint. render_field() markup passes through wp_kses(), so a placeholder element with class and data-* attributes survives but a <script> does not: load the vendor script from enqueue(), which runs on single job pages only. The Hooks reference covers the optional methods and how a misbehaving provider is contained.
Custom permission logic
Every decision about a specific application or candidate routes through the AuthorizationPolicy service and its vakanssi/authz/can filter, so you can scope access per object without touching call sites. (Screen access checks the capability directly.) The filter receives ( $allowed, $action, $object, $user, $capability ). Here is a policy that tightens who can move stages: only the assigned user (plus admins and HR) may move an application that has an assignee.
add_filter( 'vakanssi/authz/can', function ( $allowed, $action, $object, $user, $capability ) {
if ( ! $allowed || 'move_application' !== $action || empty( $object ) ) {
return $allowed; // Never loosen: bail unless the capability layer already said yes.
}
$user_id = ( $user instanceof WP_User ) ? (int) $user->ID : (int) ( $user ?: get_current_user_id() );
if ( user_can( $user_id, 'vakanssi_manage_settings' ) ) {
return $allowed; // Admins and HR keep full access.
}
global $wpdb;
$assigned = (int) $wpdb->get_var( $wpdb->prepare(
"SELECT assigned_to FROM {$wpdb->prefix}vakanssi_applications WHERE id = %d",
(int) $object
) );
return ( 0 === $assigned || $assigned === $user_id );
}, 10, 5 );
On the premium build Pro feature, requires a Vakanssi Pro license, you can also narrow what appears in application lists with a matching vakanssi/authz/application_scope clause. That filter receives ( $scope, $user_id ), where $scope is array{ clauses: string[], params: array } of SQL fragments merged into every application-listing query, so your scoping is honoured in the inbox, the board, exports, and counts alike.
The free build does not fire this filter. There the callback below never runs and application lists stay organization-wide, while vakanssi/authz/can above still guards each single application.
add_filter( 'vakanssi/authz/application_scope', function ( $scope, $user_id ) {
$user_id = (int) $user_id; // This filter always passes a user id.
if ( user_can( $user_id, 'vakanssi_manage_settings' ) ) {
return $scope; // No restriction for admins and HR.
}
// Every fragment must qualify its columns with the `a.` applications alias:
// they are merged into a query that already joins other tables.
$scope['clauses'][] = '(a.assigned_to = %d OR a.assigned_to = 0)';
$scope['params'][] = $user_id;
return $scope;
}, 10, 2 );
Tighten, never loosen
The vakanssi/authz/can return value is the final decision, so nothing mechanically stops a callback from returning true where the capability check failed, but doing so is outside the supported contract. The documented rule, and the pattern every shipped consumer follows, is to deny or pass through: return false to tighten, and return $allowed unchanged otherwise. Never grant access the capability layer denied.
Customizing emails
Every templated email the plugin sends passes through the same filters. Add a header:
add_filter( 'vakanssi/email/headers', function ( array $headers ) {
$headers[] = 'Bcc: hiring-archive@example.com';
return $headers;
} );
Modify the HTML. The filter receives the fully wrapped, inline-styled markup plus the merge context, so you can append or transform:
add_filter( 'vakanssi/email/html', function ( $html, array $context ) {
// Not every message carries a company in its context; fall back to the site name.
$company = ! empty( $context['company'] ) ? $context['company'] : get_bloginfo( 'name' );
return $html . '<p style="color:#787c82;font-size:12px">'
. esc_html( $company ) . ' is an equal-opportunity employer.</p>';
}, 10, 2 );
Suppress an automatic notification. vakanssi/email/stage_notification gates the status emails triggered by stage moves. It receives ( $send, $application_id, $status ); return false to skip the email. The stage move itself and the audit log are unaffected:
add_filter( 'vakanssi/email/stage_notification', function ( $send, $application_id, $status ) {
if ( 'rejected' === $status ) {
return false; // The team sends rejections manually.
}
return $send;
}, 10, 3 );
Related filters worth knowing: vakanssi/email/notify_address (reroute the recruiter notification per job, ( $email, $job_id )), and vakanssi/email/company (the display name in the header and {company} tag). The counterpart of stage_notification, vakanssi/email/force_stage_notification, is Internal: it carries the REST move endpoint’s notify=true to the listener, and is not a contract to build on. Signatures are on the Hooks reference; template editing itself is covered in Emails.
Reacting to lifecycle events
Every meaningful hiring event fires an action after the data is durably written. The workhorse is vakanssi/application/created, which fires after the capture transaction commits, so observers always see a real row. It receives ( $application_id, $candidate_id, $data ):
add_action( 'vakanssi/application/created', function ( $application_id, $candidate_id, $data ) {
// Non-blocking, so a slow receiver never delays the applicant's confirmation page.
wp_remote_post( 'https://crm.example.com/api/leads', array(
'blocking' => false,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( array(
'source' => 'vakanssi',
'application' => (int) $application_id,
'candidate' => (int) $candidate_id,
'name' => trim( ( $data['first_name'] ?? '' ) . ' ' . ( $data['last_name'] ?? '' ) ),
'email' => $data['email'] ?? '',
'job_id' => (int) ( $data['job_id'] ?? 0 ),
) ),
) );
}, 10, 3 );
Other public actions in the same family:
vakanssi/application/stage_changed,( $application_id, $stage_id, $from, $status ), fires after an application moves stage ($stage_idis the destination stage id,$fromthe previous one,$statusthe derived application status).vakanssi/interview/scheduled,( $interview_id, $row ), fires when an interview is booked (from the Interviews module, and from Pro self-booking).vakanssi/email/sent,( $key, $to, $ok, $application_id ), fires after each templated email is dispatched.
See the Hooks reference for the full list with arguments. If you would rather not write code at all, the Pro Integrations module Pro feature, requires a Vakanssi Pro license delivers these same events as signed webhooks and Slack messages from the admin UI; see Integrations.
Rules of the road
- Build on the v1 contract. Hooks marked Public on the Hooks reference have frozen names, argument order, and semantics. Hooks marked Internal, and anything undocumented, may change in any release.
- Treat internals as private. The service container, repository classes, module classes, and admin markup are implementation detail. If it is not a documented hook, REST route, shortcode, or capability, do not depend on it.
- Tighten, do not loosen. Authorization filters should only ever deny or pass through the incoming decision, never grant access the capability layer refused.
- Do not write to the tables directly. Deletes cascade across the custom tables and everything audit-logs through services, so raw writes bypass both. Read through the REST API, react through hooks. (See Database schema for what is connected to what.)
- Test on the free build. It contains no Pro code at all: Pro hooks never fire, Pro REST routes 404, Pro drawer panels and settings tabs do not exist, and the four Pro-only tables are never created. Guard anything that assumes them so your extension degrades cleanly. The module split is on Architecture; the user-facing view is Free vs Pro.