Hooks reference
The complete Vakanssi hook contract: every vakanssi/* action and filter with its exact signature, parameters, the source file it fires in, and copy-paste PHP examples, plus the JavaScript filters and actions on the Applications screen.
On this page(23)
Vakanssi exposes its behavior through WordPress hooks. Actions and filters are namespaced vakanssi/{area}/{event} (for example vakanssi/application/created or vakanssi/jobs/query_args), with one historical underscore-form exception, vakanssi_fs_loaded. The JavaScript hooks use dot notation: five filters (vakanssi.pipeline.drawer_panels, vakanssi.pipeline.details_sections, vakanssi.pipeline.header_controls, vakanssi.pipeline.rail_sections, and vakanssi.pipeline.card_markers) plus three actions (vakanssi.pipeline.state, vakanssi.presence.update, and vakanssi.presence.connection).
Filters receive a value and must return one of the same type (return it unchanged if you do not want to alter it). Actions are notifications: their return value is ignored. Register your callbacks in a small must-use plugin (safest, survives theme switches) or in your theme’s functions.php.
For the module system behind these hooks see Architecture; for building whole extensions see Extending Vakanssi; for the REST surface many of them shape see the REST API.
Stability contract
Hooks are the v1 extension contract: public hook names, argument order, and semantics are frozen and change only in a major release, after a deprecation cycle. Hooks flagged Internal below carry no compatibility guarantee and may change or vanish in any release. Do not build on internal hooks.
A contract test in the plugin (tests/Unit/HookContractTest.php) fails CI if a public hook is renamed or removed, or if a fired hook is left unclassified.
Internal hooks are listed for completeness
Every vakanssi hook the plugin fires is on this page, including the internal ones: cross-build seams the Pro modules answer, test-only seams, tuning knobs over an implementation detail, and hands into undocumented objects. They are listed so you can tell what a hook you find in the source is for, not so you can build on it.
If you need an internal hook for something real, open an issue rather than filtering it: a hook with a real use case can be promoted into the contract, which is the only way it becomes safe to depend on.
Pro hooks
Hooks marked Pro feature, requires a Vakanssi Pro license are fired by Pro module code, which only the Pro build of the plugin contains: the free plugin from WordPress.org ships none of it, so those hooks never fire there. On a Pro site they fire while the license is valid and the module is switched on under Hiring, then Tools, then Features (see Features and Free vs Pro).
A hook inside any switched-off module, free or Pro, never fires, and neither does one whose module depends on a switched-off module. Your callback stays registered and is simply never called.
Contents
The index below numbers every hook on this page in page order as a reading aid only: the number shifts whenever a hook is added, so always cite a hook by its name, never by its number.
Hook index: all 186 hooks on this page, numbered in page order
vakanssi/bootedvakanssi/activatedvakanssi/deactivatedvakanssi/upgradedvakanssi/core/bootedvakanssi/schema/migratedvakanssi/modules/registervakanssi/modules/loadedvakanssi/settings/tabsvakanssi/rest/register_routesvakanssi/admin/suppress_foreign_noticesvakanssi/admin/pure_hiring_uservakanssi/admin/workspace_urlvakanssi/admin/quiet_dashboardvakanssi/http/client_ipvakanssi/http/trusted_proxiesvakanssi/jobs/post_type_argsvakanssi/jobs/supportsvakanssi/jobs/permalink_basevakanssi/jobs/schemavakanssi/jobs/output_schemavakanssi/jobs/org_namevakanssi/jobs/apply_markupvakanssi/jobs/has_apply_pathvakanssi/jobs/field_visibilityvakanssi/jobs/app_tabvakanssi/jobs/details_rowsvakanssi/jobs/details_markupvakanssi/jobs/related_markupvakanssi/jobs/archive_titlevakanssi/jobs/archive_templatevakanssi/jobs/archive_per_pagevakanssi/jobs/bar_controlsvakanssi/jobs/single_templatevakanssi/jobs/query_argsvakanssi/jobs/listing_rowvakanssi/jobs/empty_statevakanssi/jobs/expiredvakanssi/jobs/expiry_batch_sizevakanssi/application/createdvakanssi/intake/success_urlvakanssi/intake/enabledvakanssi/intake/require_consentvakanssi/intake/consent_labelvakanssi/intake/retention_notevakanssi/intake/form_fieldsvakanssi/intake/datavakanssi/intake/validatevakanssi/intake/posted_fieldsvakanssi/intake/require_apply_noncevakanssi/intake/spam_checkvakanssi/intake/spam_providersvakanssi/antispam/fail_openvakanssi/intake/min_secondsvakanssi/intake/max_secondsvakanssi/intake/rate_limitvakanssi/intake/rate_windowvakanssi/intake/attempt_limitvakanssi/intake/burst_limitvakanssi/intake/download_rate_limitvakanssi/intake/block_expired_jobsvakanssi/intake/block_duplicate_active_applicationvakanssi/intake/max_files_per_applicationvakanssi/intake/retry_storage_limitvakanssi/retention/monthsvakanssi/candidate/email_identityvakanssi/storage/base_dirvakanssi/storage/allowed_mimesvakanssi/storage/validate_filevakanssi/storage/max_upload_sizevakanssi/cv/storedvakanssi/screening/questionsvakanssi/screening/answers_savedvakanssi/screening/knockoutvakanssi/import/sourcesvakanssi/import/unsupportedvakanssi/import/allowed_cv_extensionsvakanssi/import/rowvakanssi/import/completedvakanssi/import/map_persistedvakanssi/csv_import/max_bytesvakanssi/csv_import/max_rowsvakanssi/csv_import/batch_sizevakanssi/csv_import/mapping_ttlvakanssi/application/stage_changedvakanssi/application/updatedvakanssi/application/deletedvakanssi/application/viewedvakanssi/authz/canvakanssi/authz/action_mapvakanssi/authz/application_scopevakanssi/authz/candidate_scopevakanssi/authz/visible_applicationsvakanssi/rest/application_payloadvakanssi/rest/applications_payloadvakanssi/pipeline/list_row_markersvakanssi/sheet/sectionsvakanssi/pipeline/candidate_contextvakanssi/candidate/updatedvakanssi/pipeline/bulk_actionsvakanssi/pipeline/bulk_resultvakanssi/pipeline/result_noticevakanssi/pipeline/stage_emails_on_entryvakanssi/evaluation/savedvakanssi/export/applications_max_rowsvakanssi/notes/resolve_mentionsvakanssi/note/addedvakanssi/note/updatedvakanssi/interview/scheduledvakanssi/interview/rescheduledvakanssi/interview/canceledvakanssi/interview/completedvakanssi/interview/bookedvakanssi/booking/available_slotsvakanssi/booking/powered_byvakanssi/application/assignedvakanssi/presence/intervalvakanssi/candidate/sourcedvakanssi/candidate/attachedvakanssi/candidate/mergedvakanssi/sourcing/bulk_actionsvakanssi/sourcing/bulk_resultvakanssi/candidate/taggedvakanssi/candidate/untaggedvakanssi/export/candidates_max_rowsvakanssi/webhooks/payloadvakanssi/webhooks/eventsvakanssi/webhooks/queuevakanssi/export/columnsvakanssi/export/rowvakanssi/export/chunk_sizevakanssi/email/sentvakanssi/email/htmlvakanssi/email/headersvakanssi/email/companyvakanssi/email/notify_addressvakanssi/email/notify_applicantvakanssi/email/stage_notificationvakanssi/email/force_stage_notificationvakanssi/email/resendablevakanssi/email/resend_payloadvakanssi/email/audiencevakanssi/email/send_argsvakanssi/email/failedvakanssi/email/flush_budgetvakanssi/email/placeholdersvakanssi/audit/rowvakanssi/audit/anonymize_ipvakanssi/audit/ipvakanssi/privacy/eraser_batchvakanssi/privacy/export_cv_filesvakanssi/privacy/exporter_batchvakanssi/privacy/erasedvakanssi/retention/should_deletevakanssi/retention/purgedvakanssi/retention/batch_sizevakanssi/retention/max_chainvakanssi/cron/retention_purgevakanssi/cron/expiry_purgevakanssi/cron/webhook_cleanupvakanssi/cron/webhook_retryvakanssi/license/providervakanssi/license/canvakanssi/license/freemius_instancevakanssi_fs_loadedvakanssi/dashboard/analytics_bandsvakanssi/dashboard/stage_event_analyticsvakanssi/dashboard/analytics_export_urlvakanssi/onboarding/candidate_pagesvakanssi/date_formatvakanssi/datetime_formatvakanssi/docs_urlvakanssi/stage_events/backfill_chunkvakanssi/schema/pro_tablesvakanssi/schema/table_existsvakanssi/schema/innodb_availablevakanssi/transaction/supportedvakanssi/data_reset/delete_tablevakanssi.pipeline.drawer_panelsvakanssi.pipeline.details_sectionsvakanssi.pipeline.header_controlsvakanssi.pipeline.rail_sectionsvakanssi.pipeline.card_markersvakanssi.pipeline.statevakanssi.presence.updatevakanssi.presence.connection
Lifecycle & modules
Boot order, activation, the schema migrator, the module registry, the settings-tab registry, and the REST route seam. These are the platform hooks extensions build on.
vakanssi/booted
src/Core/Plugin.phpdo_action( 'vakanssi/booted', Vakanssi\Core\Plugin $plugin )Runs once on plugins_loaded, after every active module has booted: it fires after vakanssi/modules/register and vakanssi/modules/loaded, so it is too late to register a module (a module registered here is listed as active but its boot() never runs). Register modules on vakanssi/modules/register; use this hook for work that needs the booted plugin.
Both hooks fire before a theme’s functions.php loads, so add them from a plugin or a must-use plugin.
vakanssi/activated
src/Core/Activator.phpdo_action( 'vakanssi/activated' )Activation finished: schema migrated, roles and capabilities installed, protected storage provisioned, default stages seeded, retention cron scheduled.
vakanssi/deactivated
src/Core/Deactivator.phpdo_action( 'vakanssi/deactivated' )Plugin deactivated: cron events cleared, rewrite rules flushed. Non-destructive, no data removed.
vakanssi/upgraded
src/Core/Plugin.phpdo_action( 'vakanssi/upgraded', string $version )Fires while an in-place version upgrade is being applied: after the schema migration completes, before the new version (VAKANSSI_VERSION) is recorded, checked on both init and admin_init. It also fires once when the plugin’s files are replaced at the same version by a copy that creates a different set of database tables, such as switching between the free and premium builds.
If a listener does not finish, a fatal error or a timeout partway through the chain, the version is left unrecorded and this hook fires again on the next request, so listeners must be idempotent.
do_action( 'vakanssi/core/booted', Vakanssi\Core\Container $container )The always-on Core module booted, before vakanssi/booted.
do_action( 'vakanssi/schema/migrated', string $version )- $version
- the schema version just written (Schema::VERSION)
dbDelta() created or upgraded all tables, every table and unique index was confirmed present, and the schema-version option was stored. An incomplete migration does not fire it; the next request retries.
vakanssi/modules/register
src/Core/Modules/ModuleRegistry.phpdo_action( 'vakanssi/modules/register', Vakanssi\Core\Modules\ModuleRegistry $registry )Register custom feature modules. Call $registry->register( $module ) with a ModuleInterface. It fires on plugins_loaded, before a theme’s functions.php is loaded, so a callback added from a theme never runs: register it from a plugin or a must-use plugin.
ModuleRegistry::register() takes a Vakanssi\Core\Modules\ModuleInterface; the first registration of an id wins. If your callback on vakanssi/modules/register, or your module’s own boot(), throws, the registry catches it, logs it, and continues: it does not take the rest of the site down. A throw on vakanssi/modules/register does end that hook early, though, so callbacks queued after yours on it never run. See Architecture: the module system.
// Register a custom module (Acme_Referrals_Module implements
// Vakanssi\Core\Modules\ModuleInterface).
add_action( 'vakanssi/modules/register', function ( $registry ) {
$registry->register( new Acme_Referrals_Module() );
} );do_action( 'vakanssi/modules/loaded', ModuleRegistry $registry )Every active module has booted in dependency order.
vakanssi/settings/tabs
src/Admin/SettingsRegistry.phpdo_action( 'vakanssi/settings/tabs', Vakanssi\Admin\SettingsRegistry $registry )Contribute tabs to Hiring, then Settings. Call $registry->add( $tab ).
SettingsRegistry::add() requires id and render; label, capability (defaults to the manage-settings capability), and priority (default 50, lower sorts first) are optional. The first registration of an id wins, and a tab shows only to users who hold its capability.
// Add a "Job boards" tab to Hiring > Settings.
add_action( 'vakanssi/settings/tabs', function ( $registry ) {
$registry->add( array(
'id' => 'acme-job-boards',
'label' => 'Job boards',
'priority' => 60,
'render' => function () {
echo '<h2>Job boards</h2><p>Feed configuration for external job boards.</p>';
},
) );
} );do_action( 'vakanssi/rest/register_routes', Vakanssi\Rest\Server $server, Container $container )Register REST routes alongside Vakanssi’s own, on rest_api_init.
Admin screens
Vakanssi’s own screens (anything under Hiring) automatically hide other plugins’ admin notices, review nags, upsells, and unrelated warnings, so only Vakanssi’s own messages and WordPress’s own notices show there. See the troubleshooting FAQ for the recruiter-facing explanation.
vakanssi/admin/suppress_foreign_notices
src/Admin/Admin.phpapply_filters( 'vakanssi/admin/suppress_foreign_notices', bool $suppress, WP_Screen $screen )- $suppress
- default: true
Whether Vakanssi removes other plugins’ admin_notices and all_admin_notices callbacks on its own screens. Only callbacks declared in another plugin, a must-use plugin, or a theme are removed.
WordPress core’s own notices (updates, recovery mode, paused plugins, privacy policy), Vakanssi’s own, and anything whose file lies outside every plugin and theme folder (a drop-in, say) are always kept. A callback whose file cannot be determined, such as a built-in PHP function, is removed. Return false to let every plugin’s notices show as usual.
// Let every plugin's notices show on Vakanssi screens too.
add_filter( 'vakanssi/admin/suppress_foreign_notices', '__return_false' );vakanssi/admin/pure_hiring_user
src/Admin/HiringWorkspace.phpapply_filters( 'vakanssi/admin/pure_hiring_user', bool $pure, int $user_id )Whether a user counts as hiring-only: they can view applications but hold no edit_posts or manage_options capability. Drives both hooks below. Return false to exempt a specific user.
vakanssi/admin/workspace_url
src/Admin/HiringWorkspace.phpapply_filters( 'vakanssi/admin/workspace_url', string $url, int $user_id )- $url
- the Hiring HQ admin URL
The URL a hiring-only user is sent to on login, in place of the generic WordPress dashboard. Only overrides the plain “go to wp-admin” default; a deep link the user or another plugin requested is always respected.
// Send hiring-only users to the applications inbox instead of Hiring HQ.
add_filter( 'vakanssi/admin/workspace_url', function ( string $url, int $user_id ): string {
return admin_url( 'admin.php?page=vakanssi-candidates' );
}, 10, 2 );vakanssi/admin/quiet_dashboard
src/Admin/HiringWorkspace.phpapply_filters( 'vakanssi/admin/quiet_dashboard', bool $quiet, int $user_id )- $quiet
- default: true
Whether the generic core dashboard widgets (Quick Draft, WordPress news, Site Health, and similar) and the welcome panel are removed for a hiring-only user who opens the Dashboard. Third-party widgets are left in place.
HTTP & client IPs
The client-IP resolver behind application rate-limiting, the audit log, and CAPTCHA challenge providers. See Firewalls & server compatibility for the Visitor IP source setting this backs.
A forwarded header (CF-Connecting-IP, X-Forwarded-For) is only ever read when the request actually arrived from a proxy Vakanssi trusts, not merely because the header is present. Anyone can send either header straight to the origin, so believing them unconditionally would let a candidate mint themselves an unlimited supply of rate-limit buckets.
In cloudflare mode the trusted set is the published Cloudflare edge ranges. In forwarded mode the immediate peer is trusted by definition, since choosing that mode is the operator stating that a reverse proxy fronts the site. A site with an extra hop in front of it (a CDN and a load balancer, say) declares the outer proxy through the VAKANSSI_TRUSTED_PROXIES constant or the vakanssi/http/trusted_proxies filter below.
vakanssi/http/client_ip
src/Support/ClientIp.phpapply_filters( 'vakanssi/http/client_ip', string $ip, string $mode )- $ip
- may be ''
- $mode
- off, cloudflare, or forwarded
The resolved visitor IP. When the request arrived from a trusted proxy, this is CF-Connecting-IP or the right-most X-Forwarded-For entry that is not itself a trusted proxy; otherwise it falls back to REMOTE_ADDR, the direct connection address.
Runs last, after the proxy mode and the trusted-proxy list, so a value returned here overrides both. Every per-IP consumer reads it: the apply-form abuse counters, the anti-spam challenge providers, and the audit log. Returning a header a visitor controls defeats all of those counters at once.
// Trust a load balancer's custom header instead of the built-in modes,
// but only on requests that actually arrived from that load balancer.
add_filter( 'vakanssi/http/client_ip', function ( string $ip, string $mode ): string {
if ( 'off' === $mode && '10.0.0.5' === $ip && ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
$candidate = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) );
return filter_var( $candidate, FILTER_VALIDATE_IP ) ? $candidate : $ip;
}
return $ip;
}, 10, 2 );vakanssi/http/trusted_proxies
src/Support/ClientIp.phpapply_filters( 'vakanssi/http/trusted_proxies', string[] $proxies, string $mode )- $proxies
- IPs or CIDR ranges
- $mode
- cloudflare or forwarded
The proxies whose forwarded client-IP headers this site believes, as IPv4/IPv6 addresses or CIDR ranges. Only consulted when Visitor IP source is cloudflare or forwarded; in cloudflare mode the list arrives pre-seeded with the published Cloudflare edge ranges, so return a narrower list to override them entirely.
The VAKANSSI_TRUSTED_PROXIES constant (array or comma-separated string, set in wp-config.php) feeds the same list and is usually the simpler way to add one address.
An entry here means “a request arriving FROM this address has a trustworthy forwarded header”: list your own infrastructure, never a client.
// A second reverse proxy sits between Cloudflare and the origin.
add_filter( 'vakanssi/http/trusted_proxies', function ( array $proxies, string $mode ): array {
$proxies[] = '10.0.0.0/8';
return $proxies;
}, 10, 2 );Jobs & careers
The free Jobs & Careers module: the vakanssi_job post type, the careers listing and single-job page, the visible detail rows, and the Google for Jobs structured data. See Creating jobs and The careers page.
How returned markup is escaped
Since v0.37.9, every candidate-facing markup hook below (apply_markup, listing_row, empty_state, details_markup, related_markup, plus form_fields and consent_label in Application intake) is printed through wp_kses() with one of three allowlists on Vakanssi\Support\Svg. rich() keeps post markup, inline SVG, and <bdi>. apply_form() adds form controls and their standard attributes. board() adds aria-disabled on top of apply_form().
The filter removes <script>, <style>, <iframe>, <noscript>, <template>, <object>, <noembed>, <noframes>, and <xmp>, together with everything inside them, plus every event-handler attribute (onclick and the rest). The plugin’s own markup is unchanged; only what a callback adds through these hooks is affected.
vakanssi/jobs/post_type_args
src/Modules/Jobs/JobPostType.phpapply_filters( 'vakanssi/jobs/post_type_args', array $args )The full register_post_type() args for vakanssi_job (labels, rewrite slug, has_archive, show_in_rest, supports, capabilities), immediately before the type is registered. To move job URLs, use the Job URL base setting or vakanssi/jobs/permalink_base below rather than editing rewrite here, since that base also drives the archive.
// Keep job pages out of the site's own search results.
add_filter( 'vakanssi/jobs/post_type_args', function ( array $args ): array {
$args['exclude_from_search'] = true;
return $args;
} );vakanssi/jobs/supports
src/Modules/Jobs/JobPostType.phpapply_filters( 'vakanssi/jobs/supports', string[] $supports )The features the job type supports (title, editor, excerpt, revisions, author, custom-fields), before post_type_args.
Since v0.33.30, thumbnail is not in the default list: a job posting is a document, not an article, and a decorative photo above the role only pushed the details table and the apply button further down. Neither the listing nor the JobPosting schema ever read it.
Adding it back below only restores the editor panel. Rendering the image is then the theme’s own work, through the Site Editor on a block theme or a child-theme copy of templates/single-jobs.php on a classic one.
// Restore the featured-image panel in the job editor.
add_filter( 'vakanssi/jobs/supports', function ( array $supports ): array {
$supports[] = 'thumbnail';
return $supports;
} );vakanssi/jobs/permalink_base
src/Modules/Jobs/PermalinkBase.phpapply_filters( 'vakanssi/jobs/permalink_base', string $base )The URL base jobs register under (rewrite slug and archive), after the Job URL base setting has been read. Sanitized with sanitize_title() afterwards; an empty result falls back to jobs.
// Serve job pages under /careers/ instead of /jobs/.
// Visit Settings > Permalinks once afterwards to flush rewrite rules.
add_filter( 'vakanssi/jobs/permalink_base', function ( string $base ): string {
return 'careers';
} );vakanssi/jobs/schema
src/Modules/Jobs/JobPostingSchema.phpapply_filters( 'vakanssi/jobs/schema', array $schema, WP_Post $post )The JobPosting JSON-LD array before output. Return [] to suppress. title and the organization name arrive as plain text, not HTML entities (an apostrophe reads as ', not ’).
vakanssi/jobs/output_schema
src/Modules/Jobs/JobPostingSchema.phpapply_filters( 'vakanssi/jobs/output_schema', bool $should, WP_Post|null $post )- $should
- default: true
Whether Vakanssi emits JobPosting for a job (coexistence with SEO plugins). Only consulted on a single job page, when the JobPosting structured data setting leaves the schema to Vakanssi and the job is eligible for it; $post is get_post() at wp_head.
vakanssi/jobs/org_name
src/Modules/Jobs/JobPostingSchema.phpapply_filters( 'vakanssi/jobs/org_name', string $name )Hiring-organization name used in schema. Default: the company name setting, falling back to the site title when that is empty.
vakanssi/jobs/apply_markup
src/Modules/Jobs/JobDetails.phpapply_filters( 'vakanssi/jobs/apply_markup', string $html, int $post_id )HTML for the on-page apply call-to-action. The default is an “Online applications for this position open soon.” notice, and the Intake module replaces it with the form at priority 10. Not reached for a closed job or one with an external apply URL, which render their own markup.
Escaped with Svg::apply_form() wherever it is printed (the job page, [vakanssi_job_apply], the Elementor Apply widget); see the escaping note above.
vakanssi/jobs/has_apply_path
src/Modules/Jobs/JobEligibility.phpJobFields.phpapply_filters( 'vakanssi/jobs/has_apply_path', bool $has, int $post_id )- $has
- default: false
Whether a job has an internal apply path. On the job page and in the structured-data check, a job with an external apply URL never asks; for any other job the answer decides whether it qualifies for JobPosting structured data. The Intake module answers true while vakanssi/intake/enabled is on. Return true if you embed your own form.
The job editor’s readiness checklist asks for every job, external apply URL or not.
vakanssi/jobs/field_visibility
src/Modules/Jobs/JobFields.phpapply_filters( 'vakanssi/jobs/field_visibility', array<string,bool> $map )Which optional detail fields (salary, employment, closing) are shown, resolved from the Job details setting (Settings, then Jobs & Careers). Drives the visible block and the schema together, so structured data never advertises a field the page hides. A key missing from the returned map counts as visible.
vakanssi/jobs/app_tab
src/Modules/Jobs/JobFields.phpdo_action( 'vakanssi/jobs/app_tab', WP_Post $post )- $post
- the job being edited
Fires at the end of the job editor’s Employment & application tab panel, still inside the same <form> as the rest of the Job details box. This is the supported way to add a section to that tab instead of registering a detached meta box, so your fields submit with the job’s own form.
Saving them is still your code’s job: Vakanssi’s save handler reads only its own fields, so hook save_post_vakanssi_job to store yours.
Screening Pro feature, requires a Vakanssi Pro license uses this seam for its per-job question set. Nothing listens in the free build, where it is a no-op.
// Add an internal "hiring budget code" field to the job editor.
add_action( 'vakanssi/jobs/app_tab', function ( WP_Post $post ) {
$code = get_post_meta( $post->ID, '_acme_budget_code', true );
wp_nonce_field( 'acme_budget_code', 'acme_budget_code_nonce' );
printf(
'<p><label for="acme-budget-code">Budget code</label><br /><input type="text" id="acme-budget-code" name="acme_budget_code" class="regular-text" value="%s" /></p>',
esc_attr( $code )
);
} );vakanssi/jobs/details_rows
src/Modules/Jobs/JobDetails.phpapply_filters( 'vakanssi/jobs/details_rows', array $rows, int $post_id )- $rows
- each row is {key, label, value}
The visible detail rows before render: add, remove, reorder, or relabel. Both cells are escaped by the renderer, so a label or value returned here must be plain text, not HTML. A row with an empty label and value is skipped.
vakanssi/jobs/details_markup
src/Modules/Jobs/JobDetails.phpapply_filters( 'vakanssi/jobs/details_markup', string $html, int $post_id, array $rows )The assembled detail block markup (values already escaped). Return your own HTML to replace it, or an empty string to remove the block. Escaped with Svg::rich() for [vakanssi_job_details] and the Elementor Job Details widget, and with Svg::apply_form() on the job page; see the escaping note above.
vakanssi/jobs/archive_title
src/Modules/Jobs/CareersArchive.phptemplates/archive-jobs.phpapply_filters( 'vakanssi/jobs/archive_title', string $title )- $title
- default: 'Open positions'
The careers archive heading, on both a classic theme’s templates/archive-jobs.php and, since v0.33.28, a block theme’s Site Editor template.
On a block theme the title is read when the template registers, on init at priority 10, since that is where register_block_template() itself has to run. A callback added at file scope, on plugins_loaded, or in a theme’s functions.php is in time; one added on wp or later is not. A classic theme reads the filter on every request instead, so timing does not matter there.
// Rename the careers page heading.
add_filter( 'vakanssi/jobs/archive_title', function ( string $title ): string {
return 'Careers at Acme';
} );vakanssi/jobs/archive_template
src/Modules/Jobs/CareersArchive.phpapply_filters( 'vakanssi/jobs/archive_template', string $template )Path to the careers archive template on a classic theme. A theme’s own archive-vakanssi_job.php, in the theme root or a vakanssi/ subfolder, still wins. Not called when a block theme is active (a Site Editor template resolves instead) or when the theme cannot supply get_header() and get_footer().
vakanssi/jobs/archive_per_page
src/Modules/Jobs/CareersArchive.phpapply_filters( 'vakanssi/jobs/archive_per_page', int $per_page )- $per_page
- default: 20
Jobs per page on the built-in /jobs/ archive main query. Clamped to 1 through 100.
vakanssi/jobs/bar_controls
src/Modules/Jobs/Shortcodes.phpapply_filters( 'vakanssi/jobs/bar_controls', string[] $controls, array $atts )- $controls
- in render order
Which command-bar controls a [vakanssi_jobs] listing renders, resolved from the filters attribute. Add or remove any of search, department, remote, sort; return [] to drop the bar entirely. Runs last, so it can restore a bar on a placement that set filters="false".
Cannot force the team picker onto a listing with only one team, since that check runs after this filter. The return value is re-sanitized against the four known names.
// Drop the sort control from every careers listing, keep the rest.
add_filter( 'vakanssi/jobs/bar_controls', function ( array $controls, array $atts ): array {
return array_values( array_diff( $controls, array( 'sort' ) ) );
}, 10, 2 );vakanssi/jobs/single_template
src/Modules/Jobs/SingleJobTemplate.phpapply_filters( 'vakanssi/jobs/single_template', string $template )Path to the single-job template used in “Vakanssi layout” mode (a theme single-vakanssi_job.php, in the theme root or a vakanssi/ subfolder, still wins). Not called at all when the single_template setting is theme, when a block theme is active (a Site Editor template resolves instead), or when the theme cannot supply get_header() and get_footer().
vakanssi/jobs/query_args
src/Modules/Jobs/Shortcodes.phpapply_filters( 'vakanssi/jobs/query_args', array $args, array $atts )The WP_Query args behind every [vakanssi_jobs] listing that runs its own query, after the shortcode attributes are resolved. The built-in /jobs/ archive does not: its template renders [vakanssi_jobs archive="main"], which consumes the main query, so shape that one with vakanssi/jobs/archive_per_page or pre_get_posts.
It also runs for each listing’s helper queries: an ids-only count probe (fields => 'ids', posts_per_page => 1) and the all-teams lookup behind the team picker (posts_per_page => -1). A callback that sets posts_per_page or fields unconditionally breaks the page count and the picker, so leave those keys alone when they are already set.
Since v0.33.7, a paginated listing (see Pagination) already carries paged and no_found_rows => false in $args; leave those alone, or the count line and the page nav lose the total they’re built from.
vakanssi/jobs/listing_row
src/Modules/Jobs/Shortcodes.phpapply_filters( 'vakanssi/jobs/listing_row', string $row_html, WP_Post $post, array $atts )The markup for one job row, immediately before it is printed. The result goes through wp_kses() with the plugin’s rich allowlist: post markup, inline SVG, and data-* attributes survive; scripts, event handlers, <form>, <input>, and <select> do not.
vakanssi/jobs/empty_state
src/Modules/Jobs/Shortcodes.phpapply_filters( 'vakanssi/jobs/empty_state', string $default, array $atts )The markup shown when a listing matches no roles: the empty_message attribute, or “There are no open positions right now.”, already escaped. Escaped with Svg::board(); see the escaping note above.
vakanssi/jobs/expired
src/Modules/Jobs/ExpiryPurge.phpdo_action( 'vakanssi/jobs/expired', int $job_id )- $job_id
- the job that just closed
Fires when a job has closed because its closing date passed, found by the daily vakanssi/cron/expiry_purge sweep (see Cron events below). Since v0.33.36, the sweep calls clean_post_cache() on the job first, which every major page cache listens to, and fires this once per job. Saving the job clears that once-only marker, so a job saved again while still past its closing date fires it again at the next sweep.
Unlike an operator closing a job, this edits no post, so none of the save_post hooks a page cache listens to fire. Hook it to purge what clean_post_cache() cannot reach: a CDN, a reverse proxy, or the careers archive, which is a URL with no post behind it.
// Purge the careers archive from a CDN when a role closes on its own.
add_action( 'vakanssi/jobs/expired', function ( int $job_id ) {
acme_cdn_purge( get_post_type_archive_link( 'vakanssi_job' ) );
} );apply_filters( 'vakanssi/jobs/expiry_batch_size', int $size )- $size
- default: 100
Expired jobs purged per sweep run, floored at 1. A full batch chains a follow-up run a minute later, up to 20 follow-ups, so one day handles at most 21 batches (2,100 jobs at the default).
No Custom CSS filters
Since v0.36.0 Vakanssi has no Custom CSS box of its own, so the vakanssi/frontend/custom_css and vakanssi/frontend/custom_css_boost filters are gone. Write your rules in WordPress’s own Additional CSS editor (Appearance, then Customize, then Additional CSS on a classic theme); the vakanssi- class names below are unchanged.
CSS classes on the careers and job pages
The careers archive and single job page render through two different paths: a classic theme gets templates/archive-jobs.php and templates/single-jobs.php; a block theme gets the Site Editor templates CareersArchive and SingleJobTemplate register (see Block themes get a branded archive and single page for free).
Since v0.33.28, both paths emit the same wrapper classes, so one CSS rule targets either theme kind. On a block theme that needs WordPress 6.7 or later (older versions cannot register plugin block templates), with the careers archive left on and the job page’s Page layout left on Vakanssi layout.
| Class | Where |
|---|---|
.vakanssi-careers-archive, #vakanssi-careers | Careers archive wrapper |
.vakanssi-careers-header | The archive heading block |
.vakanssi-single-job, #vakanssi-single-job | Job page wrapper |
.vakanssi-single-job__article | The job article inside that wrapper |
.vakanssi-single-job__header | The block holding the job title |
.vakanssi-single-job__title | The job title |
.vakanssi-single-job__body | The job description |
The listing itself, everything from .vakanssi-jobs-board down to .vakanssi-job-apply (see Markup and extension points), comes from the shortcode/block renderer and is already identical on every theme; only the wrapper classes above changed.
Three things to know before you rely on them:
- On a block theme, these classes carry no Vakanssi styling of their own. The wrappers are core blocks (group, post title) whose spacing the theme already owns through
theme.json, and Vakanssi deliberately does not fight that. Your CSS applies exactly as written. On a classic theme the same classes additionally carry Vakanssi’s own column width and spacing, since there is no theme layout to defer to. .vakanssi-single-job__articleand.vakanssi-single-job__headerare now present on both theme kinds. They used to exist on classic themes only, so CSS written against them did nothing on a block theme.- What still stays classic-only is the rest of what
post_class()prints on that same element:post-123,type-vakanssi_job,status-publish, andhentry. (Departments and Locations are private taxonomies, sopost_class()adds no per-term classes for them on any theme.) A rule combining one of those with the wrapper still applies on classic themes only. body_class()gives yousingle-vakanssi_jobandpostid-123on every theme, which covers most of what those per-post tokens were used for.- There is no
.vakanssi-single-job__thumbclass since v0.33.30. The post type no longer supports a featured image by default, so neither template renders one. Seevakanssi/jobs/supportsabove to add it back.
If you already opened and saved “Jobs archive” or “Single job” under Appearance, then Editor, WordPress keeps your edited copy and plugin updates no longer touch it; add the class yourself on the block’s Advanced panel, under Additional CSS class(es). The body classes post-type-archive-vakanssi_job and single-vakanssi_job are always present regardless of which template source rendered the page.
Application intake
The free Application Intake module: the native apply form, anti-spam, CV storage, candidate deduplication, and retention. See Receiving applications and Privacy.
vakanssi/application/created
src/Modules/Intake/CaptureService.phpdo_action( 'vakanssi/application/created', int $application_id, int $candidate_id, array $data )A new application was captured, after the database commit and after vakanssi/cv/stored. From the public apply form, $data holds job_id, first_name, last_name, email, phone, linkedin_url, cover_letter, applicant_country, consent, consent_basis, source (native), referrer, and attach_cv_on_reapply, plus anything a vakanssi/intake/data callback attached (Screening Pro feature, requires a Vakanssi Pro license adds screening).
The admin Add application screen fires it too, with a shorter $data (no linkedin_url, consent_basis, referrer, or attach_cv_on_reapply; consent is false; source is what staff picked, or manual). A resubmission collapsed into an existing application does not fire it again.
// Post to Slack whenever a new application is captured.
add_action( 'vakanssi/application/created', function ( int $application_id, int $candidate_id, array $data ) {
wp_remote_post( 'https://hooks.slack.com/services/T000/B000/XXXX', array(
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( array(
'text' => sprintf(
'New application #%d from %s %s for job #%d',
$application_id,
$data['first_name'],
$data['last_name'],
$data['job_id']
),
) ),
) );
}, 10, 3 );vakanssi/intake/success_url
src/Modules/Intake/SubmissionHandler.phpapply_filters( 'vakanssi/intake/success_url', string $url, int $job_id, int $application_id )- $url
- default: the job's return URL with vakanssi_applied=1 and the #vakanssi-apply fragment
- $application_id
- the new application, or the existing one a resubmission was folded into
The URL an applicant is redirected to after a successful submission. Still goes through wp_safe_redirect(): the filtered URL’s host is added to allowed_redirect_hosts for this one redirect only, so a same-host or cross-host URL (a hosted “thank you” page, for instance) survives.
A URL that still fails wp_safe_redirect()’s own validation (a scheme other than http or https, for instance) falls back to WordPress’s safe-redirect fallback, which is the admin URL unless something filters wp_safe_redirect_fallback. A logged-out applicant sent there lands on the login screen, so always return an absolute http or https URL.
vakanssi/intake/enabled
src/Modules/Intake/ApplicationForm.phpCaptureService.phpapply_filters( 'vakanssi/intake/enabled', bool $enabled, int $post_id )- $enabled
- default: true
Master switch for the native intake form per job. Returning false hides the form on the job page AND closes the write endpoint for that job: CaptureService::job_closed() applies this same filter as its authoritative gate, so a submission cannot bypass it by posting directly. A visitor sees “Online applications for this position open soon.”
It is never consulted for a job with an external apply URL, which the endpoint refuses before this check. The Intake module also answers vakanssi/jobs/has_apply_path from it, so returning false on a job without an external URL marks the job as having no way to apply, which keeps it out of JobPosting structured data.
This gate applies to the public endpoint only. The Add application button on the Applications screen, for logging a candidate who applied by email or was sourced by your own team, does not opt into it, so staff can still record someone against a job with the native form switched off.
vakanssi/intake/require_consent
src/Modules/Intake/ApplicationForm.phpSubmissionHandler.phpapply_filters( 'vakanssi/intake/require_consent', bool $required )- $required
- default: true
Whether the consent checkbox is rendered and enforced. Read separately by the form and by the submit handler, so answer it the same way on both. With it off, the retention note below is not shown either.
vakanssi/intake/consent_label
src/Modules/Intake/ApplicationForm.phpapply_filters( 'vakanssi/intake/consent_label', string $label )Consent checkbox label HTML: the consent sentence plus a Privacy policy link when the site has a privacy policy page. Printed through wp_kses_post(); since v0.37.9, scripts, styles, and other unsafe elements are also stripped first, with their contents.
vakanssi/intake/retention_note
src/Modules/Intake/ApplicationForm.phpapply_filters( 'vakanssi/intake/retention_note', string $note, int $months )The short retention disclosure rendered as small print beneath the submit button, for instance “We keep your application for up to 24 months, then delete it.” $months is the with-consent window from vakanssi/retention/months (the retention_months_consent setting, shipped default 24). Return '' to hide the note.
The filter only runs when the note is enabled (Settings, then General, then Data retention, “Show on the apply form”; off by default) and the consent checkbox is required, so it customizes the wording rather than turning the note on. The note is escaped when printed, so return plain text.
vakanssi/intake/form_fields
src/Modules/Intake/ApplicationForm.phpdo_action( 'vakanssi/intake/form_fields', int $post_id, array $retained )- $retained
- retry values, keyed as POSTed; empty on a fresh view
Fires inside the open <form>, after the applicant fields (cover letter last) and before the consent checkbox and submit button, so extensions can inject fields. Screening Pro feature, requires a Vakanssi Pro license hooks this at priority 10 to render its question set, and the anti-spam challenge widget renders here at priority 20.
Name every field you print with vakanssi/intake/posted_fields below. A field that is not named there is not passed to the spam check and is not kept when the form is shown again after an error.
Since v0.37.9, what you echo here is part of the apply form and is escaped with it using Svg::apply_form() (see the escaping note): standard form markup survives, scripts and styles are removed with their contents.
$retained carries the applicant’s own previously submitted values when the form is re-rendered after a validation failure (see vakanssi/intake/validate below). Honor it if your field can hold a value: the intake form already tells the applicant “your answers have been kept below”, so silently blanking a field you added makes that message false.
vakanssi/intake/data
src/Modules/Intake/SubmissionHandler.phpapply_filters( 'vakanssi/intake/data', array $data, int $job_id )- $data
- sanitized submission data
Since v0.34.1, the sanitized submission data after the built-in checks (spam check, name and email, consent, CV present, job open) and before vakanssi/intake/validate, the CV file check, capture, and vakanssi/application/created.
This is where a module that collects its own fields on the apply form attaches them, so the rest of the pipeline reads one resolved value instead of each consumer reaching back into $_POST. Screening Pro feature, requires a Vakanssi Pro license attaches the applicant’s answers here under screening.
It runs before vakanssi/intake/validate, so a validator can read what was attached, and before the spam counters, so a rejected submission costs no quota. It therefore also runs for submissions that are about to fail: keep callbacks pure and cheap, and write nothing here.
Capture reads named keys only and never iterates the array, so a key you add is not stored by Vakanssi; it simply travels through to the vakanssi/application/created payload. The admin Add application screen calls capture directly and never passes through this filter.
// Attach a referral code your own form field collects (named on posted_fields).
add_filter( 'vakanssi/intake/data', function ( array $data, int $job_id ): array {
// phpcs:ignore WordPress.Security.NonceVerification.Missing
$code = isset( $_POST['acme_referral'] ) ? sanitize_text_field( wp_unslash( $_POST['acme_referral'] ) ) : '';
$data['acme_referral'] = $code;
return $data;
}, 10, 2 );vakanssi/intake/validate
src/Modules/Intake/SubmissionHandler.phpapply_filters( 'vakanssi/intake/validate', string[] $errors, array $data, int $job_id )- $errors
- empty by default
- $data
- sanitized submission data
Extra required-field validation beyond the built-in checks. Screening Pro feature, requires a Vakanssi Pro license hooks this to enforce its required-answer rule. Runs in the same validation phase as the built-in required-field checks, before the spam rate-limit and burst counters increment, so a rejection here does not count against those limits either.
Return one or more error strings to reject the submission. The handler joins them with a space into a single notice and redirects back to the form, the same way a built-in required-field failure does. An empty array (the default) means no extra failures.
// Require a minimum cover letter length.
add_filter( 'vakanssi/intake/validate', function ( array $errors, array $data, int $job_id ): array {
$cover_letter = isset( $data['cover_letter'] ) ? (string) $data['cover_letter'] : '';
if ( strlen( trim( $cover_letter ) ) < 50 ) {
$errors[] = 'Please write at least a few sentences in your cover letter.';
}
return $errors;
}, 10, 3 );vakanssi/intake/posted_fields
src/Modules/Intake/SubmissionHandler.phpapply_filters( 'vakanssi/intake/posted_fields', string[] $names, int $job_id )- $names
- request field names
The request field names the apply handler reads. Only these are passed to vakanssi/intake/spam_check and kept for the retry after a validation error, or after a challenge the applicant did not complete or that could not be checked; any other posted field is left out of both. Defaults to the built-in applicant fields (first_name, last_name, email, phone, linkedin_url, cover_letter, applicant_country, consent) and the honeypot and time-trap fields.
The retry never keeps the anti-spam fields themselves: the honeypot, the time-trap token, and the active challenge’s token field, as its request_field() method names it, since a credential is not something the applicant typed.
The active anti-spam challenge adds its token field, and Screening Pro feature, requires a Vakanssi Pro license adds screening. The handler also reads job_id, its nonce, and the compatibility-test marker by name, the referer through wp_get_referer(), and the CV through $_FILES. Return the array with your names appended.
// Keep a custom apply-form field across a validation error.
add_filter( 'vakanssi/intake/posted_fields', function ( array $names, int $job_id ): array {
$names[] = 'acme_referral';
return $names;
}, 10, 2 );vakanssi/intake/require_apply_nonce
src/Modules/Intake/SubmissionHandler.phpapply_filters( 'vakanssi/intake/require_apply_nonce', bool $require, int $job_id )- $require
- default: is_user_logged_in()
Whether a bad or missing apply nonce hard-fails the submission. Anonymous submitters default to false, so a stale nonce (a cached job page, for example) falls through to the time-trap, honeypot, rate limit, and CAPTCHA instead. Logged-in submitters default to true. Return true to make the nonce mandatory for everyone, which rejects submissions from cached job pages whose nonce has expired.
vakanssi/intake/spam_check
src/Modules/Intake/SpamGuard.phpapply_filters( 'vakanssi/intake/spam_check', bool|WP_Error $result, array $request )- $result
- default: true (clean)
- $request
- only the fields named by vakanssi/intake/posted_fields
The spam decision, after the honeypot, time-trap, and rate-limit checks have passed. Return a WP_Error to reject with its message, or false to reject with the generic spam message; a later callback returning true overturns either. The built-in anti-spam challenge runs at priority 10 and passes an earlier rejection through unchanged.
A refusal whose only error codes are vakanssi_captcha_missing (no challenge token posted) or vakanssi_captcha_unavailable (the challenge could not be checked) keeps the applicant’s typing and does not count against vakanssi/intake/attempt_limit. Any other refusal, a rejected token included, clears the form and counts.
$request holds only the sanitized fields named by vakanssi/intake/posted_fields; name any field your check reads there.
// Reject disposable email domains at submit time.
add_filter( 'vakanssi/intake/spam_check', function ( $result, array $request ) {
if ( true !== $result ) {
return $result; // An earlier check already rejected it.
}
$email = isset( $request['email'] ) ? (string) $request['email'] : '';
$domain = strtolower( (string) substr( strrchr( $email, '@' ), 1 ) );
if ( in_array( $domain, array( 'mailinator.com', 'trashmail.com' ), true ) ) {
return new WP_Error( 'acme_disposable', 'Please apply with a personal or work email address.' );
}
return $result;
}, 10, 2 );vakanssi/intake/spam_providers
src/Modules/Intake/Spam/SpamProviderResolver.phpapply_filters( 'vakanssi/intake/spam_providers', array $providers )- $providers
- id => SpamProvider
The registered anti-spam challenge providers, id => SpamProvider. The free plugin registers none and turnstile; Advanced anti-spam Pro feature, requires a Vakanssi Pro license appends hcaptcha and recaptcha_v3, and your own adapter can append another. A non-array return falls back to the defaults.
A provider’s render_field() markup is printed through wp_kses(): a placeholder element with class and data-* attributes survives, a <script> does not (enqueue it instead). verify() receives only the fields named by vakanssi/intake/posted_fields, so a provider whose widget posts a token names that field with a public request_field() method or on that filter. Without either, the token never reaches verify() and a provider that rejects a missing token refuses every applicant.
Every provider method is called inside a guard. One that throws, or answers the wrong type, is logged to the PHP error log once per class and method, never with the exception message. (is_configured() is the exception: its answer is cast to a boolean, so only a throw counts as a fault there.)
When verify() fails that way, id() throws or answers an empty id, or is_configured() throws while someone applies, the application gets the vakanssi/antispam/fail_open answer below instead of an error page; a failing render_field() just leaves the widget off the job page.
vakanssi/antispam/fail_open
src/Modules/Intake/Spam/AbstractChallengeProvider.phpSpamProviderResolver.phpapply_filters( 'vakanssi/antispam/fail_open', bool $fail_open, WP_Error $error )- $fail_open
- default: the fail-open anti-spam setting, on unless you turn it off
Whether a challenge that could not be checked is treated as a pass, so a provider outage never blocks a real applicant. It covers a siteverify transport failure (network error, timeout, malformed response), and a fault in the active provider: verify() throwing or answering the wrong type, id() throwing or answering an empty id, or is_configured() throwing.
$error is the transport WP_Error in the first case and a generic vakanssi_captcha_unavailable error in the second; the provider’s own message is never passed on, because it can carry its secret key or the applicant’s token. A callback that throws is logged and the submission is refused. The honeypot, time-trap, and rate limit have already run either way.
That vakanssi_captcha_unavailable refusal keeps the applicant’s typed answers and costs no attempt against vakanssi/intake/attempt_limit (see vakanssi/intake/spam_check above).
vakanssi/intake/min_seconds
src/Modules/Intake/SpamGuard.phpapply_filters( 'vakanssi/intake/min_seconds', int $seconds )- $seconds
- default: 3
Time-trap minimum submit time. Faster submissions are rejected.
vakanssi/intake/max_seconds
src/Modules/Intake/SpamGuard.phpapply_filters( 'vakanssi/intake/max_seconds', int $seconds )- $seconds
- default: 2 * DAY_IN_SECONDS
Time-trap maximum token age. Also the freshness window for the cache-proof fallback that vakanssi/intake/require_apply_nonce enables: a submission older than this is rejected even with a valid time-trap signature.
vakanssi/intake/rate_limit
src/Modules/Intake/SpamGuard.phpapply_filters( 'vakanssi/intake/rate_limit', int $limit )- $limit
- default: 5
How many applications one IP may submit inside the rate window before being refused.
vakanssi/intake/rate_window
src/Modules/Intake/SpamGuard.phpapply_filters( 'vakanssi/intake/rate_window', int $seconds )- $seconds
- default: HOUR_IN_SECONDS
The rate-limit window in seconds. The attempt limit, the burst limit, and the retry-storage cap below are all counted over this same window.
vakanssi/intake/attempt_limit
src/Modules/Intake/SpamGuard.phpapply_filters( 'vakanssi/intake/attempt_limit', int $limit )- $limit
- default: 20
How many counted hits one IP may make inside the rate window before every further request is refused. Counted: a hard nonce failure, a spam refusal (honeypot, time-trap, rate limit, a rejected challenge token, a third-party refusal), and every submission that passes validation. Deliberately more generous than vakanssi/intake/rate_limit.
An ordinary validation failure, a missed or unavailable anti-spam challenge included, does not count against it, so a fumbling applicant is never locked out. 0 or less disables it.
vakanssi/intake/burst_limit
src/Modules/Intake/SpamGuard.phpapply_filters( 'vakanssi/intake/burst_limit', int $limit )- $limit
- default: 100
How many successful submissions the whole site accepts inside the rate window, a backstop against distributed floods that rotate IPs. 0 or less disables it.
vakanssi/intake/download_rate_limit
src/Modules/Intake/DownloadEndpoint.phpapply_filters( 'vakanssi/intake/download_rate_limit', int $value )- $value
- default: 30
Max CV downloads per logged-in user before one more returns HTTP 429. Each allowed download restarts a one-minute timer, so the count resets only after a full minute without downloads, not once per clock minute. Keyed by user id, not IP; only requests that pass the nonce and permission checks count.
vakanssi/intake/block_expired_jobs
src/Modules/Intake/CaptureService.phpapply_filters( 'vakanssi/intake/block_expired_jobs', bool $block, int $job_id )- $block
- default: true
Whether a submission to a job past its closing date is rejected with vakanssi_job_closed. Return false to let expired jobs keep accepting applications server-side. Checked during validation (before the abuse counters increment) and again inside capture, so the answer is the same on both paths. The admin Add application screen is subject to it too.
vakanssi/intake/block_duplicate_active_application
src/Modules/Intake/CaptureService.phpapply_filters( 'vakanssi/intake/block_duplicate_active_application', bool $block, int $candidate_id, int $job_id )- $block
- default: true
Whether a resubmission to a job the candidate already has an ACTIVE application for (a double-click, a resubmit) is collapsed into that existing application instead of creating a second one. A prior application that already reached a terminal status (rejected, withdrawn, hired) or was anonymized by retention is not a match, a fresh application is created as usual.
Shared by the public apply form and the admin “Add application” screen. Return false to restore the old always-create-a-new-row behavior. On the public form, a CV attached to the collapsed resubmission is kept (see vakanssi/intake/max_files_per_application below) rather than discarded; the admin “Add application” screen does not opt into that, since a member of staff is present to resolve the duplicate directly.
vakanssi/intake/max_files_per_application
src/Modules/Intake/CaptureService.phpapply_filters( 'vakanssi/intake/max_files_per_application', int $max, int $application_id )- $max
- default: 10
Maximum CVs one application may accumulate from repeated public applications with a corrected file. A byte-identical resubmission is always recognized by content hash and ignored regardless of this cap; only once past the cap is a genuinely new file discarded. 0 or less means no cap.
apply_filters( 'vakanssi/intake/retry_storage_limit', int $limit )- $limit
- default: 20
Max retry-payload transients (the prefilled-form state shown after a rejected submission) stored per IP within the rate-limit window. A separate budget from vakanssi/intake/attempt_limit (both default to 20), so validation failures never burn that one. Once exceeded, the applicant still sees the rejection message, just without the fields pre-filled on retry.
vakanssi/retention/months
src/Modules/Intake/CaptureService.phpApplicationForm.phpsrc/Modules/Import/Sources/AbstractSource.phpsrc/Modules/Import/Sources/WpJobOpeningsSource.phpsrc/Modules/Sourcing/TalentPoolService.phpsrc/Modules/Privacy/RetentionService.phpapply_filters( 'vakanssi/retention/months', int $months, bool $consent )Retention period for the stored application. The value passed in is the admin-configured Data retention setting (Settings → General; retention_months_consent/retention_months_no_consent, shipped defaults 24 with consent and 6 without), which a filter callback can still override.
Every non-Intake call site passes false (the no-consent window), except RetentionService::repair_missing_deadlines(), the one-time upgrade repair for a legacy application with no retention deadline at all, which passes that row’s own consent_given flag so a legacy row that actually recorded consent is repaired onto the longer consent window rather than the shorter one. Everywhere a deadline is stored, the value is cast to int and floored at 1.
It is also applied, with $consent set to true, when the apply form renders the optional retention note (see vakanssi/intake/retention_note). There a value below 1 hides the note instead of being floored.
vakanssi/candidate/email_identity
src/Support/EmailIdentity.phpapply_filters( 'vakanssi/candidate/email_identity', string $normalized, string $email )Normalized email used for candidate dedup (lower-cased, +tag stripped, Gmail dots removed). Backs the unique key.
vakanssi/storage/base_dir
src/Security/ProtectedStorage.phpapply_filters( 'vakanssi/storage/base_dir', string $dir )Absolute path of the protected CV storage directory: uploads/vakanssi by default, or the VAKANSSI_STORAGE_DIR constant when one is defined. On multisite this is the per-site directory (the storage root plus a site-<id> segment), so a callback returning one fixed path makes every site of the network share one CV directory: include the blog id if you override it there.
vakanssi/storage/allowed_mimes
src/Security/ProtectedStorage.phpapply_filters( 'vakanssi/storage/allowed_mimes', array $mimes )- $mimes
- ext => MIME
Accepted CV upload types. Default: the types enabled in the Accepted CV file types setting, a subset of pdf, doc, and docx (all three when unset). This is what the upload path enforces; the form’s accept attribute is only a hint. A type added here has no built-in file-signature check, so it relies on WordPress’s own wp_check_filetype_and_ext() alone.
The same filter is also applied to the full pdf/doc/docx map to decide which Content-Type a stored file is served with. A file whose type is not in that list still downloads, as application/octet-stream. Unticking a built-in type in the setting therefore never changes how files already received are served.
vakanssi/storage/validate_file
src/Security/ProtectedStorage.phpapply_filters( 'vakanssi/storage/validate_file', bool|WP_Error $verdict, string $tmp_path, string $original_name, string $mime )- $verdict
- default: true
Whether an uploaded CV is accepted, checked after every built-in check (mime allowlist, size cap, magic-byte signature) already passed.
VETO-ONLY: return false or a WP_Error to reject a file the built-in checks would have accepted (virus scanning, content DLP); it can never accept a file a built-in check already rejected, because it never runs for one. A WP_Error’s message is shown to the applicant as the upload error; a plain false gets a generic rejection message.
On the public apply form it runs twice for the same file: once in the validation phase and again when the file is stored. A slow scanner should cache its verdict per $tmp_path rather than scan twice.
// Reject a CV that fails a virus scan, on top of the built-in checks.
add_filter( 'vakanssi/storage/validate_file', function ( $verdict, string $tmp_path, string $original_name, string $mime ) {
if ( is_wp_error( $verdict ) || ! $verdict ) {
return $verdict; // An earlier check already rejected it.
}
if ( ! acme_clamav_scan( $tmp_path ) ) {
return new WP_Error( 'acme_virus', 'This file failed a security scan.' );
}
return true;
}, 10, 4 );vakanssi/storage/max_upload_size
src/Security/ProtectedStorage.phpsrc/Modules/Import/Sources/AbstractSource.phpapply_filters( 'vakanssi/storage/max_upload_size', int $bytes )Max CV upload size in bytes. On the apply form the default is the Maximum CV file size setting (5 MB unless changed), clamped to the server’s own upload_max_filesize and post_max_size. The competitor importer applies the same filter with wp_max_upload_size() as its default when it copies CVs in.
vakanssi/cv/stored
src/Modules/Intake/CaptureService.phpsrc/Modules/Pipeline/ApplicationService.phpdo_action( 'vakanssi/cv/stored', string $stored_path, int $application_id, array $meta )- $stored_path
- resolve with ProtectedStorage::resolve_path()
- $meta
- filename, mime, size
Fires once per file, after it is durably stored AND its owning application exists. It covers an applicant’s initial CV (including one logged through Add application), a CV kept from a re-application, and a file staff attach to an existing application. CVs copied in by the competitor importer do not fire it.
The intended use is virus scanning or content DLP that needs to act on bytes already linked to a record; the veto-only vakanssi/storage/validate_file filter above runs earlier, before the bytes are written.
Not legal advice
Retention periods carry legal obligations that vary by jurisdiction. These hooks describe software behavior only. Consult counsel before changing how long you keep candidate data. See Privacy.
Screening Pro feature, requires a Vakanssi Pro license
The Screening Questions module (feature screening): custom per-job application questions with an optional auto-filter (knockout). See Screening questions and Free vs Pro.
vakanssi/screening/questions
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Screening/JobQuestions.phpapply_filters( 'vakanssi/screening/questions', array $questions, int $job_id )The resolved screening question set for a job, at the single point every consumer reads from: the apply form’s render, submission validation, answer storage, and the knockout evaluator, so a question added or removed here applies consistently across all of them.
Not applied to the wp-admin question editor, which reads the unfiltered stored set, so a filter-injected question can never be re-saved into post meta by an admin unaware it came from a filter.
vakanssi/screening/answers_saved
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Screening/AnswerStore.phpdo_action( 'vakanssi/screening/answers_saved', int $application_id, array $answers, int $job_id )- $answers
- question id => answer (string, or string[] for multi_choice)
Fires after a captured application’s screening answers are saved.
vakanssi/screening/knockout
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Screening/KnockoutHandler.phpdo_action( 'vakanssi/screening/knockout', int $application_id, int $job_id, array $failed )- $failed
- the disqualifying question/answer pairs
Fires after a submission is auto-rejected, moved to a lost-type pipeline stage, by a disqualifying screening answer. Only fires when auto-move is enabled and the move actually landed the application in a lost-type stage; a knockout that only sets the flag (auto-move is off by default) is recorded in the audit log only, with no candidate-facing consequence to report here.
Import
The free Import module copies jobs and applications in from other job plugins, from the setup wizard or from Hiring, then Tools, then Import. See Import. Sources are rebuilt on every call, so a source you add is available immediately.
apply_filters( 'vakanssi/import/sources', SourceInterface[] $sources )The registered import sources (WP Job Openings, Simple Job Board, WP Job Manager by default). Every member must implement the undocumented SourceInterface, and anything else is dropped, as is a second source with a key already taken.
apply_filters( 'vakanssi/import/unsupported', array $found )Detected but unsupported job-data sources reported to the user, each {post_type, label, jobs} in the detector’s own shape.
vakanssi/import/allowed_cv_extensions
src/Modules/Import/Sources/AbstractSource.phpapply_filters( 'vakanssi/import/allowed_cv_extensions', string[] $extensions )- $extensions
- lower-case, no dots
File extensions the importer will copy into protected CV storage. Default: pdf, doc, docx, rtf, odt, txt. This allowlist and the vakanssi/storage/max_upload_size cap are the deterministic controls; the content check behind them is best effort, and an extension WordPress cannot map to a MIME type is copied anyway and stored as application/octet-stream.
vakanssi/import/row
src/Modules/Import/Sources/AbstractSource.phpsrc/Modules/Import/Sources/WpJobOpeningsSource.phpapply_filters( 'vakanssi/import/row', array $row, string $source_key, string $type )- $row
- wp_insert_post() args for jobs, or the application row array for applications
- $source_key
- e.g. wpjo
- $type
- jobs or applications
A mapped row immediately before a competitor import inserts it, applied once per row across every import source. Returning null or false skips the row: the job or application row is not inserted, and it counts in the run’s skipped tally. For applications, the candidate record has already been created or merged by the time the filter runs, and it is kept.
This runs AFTER the built-in kses/sanitize step, so a callback owns sanitizing any value it adds or changes; any other non-array return is ignored and the original row is used unchanged, rather than risking a fatal on insert.
// Skip importing jobs from a department this site does not hire for.
add_filter( 'vakanssi/import/row', function ( $row, string $source_key, string $type ) {
if ( 'jobs' === $type && str_contains( strtolower( (string) ( $row['post_title'] ?? '' ) ), 'intern' ) ) {
return null; // Skip internships.
}
return $row;
}, 10, 3 );vakanssi/import/completed
src/Modules/Import/ImportRunner.phpdo_action( 'vakanssi/import/completed', string $source_key, array $summary )- $summary
- the runner's result/counts structure
Fires once, on the single batch call whose summary first reports the run as finished (both the jobs and applications phases reached the end of the source data), not on every intermediate batch.
apply_filters( 'vakanssi/import/map_persisted', bool $persisted, string $source_key )- $persisted
- whether every touched id-map shard was saved
Test-only: lets an integration test force the import id-map write to report failure, so the rollback path can be exercised.
CSV Import
The free CSV Import module (a separate module from Import above, needing only Jobs & Careers): the Import jobs (CSV) card on Hiring, then Tools, then Import, which bulk-creates job listings from an uploaded spreadsheet. See Import jobs from a CSV. All four of its hooks are internal tuning knobs.
apply_filters( 'vakanssi/csv_import/max_bytes', int $bytes )- $bytes
- default: 5 * MB_IN_BYTES
Max size of the uploaded CSV file itself (not a CV; the spreadsheet).
apply_filters( 'vakanssi/csv_import/max_rows', int $rows )- $rows
- default: 2000
Data rows read from the file before it is truncated.
apply_filters( 'vakanssi/csv_import/batch_size', int $size )- $size
- default: 25
Rows processed (created or skipped) per auto-continuing batch while the import runs.
apply_filters( 'vakanssi/csv_import/mapping_ttl', int $ttl )- $ttl
- seconds, default: DAY_IN_SECONDS
How long the uploaded file and column mapping are kept while idle, so how long you can leave the mapping screen before you have to upload again. Floored at one minute.
Pipeline & applications
Hooks that fire as applications move through your stages on the Applications screen, plus the authorization seams and the drawer REST payload. Applications and the pipeline are free.
vakanssi/application/stage_changed
src/Modules/Pipeline/PipelineService.phpdo_action( 'vakanssi/application/stage_changed', int $application_id, int $stage_id, int $from, string $status )- $stage_id
- new
- $from
- previous, accurate even after an internal retry
An application’s stage actually changed (after the DB update and audit). $status is the derived status: rejected for a lost-type stage, hired for a won-type stage, otherwise active, except that a withdrawn application moved between open stages stays withdrawn. Fires exactly once per real move, from the request that performed it.
It does not fire for a no-op move (a move to the stage the application is already in, when the derived status also already matches; dropping a card back on its own column is the usual case) or for a request that loses a concurrent move race to another request performing the identical change, since the one real change already fired it.
// Push new hires to the HR system the moment they reach the Hired stage.
add_action( 'vakanssi/application/stage_changed', function ( int $application_id, int $stage_id, int $from, string $status ) {
if ( 'hired' === $status ) {
wp_remote_post( 'https://hris.example.com/api/hires', array(
'body' => array( 'application_id' => $application_id ),
) );
}
}, 10, 4 );vakanssi/application/updated
src/Modules/Pipeline/ApplicationDetailsService.phpsrc/Modules/Pipeline/ApplicationService.phpdo_action( 'vakanssi/application/updated', int $application_id, array $changed )- $changed
- changed field names, no old/new values
Fires after the rating is set, an application is withdrawn or reopened, or its details are edited in the drawer. A rating save sent without the expected_rating precondition fires it even when the rating did not change.
It is not a catch-all for every write: a stage move fires vakanssi/application/stage_changed above instead, assignment Pro feature, requires a Vakanssi Pro license fires vakanssi/application/assigned, and changing a stage’s type rewrites its applications’ status without firing either. Never fires for creation or deletion.
vakanssi/application/deleted
src/Modules/Pipeline/ApplicationService.phpdo_action( 'vakanssi/application/deleted', int $application_id, array $application )A permanent cascade delete completed (CV files, meta, linked rows, audit written).
vakanssi/application/viewed
src/Modules/Pipeline/ViewTracker.phpdo_action( 'vakanssi/application/viewed', int $application_id )The first time an application is viewed (drawer opened). At most once per application in normal operation.
vakanssi/authz/can
src/Security/AuthorizationPolicy.phpapply_filters( 'vakanssi/authz/can', bool $allowed, string $action, mixed $object, int|WP_User|null $user, string|null $capability )Object-level authorization decision. Return false to deny even when the capability is held (the per-application scoping seam).
vakanssi/authz/action_map
src/Security/AuthorizationPolicy.phpapply_filters( 'vakanssi/authz/action_map', array<string,string> $map )Semantic-action to capability map. The map is cached per policy instance: the shared instance reads it at the first authorization check, and a few code paths build their own. Add the filter on plugins_loaded or init, before any authorization check runs; a filter added later reaches some surfaces and not others. See Capabilities.
vakanssi/authz/application_scope
src/Security/AuthorizationPolicy.phpapply_filters( 'vakanssi/authz/application_scope', array{clauses:string[], params:mixed[]} $scope, int $user )Extra SQL WHERE fragments that scope application collections: the inbox, the person list, the CSV export, the REST list, the Hiring HQ counts and cards, the Jobs list’s Applications column, and the Hiring menu’s unviewed count, plus the Analytics Pro feature, requires a Vakanssi Pro license and Presence Pro feature, requires a Vakanssi Pro license readers. Fragments reference the a. alias.
Premium build only: the wordpress.org build ships no SQL taken from a filter, so a callback added here never runs there, and those collections stay whole-org.
The contract: every fragment must reference the applications table through the a. alias, and every placeholder (%s, %d, %i) must have a matching entry in params, in order.
// Hiring managers only see applications for jobs they authored.
add_filter( 'vakanssi/authz/application_scope', function ( array $scope, int $user ): array {
if ( user_can( $user, 'manage_options' ) ) {
return $scope; // Administrators keep full visibility.
}
global $wpdb;
$scope['clauses'][] = "a.job_id IN ( SELECT ID FROM {$wpdb->posts} WHERE post_author = %d )";
$scope['params'][] = $user;
return $scope;
}, 10, 2 );vakanssi/authz/candidate_scope
src/Security/AuthorizationPolicy.phpapply_filters( 'vakanssi/authz/candidate_scope', array{clauses:string[], params:mixed[]} $scope, int $user )Extra SQL WHERE fragments that scope candidate collections: the Candidates Pro feature, requires a Vakanssi Pro license list and its source and tag filters, the duplicates screen, tag suggestions, and the candidates CSV export. Bulk actions are checked per id through vakanssi/authz/can instead.
Fragments reference the candidates table through the c. alias, with the same placeholder contract as vakanssi/authz/application_scope above. Premium build only: the free build does not fire it, and only the Pro build has a candidate list to call it.
The candidate-level companion to vakanssi/authz/application_scope, which scopes application collections only. A site adding per-recruiter scoping must answer both, or the board narrows while the candidate list stays org-wide. Pro Collaboration answers it when assignment scoping is on.
vakanssi/authz/visible_applications
src/Security/AuthorizationPolicy.phpapply_filters( 'vakanssi/authz/visible_applications', int[] $application_ids, int $user )Narrows a set of application ids to the ones a user may see by scope alone, independent of the view-applications capability. Used where the ids are already in hand and no SQL scope applies: the candidate profile’s application history and the Presence payload, both Pro feature, requires a Vakanssi Pro license. Order is preserved and the result is re-indexed. Premium build only, like the two scope filters above.
The third member of the scoping trio with vakanssi/authz/application_scope and vakanssi/authz/candidate_scope: a site adding custom scoping answers all three, or the profile shows applications the board hides.
vakanssi/rest/application_payload
src/Rest/Routes/ApplicationsController.phpapply_filters( 'vakanssi/rest/application_payload', array $payload, int $id, WP_REST_Request $request )The single-application drawer payload. Add keys for custom drawer panels. application.job_title, each related row’s job_title, and the Interviews module’s interviews[] title, location, notes, interviewers, and interviewer_options[].name all arrive as plain text, not HTML entities.
vakanssi/rest/applications_payload
src/Rest/Routes/ApplicationsController.phpapply_filters( 'vakanssi/rest/applications_payload', array $items, WP_REST_Request $request )- $items
- one page of shaped application rows
Since v0.33.43, the application collection payload: one page of pipeline board cards, already shaped. The plural twin of vakanssi/rest/application_payload. The whole page arrives at once, so a callback that needs a per-application lookup can batch it into one query instead of one per card. Screening Pro feature, requires a Vakanssi Pro license adds its screening_knockout flag here. Each row’s job_title arrives as plain text, not HTML entities.
Return the same list, with the same ids, in the same order. A field you add here is what vakanssi.pipeline.card_markers (under JavaScript hooks below) sees on each card.
vakanssi/pipeline/list_row_markers
src/Modules/Pipeline/CandidatesListTable.phpapply_filters( 'vakanssi/pipeline/list_row_markers', string[] $markers, array $item, array $items )- $markers
- escaped HTML, empty by default
- $item
- the row being rendered
- $items
- every row on this page
Since v0.33.43, quiet markers appended after a candidate’s name in the Applications List view, beside the built-in Withdrawn and repeat-applicant markers. For application-level flags a recruiter must see while triaging, without opening each application; the board-card equivalent is the JavaScript filter vakanssi.pipeline.card_markers.
Return already-escaped HTML strings. Each marker is printed through wp_kses(), which keeps only span (class, title, aria-label, aria-hidden), abbr (title), and strong, em, small (class); anything else is stripped. Keep each marker to a word or two. $items is the whole page, so a per-application lookup can be primed in one query on the first row.
// Mark applications that came in through a referral.
add_filter( 'vakanssi/pipeline/list_row_markers', function ( array $markers, array $item, array $items ): array {
if ( 'referral' === ( $item['source'] ?? '' ) ) {
$markers[] = '<span class="acme-referral">' . esc_html__( 'Referral', 'acme' ) . '</span>';
}
return $markers;
}, 10, 3 );vakanssi/sheet/sections
src/Modules/Pipeline/ApplicationSheet.phpdo_action( 'vakanssi/sheet/sections', int $application_id, array $meta )Extra sections for the printable/PDF application sheet, between the cover letter and the CV line. Echo already-escaped HTML. Exists so a module the free build strips (Screening) can add to the sheet without this free/core file naming a class that would fatal a free install; the Screening Pro feature, requires a Vakanssi Pro license module hooks in here to print the candidate’s screening answers and knockout flag.
// Add a custom section to the printable application sheet.
add_action( 'vakanssi/sheet/sections', function ( int $application_id, array $meta ) {
echo '<section><h2>Internal reference</h2><p>App #' . esc_html( $application_id ) . '</p></section>';
}, 10, 2 );vakanssi/pipeline/candidate_context
src/Modules/Pipeline/CandidateContextService.phpapply_filters( 'vakanssi/pipeline/candidate_context', array $result, int $application_id, int $candidate_id )The cross-application “Candidate history” bridge ({related, summary}) in the drawer payload, where it arrives as related and related_summary. Only those two keys are passed on, so change their contents rather than adding new keys. Not called for an application with no candidate record. (The board card’s repeat-applicant hint is counted separately in SQL and does not read this filter.) Each related row’s job_title arrives as plain text, not HTML entities.
vakanssi/candidate/updated
src/Modules/Pipeline/CandidateContactService.phpdo_action( 'vakanssi/candidate/updated', int $candidate_id, string[] $changed )- $changed
- changed field keys, no old/new values
Fires after a candidate’s person-level fields (contact details and/or enrichment) are updated, from either the pipeline drawer or the Candidates profile Pro feature, requires a Vakanssi Pro license, the single write path both surfaces go through.
From the drawer it fires before the whole edit commits, so a later failure in the same save can roll back a change a listener has already seen. Not fired on merge (see vakanssi/candidate/merged under Collaboration & sourcing below) or on candidate creation.
vakanssi/pipeline/bulk_actions
src/Modules/Pipeline/CandidatesListTable.phpapply_filters( 'vakanssi/pipeline/bulk_actions', array<string,string> $actions, int $job )- $actions
- key => label
- $job
- current job filter, 0 = all
Extend the Applications list’s bulk-action dropdown, after the built-in “Move to: …” and “Delete permanently” entries have been added for the capabilities the current user actually holds. Pro Collaboration adds “Assign to: …” and “Unassign” here.
A key added here needs a matching handler on vakanssi/pipeline/bulk_result; an unclaimed key does nothing and redirects back with no result. Labels are plain text: Vakanssi escapes them with esc_html() before WordPress prints them, so do not pre-escape them or pass HTML.
// Add a custom "Notify hiring lead" bulk action to the Applications list.
add_filter( 'vakanssi/pipeline/bulk_actions', function ( array $actions ): array {
$actions['acme_notify_lead'] = 'Notify hiring lead';
return $actions;
} );
add_filter( 'vakanssi/pipeline/bulk_result', function ( $result, string $action, array $ids, int $user ) {
if ( null !== $result || 'acme_notify_lead' !== $action ) {
return $result; // Not ours to handle.
}
wp_mail( 'lead@example.com', 'Applications need a look', sprintf( '%d application(s): %s', count( $ids ), implode( ', ', $ids ) ) );
return array( 'acme_notified' => count( $ids ) );
}, 10, 4 );vakanssi/pipeline/bulk_result
src/Modules/Pipeline/InboxPage.phpapply_filters( 'vakanssi/pipeline/bulk_result', array<string,int>|null $result, string $action, int[] $ids, int $user )Handle a custom bulk action registered via vakanssi/pipeline/bulk_actions. Runs after the bulk-vakanssi-applications nonce check and after the built-in move and delete branches, each of which redirects and exits, so it only sees keys core does not own. Return an array of result args to claim the action (they become query args on the redirect back to the list), or null to pass.
The handler owns its own capability and per-object scope checks: $ids has only been run through absint() and emptied of zeroes, never filtered by the authorization policy.
vakanssi/pipeline/result_notice
src/Modules/Pipeline/InboxPage.phpdo_action( 'vakanssi/pipeline/result_notice' )Fires on the Applications list screen after the built-in bulk-result notices have printed, so a handler that claimed an action via vakanssi/pipeline/bulk_result can read back its own result query arg and print its own notice. Echo already-escaped HTML.
apply_filters( 'vakanssi/pipeline/stage_emails_on_entry', bool $emails, int $stage_id )- $emails
- default: false
Cross-build seam: whether entering an open stage emails the candidate by itself, so the free Applications screen can warn before a bulk move into it, the way it already warns for won and lost stages. Answered by Email Automation Pro feature, requires a Vakanssi Pro license, which the free screen cannot name.
vakanssi/evaluation/saved
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Scorecards/EvaluationsController.phpdo_action( 'vakanssi/evaluation/saved', int $application_id, int $evaluator_id, int $interview_id )- $evaluator_id
- the reviewer, the current user
- $interview_id
- the interview the scorecard belongs to, 0 when it is not tied to one
A reviewer saved a scorecard (Scorecards module). Fires on every save, including one that changed nothing, although only a save that changed something is written to the Activity log.
Since v0.34.0, a scorecard belongs to the interview it came out of, and $interview_id says which. A callback registered for two arguments keeps working unchanged.
// Note which interview round a scorecard came from.
add_action( 'vakanssi/evaluation/saved', function ( int $application_id, int $evaluator_id, int $interview_id ) {
error_log( sprintf( 'Scorecard by user %d on application %d, interview %d', $evaluator_id, $application_id, $interview_id ) );
}, 10, 3 );vakanssi/export/applications_max_rows
src/Modules/Pipeline/ApplicationsExport.phpapply_filters( 'vakanssi/export/applications_max_rows', int $max_rows )- $max_rows
- default: 50000
Row ceiling for the applications CSV export. Return 0 or a negative number to export every matching row instead of stopping at the cap. When the export does stop at the cap, the file’s last row is a TRUNCATED marker rather than an ordinary record, so a partial export is never mistaken for a complete one.
Notes
The free Notes module: private notes on a candidate, with optional @mentions that stay inert until the Collaboration Pro module validates them.
vakanssi/notes/resolve_mentions
src/Modules/Notes/NotesService.phpapply_filters( 'vakanssi/notes/resolve_mentions', int[] $resolved, array $raw_mentions, int $application_id )Resolve raw client @mention ids to a validated id list before a note is saved. Free returns []; the Collaboration Pro module validates ids against the teammates who may view that application (the whole team, unless assignment scoping is on, in which case admins/HR and the assignee, or the whole team while the application is unassigned).
On an edit, a registered resolver is also called once with the note’s stored ids, so mentions the editor could not see are kept.
vakanssi/note/added
src/Modules/Notes/NotesService.phpdo_action( 'vakanssi/note/added', int $note_id, int $application_id, int[] $mentions, string $body )A private note was saved. Pro modules listen to notify @mentioned teammates.
vakanssi/note/updated
src/Modules/Notes/NotesService.phpdo_action( 'vakanssi/note/updated', int $note_id, int $application_id, int[] $mentions, string $body, int[] $added )- $mentions
- the stored list after the edit; unchanged when the request sent no mentions
- $added
- ids in $mentions that were not in the note before this edit
A note’s text or @mentions were edited by its author. Fires once per successful save, never on a rejected edit (a stale-copy conflict, a forbidden or empty edit, a failed write). Pro Collaboration listens here to email only $added, so re-saving a note never re-notifies a teammate already on it; webhook deliveries do not forward edits.
Interviews & scheduling
The free Interviews module schedules interviews at a fixed time; the Scheduling Pro feature, requires a Vakanssi Pro license module adds candidate self-booking and the public booking page. Editing an interview’s other details (interviewers, mode, location, title, duration, notes) updates it without firing a hook, re-sending invites only when the interview is already scheduled; only a change to the time fires vakanssi/interview/rescheduled below.
vakanssi/interview/scheduled
src/Modules/Interviews/InterviewService.phpsrc/Modules/Scheduling/SelfBookingService.phpdo_action( 'vakanssi/interview/scheduled', int $interview_id, array $interview )An interview was scheduled at a fixed time (free Interviews), or proposed as a set of slots for the candidate to pick from (Scheduling Pro feature, requires a Vakanssi Pro license), after the invites or booking-link email and the audit entry. When the candidate later picks a slot, vakanssi/interview/booked fires instead.
vakanssi/interview/rescheduled
src/Modules/Interviews/InterviewService.phpdo_action( 'vakanssi/interview/rescheduled', int $interview_id, string $old_datetime, string $new_datetime )- $old_datetime
- GMT mysql datetime, '' if the interview had none
- $new_datetime
- GMT mysql datetime
Fires after a scheduled interview’s time is changed, after the new time is persisted and the calendar invite is re-sent.
vakanssi/interview/canceled
src/Modules/Interviews/InterviewService.phpdo_action( 'vakanssi/interview/canceled', int $interview_id, array $interview )An interview was canceled. Any interview that had a time (scheduled, or completed) sends attendees a CANCEL calendar update; a proposed one (Scheduling Pro feature, requires a Vakanssi Pro license), which has no calendar entry, emails the candidate that the proposed times are withdrawn.
vakanssi/interview/completed
src/Modules/Interviews/InterviewService.phpdo_action( 'vakanssi/interview/completed', int $interview_id, array $interview )A scheduled interview was marked completed. No calendar message is sent; this is an internal state change only. Fires once per real transition, after the guarded status update and the audit entry; a request that loses the race to another marking the same interview completed gets a 409 and does not fire it. $interview['status'] is already completed.
vakanssi/interview/booked
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Scheduling/SelfBookingService.phpdo_action( 'vakanssi/interview/booked', int $interview_id, array $interview )A candidate self-booked a proposed slot on the public booking page (Scheduling module), after the calendar invite went out (plus a separate booking notice to the organizer, unless the organizer is also an invited attendee). $interview['status'] is scheduled.
vakanssi/booking/available_slots
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Scheduling/SelfBookingService.phpapply_filters( 'vakanssi/booking/available_slots', array $slots, array $context )- $slots
- array of {start, end}
- $context
- interview_id, application_id, organizer_id, interviewers
The slots offered for self-service interview booking, applied at the single source of truth both the booking page’s rendered picker and the booking-submission validation read, so a slot the filter removes is neither shown nor bookable via a direct POST. The intended (veto) direction is removal; a callback can also add a slot, which becomes unconditionally bookable since there is no further built-in constraint (no business-hours or capacity check) downstream.
// Hide a slot a staff calendar integration has since marked as busy.
add_filter( 'vakanssi/booking/available_slots', function ( array $slots, array $context ): array {
$busy = acme_calendar_busy_ranges( $context['organizer_id'] );
return array_values( array_filter( $slots, fn( $slot ) => ! acme_overlaps( $slot, $busy ) ) );
}, 10, 2 );vakanssi/booking/powered_by
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Scheduling/BookingPage.phpapply_filters( 'vakanssi/booking/powered_by', bool $show )- $show
- default: false; was true up to v0.37.11
The muted “Powered by Vakanssi” credit under the booking card. Off by default: return true to show it. Up to and including v0.37.11 the default was true (credit shown), so a site that relied on that default now shows no credit until it returns true.
Collaboration & sourcing Pro feature, requires a Vakanssi Pro license
Hooks from the Collaboration module (feature collaboration), the Presence module (also gated on collaboration), and the Sourcing and CRM module (feature sourcing). See Collaboration and Candidates.
vakanssi/application/assigned
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Collaboration/CollaborationService.phpdo_action( 'vakanssi/application/assigned', int $application_id, int $assignee_id )- $assignee_id
- 0 = unassigned
An application’s assignee actually changed, through the REST assign endpoint or a bulk assign. Fires exactly once per real assignment, from the request that performed it.
Does not fire when the requested assignee is already the application’s live assignee (a true no-op, including a client retrying its own already-applied request or a bulk assign re-run over an already-assigned batch), and a request that loses a concurrent optimistic-concurrency race to another request assigning to the identical user does not fire it either, since the one real change already did.
vakanssi/presence/interval
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Presence/PresenceService.phpapply_filters( 'vakanssi/presence/interval', int $interval )- $interval
- seconds, default 15
How often the Presence heartbeat beats, in seconds. Presence draws the live “who else is viewing this” indicators on the board and in the candidate drawer; it is gated on the collaboration license feature rather than one of its own, so a license that unlocks Collaboration unlocks it too.
The returned value is clamped to 15 to 120 seconds whatever you pass, so a filter cannot make a busy board hammer admin-ajax.php or let an indicator go stale enough to mislead. Raise it on a large team to cut admin requests.
// Beat once a minute instead of every 15 seconds.
add_filter( 'vakanssi/presence/interval', fn () => 60 );vakanssi/candidate/sourced
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/TalentPoolService.phpdo_action( 'vakanssi/candidate/sourced', int $candidate_id, array $input )A recruiter manually added a candidate on the Candidates screen’s Add candidate form, after the submitted tags are attached and the audit entry is written. The form refuses an email address that already belongs to a candidate (matched by normalized email identity) and hands the form back instead of merging, so this fires only for a new person. $input is the sanitized submitted fields, including tags.
vakanssi/candidate/attached
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/TalentPoolService.phpdo_action( 'vakanssi/candidate/attached', int $application_id, int $candidate_id, int $job_id )A sourced candidate was attached to a job as a new application in the entry stage, after the audit entry. Does not trigger vakanssi/application/created: a sourced candidate did not apply, so no applicant confirmation is sent. Attaching is idempotent, so it fires once, from the request that really inserted the application.
vakanssi/candidate/merged
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/CandidateMergeService.phpdo_action( 'vakanssi/candidate/merged', int $surviving_id, int $loser_id )Two candidate records were merged from the duplicates screen. Fires once per successful merge, after the transaction has committed and the merge lock is released, so a callback never sees data that could still roll back. $loser_id is already deleted. A merge does not fire vakanssi/candidate/updated.
vakanssi/sourcing/bulk_actions
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/TalentPoolListTable.phpapply_filters( 'vakanssi/sourcing/bulk_actions', array<string,string> $actions )- $actions
- key => label
Extend the Candidates list’s bulk-action dropdown, beyond the built-in Add tag, per-job Add to job entries, and (for users who hold the delete capability) Delete permanently. A registered key needs a matching handler on vakanssi/sourcing/bulk_result. Labels are plain text: Vakanssi escapes them with esc_html() before WordPress prints them, so do not pre-escape them or pass HTML.
// Add a custom "Export to CRM" bulk action to the Candidates list.
add_filter( 'vakanssi/sourcing/bulk_actions', function ( array $actions ): array {
$actions['acme_export_crm'] = 'Export to CRM';
return $actions;
} );
add_filter( 'vakanssi/sourcing/bulk_result', function ( $result, string $action, array $ids, int $user ) {
if ( null !== $result || 'acme_export_crm' !== $action ) {
return $result; // Not ours to handle.
}
// $ids is already filtered through the view_candidate policy.
return array( 'acme_exported' => count( $ids ) );
}, 10, 4 );vakanssi/sourcing/bulk_result
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/TalentPoolPage.phpapply_filters( 'vakanssi/sourcing/bulk_result', array<string,int>|null $result, string $action, int[] $ids, int $user )- $ids
- already filtered through the view_candidate policy
Handle a custom bulk action registered via vakanssi/sourcing/bulk_actions. Return an array of result args, which become query args on the redirect back to the list, or null to pass to the next handler. The list only shows notices for its own keys, so a custom action that wants a notice prints it itself (for example on admin_notices, reading its own query arg).
Unlike the pipeline equivalent, $ids has already been filtered to candidates the current user may read; the handler still owns any further capability check its own action needs.
vakanssi/candidate/tagged
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/TagsService.phpCandidateProfileController.phpdo_action( 'vakanssi/candidate/tagged', int $candidate_id, string $tag )- $tag
- this service's tags have no separate numeric id
Fires after a tag is attached to a candidate, from either the application-scoped tag control or the Candidates profile’s bulk-add route. Not fired for an idempotent re-add of a tag the candidate already has (case-insensitive).
Three paths write tags without firing it: the Candidates list’s bulk Add tag action, tags entered on the Add candidate form (read them from $input['tags'] on vakanssi/candidate/sourced instead), and tags folded in during a merge.
vakanssi/candidate/untagged
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/TagsService.phpCandidateProfileController.phpdo_action( 'vakanssi/candidate/untagged', int $candidate_id, string $tag )Fires after a tag is detached from a candidate, from either surface above. Not fired when the tag was not present in the first place.
vakanssi/export/candidates_max_rows
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Sourcing/CandidatesExport.phpapply_filters( 'vakanssi/export/candidates_max_rows', int $max_rows )- $max_rows
- default: 50000
Row ceiling for the candidates (talent pool) CSV export. Same contract as vakanssi/export/applications_max_rows above: return 0 or negative for no ceiling, and a capped export ends on a TRUNCATED marker row rather than an ordinary one.
Webhooks (Integrations) Pro feature, requires a Vakanssi Pro license
The Integrations module (feature integrations) delivers events to signed webhook endpoints and Slack/Teams notifications. It forwards nine built-in vakanssi/* actions out of the box (listed after Cron events below) as ten subscribable event keys, and, via the hooks below, also accepts events your own code defines. See Integrations.
vakanssi/webhooks/payload
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Integrations/Dispatcher.phpapply_filters( 'vakanssi/webhooks/payload', array $payload, string $event, array $endpoint )- $event
- event key
- $endpoint
- id, label, url, type, events, active
An event’s payload immediately before it is formatted, signed, and delivered. Runs once per delivery attempt to one endpoint: the first send, every automatic retry, a manual Resend, and the endpoint’s Send test (which uses a sample application.created payload).
Retries re-apply the filter to the stored, unfiltered payload, so keep the callback deterministic. It runs before signing, so a webhook endpoint’s signature always covers the exact payload sent. Never includes the endpoint’s signing secret.
The job title, stage, interview title and location, staff names, and note excerpt arrive as plain text, not HTML entities (Director's Assistant, not Director’s Assistant), so escape them yourself before putting them into HTML. The candidate’s name is the exception: it is sent as stored, since it comes from the public application form.
vakanssi/webhooks/events
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Integrations/EventCatalog.phpapply_filters( 'vakanssi/webhooks/events', array $events )- $events
- event key => human label
The set of events an endpoint can subscribe to. A key registered here becomes selectable in the endpoint subscription UI and a valid target for vakanssi/webhooks/queue below.
vakanssi/webhooks/queue
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Integrations/IntegrationsModule.phpdo_action( 'vakanssi/webhooks/queue', string $event, array $payload )- $event
- event key
- $payload
- default: empty
The public way to deliver an instance of a registered custom event: Vakanssi never fires this itself. Call it from your own code, and the Integrations module forwards it to every subscribed endpoint the same way it forwards the plugin’s own built-in domain hooks.
Only an event registered via vakanssi/webhooks/events is forwarded, and only when at least one active endpoint subscribes to it; an unregistered or mistyped key, or a non-array payload, is silently ignored. With Integrations switched off, or on the free plugin, the call still returns normally and nothing is delivered.
// Deliver a custom "candidate.referred" event to any subscribed webhook endpoint.
add_filter( 'vakanssi/webhooks/events', function ( array $events ): array {
$events['candidate.referred'] = __( 'Candidate referred', 'acme' );
return $events;
} );
// Later, wherever your own code records a referral:
function acme_record_referral( int $candidate_id ) {
do_action( 'vakanssi/webhooks/queue', 'candidate.referred', array( 'candidate_id' => $candidate_id ) );
}Export
The CSV export header row and per-row data, shared by both the free Applications export and the Candidates Pro feature, requires a Vakanssi Pro license export (see vakanssi/export/applications_max_rows and vakanssi/export/candidates_max_rows above). $kind tells a callback which export is running.
vakanssi/export/columns
src/Modules/Pipeline/ApplicationsExport.phpsrc/Modules/Sourcing/CandidatesExport.phpapply_filters( 'vakanssi/export/columns', string[] $header, string $kind )- $kind
- applications or candidates
The CSV export header row. A column added here must be matched by a value added at the same position via vakanssi/export/row below, or the file’s columns and values drift out of alignment.
// Add a "job_url" column to the applications CSV export.
add_filter( 'vakanssi/export/columns', function ( array $header, string $kind ): array {
if ( 'applications' === $kind ) {
$header[] = 'job_url';
}
return $header;
}, 10, 2 );
add_filter( 'vakanssi/export/row', function ( array $row, array $item, string $kind ): array {
if ( 'applications' === $kind ) {
$row[] = (string) get_permalink( (int) $item['job_id'] );
}
return $row;
}, 10, 3 );vakanssi/export/row
src/Modules/Pipeline/ApplicationsExport.phpsrc/Modules/Sourcing/CandidatesExport.phpapply_filters( 'vakanssi/export/row', string[] $row, array $item, string $kind )- $row
- same order as the (possibly filtered) header
- $item
- the raw source database row
- $kind
- applications or candidates
One CSV export data row, applied before the existing formula-injection escaping, so an added value is escaped exactly like a built-in one. $item is the row the export selected: for applications, id, created_at, job_title, job_id, stage (the stage name), status, source, has_cv, and rating, plus the candidate’s first_name, last_name, email, and phone.
Since v0.37.15 the applications export’s job_title cell in $row is already plain text, not HTML entities, by the time this filter runs. Since v1.0.0 so are the applications export’s first_name, last_name, stage, and source cells, and the Candidates Pro feature, requires a Vakanssi Pro license export’s first_name, last_name, location, source, and tags cells (Ann < Bo, not Ann < Bo). $item still holds the stored values. See the paired example under vakanssi/export/columns above.
apply_filters( 'vakanssi/export/chunk_size', int $size )- $size
- default: 5000
Rows fetched per database round trip by the chunked CSV exports, floored at 1. Peak memory during an export depends on this value, not on how many rows are exported.
The free Email and Notifications module: every templated email (confirmation, recruiter notification, rejection, offer) passes through these. See Emails.
vakanssi/email/sent
src/Modules/Email/Mailer.phpdo_action( 'vakanssi/email/sent', string $key, string $to, bool $ok, int $application_id )- $key
- the template key, or the log label (for example custom, automation, booking_link) for a pre-rendered message
A send was attempted and logged. $ok is whether wp_mail() succeeded. $application_id is 0 when not application-bound.
Fires for every send the plugin logs, whoever composed it: templated candidate mail, ad-hoc recruiter messages, Pro automation sends, interview invitations and cancellations, booking links, the organizer’s booking notice, assignment and mention mail to the team, and (premium build) the one-time scoping-lapse alert to the site admin.
The one send it does not cover is the test email (Tools, then Status, and the setup wizard), which is a transport diagnostic and is not logged.
vakanssi/email/html
src/Modules/Email/EmailTemplates.phpapply_filters( 'vakanssi/email/html', string $html, array $context )The wrapped HTML email markup, filtered when the branded shell is rendered rather than at send time: on a templated send, vakanssi/email/send_args runs afterwards and can still replace the body. It also runs for the unlogged test email. $context is an empty array for messages rendered without a merge context (interview and booking mail, the test email, the scoping-lapse alert).
vakanssi/email/headers
src/Modules/Email/Mailer.phpapply_filters( 'vakanssi/email/headers', string[] $headers )Outgoing mail headers (Content-Type, From, Reply-To).
vakanssi/email/company
src/Modules/Email/Notifications.phpsrc/Modules/Scheduling/BookingPage.phpsrc/Modules/EmailAutomation/ContextBuilder.phpapply_filters( 'vakanssi/email/company', string $company )Company name in the email merge context and on the public booking page Pro feature, requires a Vakanssi Pro license. Default in the email merge context: the company name setting, falling back to the site title. On the booking page the default is the site title only, so set the name through this filter if the two should match.
Fired by Email, by Email Automation Pro feature, requires a Vakanssi Pro license, and by Scheduling Pro feature, requires a Vakanssi Pro license, so it keeps firing while any of them is on.
vakanssi/email/notify_address
src/Modules/Email/Notifications.phpapply_filters( 'vakanssi/email/notify_address', string $email, int $job_id )- $job_id
- 0 when unknown
Recruiter notification recipient. Resolved from the per-job override, then the global setting, then admin_email.
// Route engineering applications to the engineering hiring inbox.
add_filter( 'vakanssi/email/notify_address', function ( string $email, int $job_id ): string {
if ( $job_id && has_term( 'engineering', 'vakanssi_department', $job_id ) ) {
return 'eng-hiring@example.com';
}
return $email;
}, 10, 2 );vakanssi/email/notify_applicant
src/Modules/Email/Notifications.phpapply_filters( 'vakanssi/email/notify_applicant', bool $notify, int $application_id, array $data )- $notify
- default: true
Whether the applicant confirmation email is sent on capture. Return false to suppress it. The Add application button on the Applications screen wires this to false for staff-created applications, since the candidate did not apply themselves.
vakanssi/email/stage_notification
src/Modules/Email/Notifications.phpsrc/Modules/EmailAutomation/AutomationSender.phpapply_filters( 'vakanssi/email/stage_notification', bool $send, int $application_id, string $status )- $send
- default: true
- $status
- derived status
Whether a stage move emails the candidate at all; return false to suppress. It covers every automatic candidate email a move can send: the free reject and offer templates, and the Email Automation Pro feature, requires a Vakanssi Pro license stage rule. The REST move endpoint’s notify=false wires in here, and Screening’s knockout auto-move scopes it to one application.
An explicit suppress here wins even over notify=true. A suppressed automation send does not use up its once-per-stage marker, so a later move into the same stage still sends. Does not affect the move or the audit log.
// Never auto-send rejections; the team sends personal notes instead.
add_filter( 'vakanssi/email/stage_notification', function ( bool $send, int $application_id, string $status ): bool {
if ( 'rejected' === $status ) {
return false;
}
return $send;
}, 10, 3 );apply_filters( 'vakanssi/email/force_stage_notification', bool $force, int $application_id, string $status )- $force
- default: false
Cross-build seam: whether to send the reject or offer email even though its template’s automatic-sending setting is off. The REST move endpoint’s notify=true wires in here for one request (the board and drawer’s “Send email & move”). It carries request-scoped operator intent, not a site setting; vakanssi/email/stage_notification is the public send or suppress gate.
apply_filters( 'vakanssi/email/resendable', bool $resendable, string $template, int $application_id, string $subject )- $resendable
- default: false
- $template
- logged template id or label
- $subject
- the subject logged on the row, empty when the caller did not have the row
Whether a non-template logged email (a label like booking_link or automation rather than a real template key) can be rebuilt and resent. The Activity tab’s Resend button is offered per row based on this, and Pro Scheduling and Email Automation claim it for the rows they can rebuild.
$subject is the only thing that tells one label-only row of an application from another, so a claimant answers for the row, not the label.
The logged subject is now plain text: HTML entities from the merged title (an apostrophe stored as ’) are decoded, and any line break or other control character is folded to a space, before the row is written. A claimant written against the raw, entity-laden subject needs its own comparison updated to match.
apply_filters( 'vakanssi/email/resend_payload', array{subject:string, body:string}|null $payload, string $template, array $context, int $application_id, string $subject )- $subject
- the subject logged on the row
Supplies the rebuilt subject and body for a non-template resend claimed via vakanssi/email/resendable. Return null to decline. Runs in the site’s language, and receives the same logged subject for the same reason as vakanssi/email/resendable, decoded the same way.
apply_filters( 'vakanssi/email/audience', string $audience, string $label )- $audience
- candidate, staff, or mixed
- $label
- logged template id or label
The third member of the resend seam: who a logged email was addressed to. A module that logs its own label and claims it resendable also says who it addressed. staff keeps a resend going to the address on the logged row, and mixed (a label logged once per recipient) refuses the resend instead of guessing. An unrecognized return falls back to candidate, never to staff.
vakanssi/email/send_args
src/Modules/Email/Mailer.phpapply_filters( 'vakanssi/email/send_args', array $args, string $key, array $context, int $application_id )- $args
- to, subject, body
- $key
- template id, or the log label for a pre-rendered message
- $context
- merge context; an empty array for a pre-rendered message
- $application_id
- 0 = not application-bound
The recipient, subject, and body immediately before an email is logged (claimed) and sent. Returning a falsy value, or an array whose to is empty or invalid, aborts the send entirely: no claim row is written and wp_mail() is never called, and no vakanssi/email/sent action fires either.
The once-only dedupe identity stays keyed to the original recipient, computed before this filter runs, so a callback that reroutes delivery (a BCC or logging address) does not change what counts as “already sent” for this application and template.
vakanssi/email/failed
src/Modules/Email/Mailer.phpdo_action( 'vakanssi/email/failed', string $key, string $to, string $error, int $application_id )- $key
- template id, or the log label for a pre-rendered message
- $to
- recipient actually attempted, post-send_args
Fires when an email send attempt fails (wp_mail() returned false), after the log row records the failure (and, for a templated send, after its once-only claim is released), so a callback reading the log sees the final state.
vakanssi/email/flush_budget
src/Modules/Email/Mailer.phpapply_filters( 'vakanssi/email/flush_budget', int $seconds )- $seconds
- wall-clock seconds, default: 20
Since v0.33.41, how long a deferred email flush may keep sending. Deferred sends run on shutdown, after the response has gone, so a batch killed partway used to leave no trace of the messages it never reached.
The flush now stops when this budget is spent and records the shortfall on each candidate’s activity timeline instead; those messages send the next time they are triggered.
Raise it on a host with a generous request_terminate_timeout and a slow mail provider. 0 or less removes the limit.
vakanssi/email/placeholders
src/Modules/Email/Notifications.phpsrc/Modules/EmailAutomation/ContextBuilder.phpapply_filters( 'vakanssi/email/placeholders', array $context, int $application_id, string $template_key )- $context
- placeholder key => value
- $application_id
- 0 when not yet known
- $template_key
- confirmation/reject/offer/recruiter_notification/custom/a resent template, a Pro automation rule reference like custom:3, or '' when one context build is shared by more than one template
The placeholder/merge-tag map before subject/body substitution. One filter name for every templated candidate email, free and Pro: the free Email module’s confirmation/reject/offer/recruiter_notification/custom/resend sends, and the Pro Email Automation stage-triggered custom-template send, both apply it. A custom key added here substitutes in both subject and body wherever a built-in key does.
$template_key is '' on the capture path, where one context is built once for both the applicant confirmation and the recruiter notification, so do not rely on it to tell those two apart.
application_url, candidate_email, candidate_phone, and source are always present in $context by the time this filter runs, even though only some of them carry a value yet. On the free module’s on_created() and resend() paths they arrive empty: this filter fires before recruiter_context() fills them in, so a value your callback sets for any of the four is silently overwritten by that later merge.
Downstream of this filter, EmailTemplates::body() and subject() blank application_url and source for every template except recruiter_notification, regardless of what this filter returns: those two are staff-only merge tags, and a candidate-facing template (or a one-off message) always renders them empty. candidate_email and candidate_phone carry no such restriction.
// Add a custom {careers_url} merge tag to every templated email.
add_filter( 'vakanssi/email/placeholders', function ( array $context, int $application_id, string $template_key ): array {
$context['careers_url'] = (string) get_post_type_archive_link( 'vakanssi_job' );
return $context;
}, 10, 3 );Audit & privacy
The audit logger records every significant event; the Privacy module (free) runs the daily retention purge. See Capabilities and Privacy.
vakanssi/audit/row
src/Audit/AuditLogger.phpapply_filters( 'vakanssi/audit/row', array $row )The audit row before insert: entity_type, entity_id, user_id, action, changes (JSON or null), ip_address. Redact or enrich.
vakanssi/audit/anonymize_ip
src/Audit/AuditLogger.phpapply_filters( 'vakanssi/audit/anonymize_ip', bool $anonymize )- $anonymize
- default: true
Whether the stored client IP is anonymized with wp_privacy_anonymize_ip(). Not consulted when no client IP could be resolved.
vakanssi/audit/ip
src/Audit/AuditLogger.phpapply_filters( 'vakanssi/audit/ip', string $ip )The client IP about to be stored, already anonymized unless vakanssi/audit/anonymize_ip returned false. Return '' to store no IP.
apply_filters( 'vakanssi/privacy/eraser_batch', int $batch )- $batch
- default: 25
Applications processed per call of the WordPress “Erase Personal Data” eraser callback, floored at 1. Each delete cascades CV bytes, file rows, meta, notes, evaluations, and email logs, so a smaller batch trades more calls for never timing out on a candidate with many applications.
vakanssi/privacy/export_cv_files
src/Modules/Privacy/PrivacyExporter.phpapply_filters( 'vakanssi/privacy/export_cv_files', bool $include )- $include
- default: true
Whether the subject’s retained CV files are enclosed in their “Export Personal Data” archive, in addition to being named in the exported rows. Returning false leaves the export naming each CV without enclosing it, which is very likely an incomplete GDPR Article 15 response, so only turn it off on your own legal advice.
apply_filters( 'vakanssi/privacy/exporter_batch', int $batch )- $batch
- default: 25
Applications the WordPress “Export Personal Data” exporter callback returns per page, floored at 1.
vakanssi/privacy/erased
src/Modules/Privacy/PrivacyEraser.phpdo_action( 'vakanssi/privacy/erased', string $email, array $summary )- $summary
- items_removed, items_retained, messages, done
Fires once, on the final erase() call whose result reports done true for a right-to-erasure request. Not fired when the request’s email matched no candidate.
vakanssi/retention/should_delete
src/Modules/Privacy/RetentionService.phpapply_filters( 'vakanssi/retention/should_delete', bool $should_delete, int $application_id, array $context )- $should_delete
- default: true
- $context
- job_id, applied_at
Whether an expired application is purged (anonymized) this run, checked after the built-in due-date check already selected the row. VETO-ONLY: return false to hold a row back (a legal hold, for instance); it can never make a not-yet-due application purge early. A vetoed row still counts as processed for this run’s batch/chain progression, but its retention_until deadline is left untouched, so it is reconsidered on the next run.
// Hold back applications listed under an active legal matter.
add_filter( 'vakanssi/retention/should_delete', function ( bool $should_delete, int $application_id ): bool {
$held = array_map( 'intval', (array) get_option( 'acme_legal_hold_application_ids', array() ) );
if ( in_array( $application_id, $held, true ) ) {
return false;
}
return $should_delete;
}, 10, 2 );vakanssi/retention/purged
src/Modules/Privacy/RetentionService.phpdo_action( 'vakanssi/retention/purged', int $application_id, array $context )- $context
- job_id, applied_at, captured before the purge
Fires after an expired application’s retention purge (anonymization) completes. The row’s PII fields are already scrubbed by the time this fires; use $context rather than re-reading the row.
apply_filters( 'vakanssi/retention/batch_size', int $size )- $size
- default: 200
Applications anonymized per retention purge run, floored at 1. A full batch chains another run a minute later, so a backlog drains in one chain rather than one batch a day.
apply_filters( 'vakanssi/retention/max_chain', int $max )- $max
- default: 100
Ceiling on chained catch-up purge runs after the daily one. 0 stops chaining.
Cron events
WordPress cron events: Vakanssi schedules them and listens to them, but WP-Cron fires them on schedule (there is no direct do_action()). Attach your own listeners with add_action() like any other action.
One exception: on a site where WP-Cron is not running, the retention purge also runs from a throttled admin-request fallback that calls the purge directly without firing vakanssi/cron/retention_purge, so a listener attached to that event misses those runs.
add_action( 'vakanssi/cron/retention_purge', $callback )Daily anonymization of candidate data past its retention deadline. Scheduled on activation, self-heals on init, cleared on deactivation. While a backlog remains, each full batch schedules a single follow-up event a minute later, passing the chain count ($chain, an int) as its one argument.
vakanssi/cron/expiry_purge
src/Modules/Jobs/ExpiryPurge.phpadd_action( 'vakanssi/cron/expiry_purge', $callback )Daily sweep that finds jobs whose closing date has passed, clears their caches, and fires vakanssi/jobs/expired for each, once per job until the job is saved again (saving clears the marker). Self-heals on init, cleared on deactivation. A full batch (see vakanssi/jobs/expiry_batch_size) chains a single follow-up event a minute later.
vakanssi/cron/webhook_cleanup
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Integrations/IntegrationsModule.phpadd_action( 'vakanssi/cron/webhook_cleanup', $callback )Daily trim of the webhook delivery log (Integrations module). Self-heals on init, cleared on deactivation.
vakanssi/cron/webhook_retry
Pro feature, requires a Vakanssi Pro licensesrc/Modules/Integrations/IntegrationsModule.phpadd_action( 'vakanssi/cron/webhook_retry', $callback )Retry scan for failed webhook deliveries, every five minutes on the plugin’s own cron interval (Integrations module). Self-heals on init, cleared on deactivation.
Beyond these cron events, the Integrations Pro feature, requires a Vakanssi Pro license module subscribes to nine built-in actions and forwards each to your signed webhook endpoints and Slack/Teams notifications: vakanssi/application/created, vakanssi/application/stage_changed, vakanssi/application/assigned, vakanssi/interview/scheduled, vakanssi/interview/booked, vakanssi/interview/canceled, vakanssi/evaluation/saved, vakanssi/candidate/sourced, and vakanssi/note/added.
The subscriptions live in src/Modules/Integrations/EventSubscriber.php, and the built-in event catalog is EventCatalog::all() in src/Modules/Integrations/EventCatalog.php: ten dotted keys (application.created, application.stage_changed, application.hired, application.rejected, application.assigned, interview.scheduled, interview.canceled, evaluation.saved, candidate.sourced, note.added) mapped from the nine actions above.
vakanssi/webhooks/payload, vakanssi/webhooks/events, and vakanssi/webhooks/queue, documented under Webhooks (Integrations) above, let your own code shape a delivery or define and deliver an event of its own. See Integrations.
Licensing
These hooks exist only in the Pro build: the free plugin from WordPress.org ships no licensing code at all, so none of them fire there. In the Pro build the license service resolves a provider each request, and each Pro module boots only while that provider allows its feature.
vakanssi/license/provider
src/Licensing/LicenseManager.phpapply_filters( 'vakanssi/license/provider', Vakanssi\Licensing\LicenseProvider $provider )Swap the license backend. The provider passed in is the one the build resolved: the unlock-all provider when VAKANSSI_UNLOCK_ALL is on, the Freemius provider when a Freemius SDK instance is loaded, otherwise the default provider that keeps Pro features off. A return that does not implement LicenseProvider is discarded and that default provider is used.
vakanssi/license/can
src/Licensing/FreemiusLicenseProvider.phpapply_filters( 'vakanssi/license/can', bool $can, string $feature, LicenseProvider $provider )- $provider
- the provider making the decision
Override a feature-gate decision. Only the Freemius provider fires it, so it runs only while a Freemius SDK instance is loaded and VAKANSSI_UNLOCK_ALL is off; a replacement provider from vakanssi/license/provider fires it only if it calls it itself. Feature slugs: analytics, antispam, collaboration, email_automation, integrations, scheduling, scorecards, screening, sourcing.
apply_filters( 'vakanssi/license/freemius_instance', object|null $instance )- $instance
- default: null
Supply a Freemius SDK instance when no vakanssi_fs() function exists. A non-object return is ignored.
do_action( 'vakanssi_fs_loaded' )The Freemius SDK finished loading. The one underscore-form hook, named to the Freemius SDK’s own convention rather than vakanssi/{area}/{name}.
Dashboard & onboarding
Internal seams on the free Hiring HQ dashboard and the setup wizard. The dashboard’s “Hiring analytics” bands are cross-build seams that Analytics Pro feature, requires a Vakanssi Pro license answers. Their calls exist only in the premium build (the free build strips them), and even there the dashboard reaches them only while the Analytics module is active, so populating them yourself on a site without active Analytics shows nothing.
apply_filters( 'vakanssi/dashboard/analytics_bands', array $default, array $context )- $default
- zeroed bands in the same shape
- $context
- {range: int} day window, 0 = all time
The “Performance” and “Sources & jobs” dashboard bands, answered by the Analytics module Pro feature, requires a Vakanssi Pro license.
apply_filters( 'vakanssi/dashboard/stage_event_analytics', array $default, array $context )- $context
- {range: int}
The “Where candidates drop off” dashboard band (drop-off funnel and time in stage), answered by the Analytics module Pro feature, requires a Vakanssi Pro license.
apply_filters( 'vakanssi/dashboard/analytics_export_url', string $default )- $default
- empty
The dashboard’s Export CSV link, shown next to the date range control to users who may export data. Answered by the Analytics module Pro feature, requires a Vakanssi Pro license; an empty value renders no button.
apply_filters( 'vakanssi/onboarding/candidate_pages', WP_Post[] $candidates )- $candidates
- max three
The ranked pages the setup wizard suggests connecting as the careers page, in the wizard’s ranking order.
Formatting & internal helpers
General helpers, then the internal schema, storage-engine, and test-only seams. The two format filters shape the dates and timestamps Vakanssi prints through its own format helpers, in wp-admin and on candidate-facing pages. Some surfaces use fixed formats instead, among them interview invitation emails, the drawer’s interview time and applied date, the candidate booking page Pro feature, requires a Vakanssi Pro license, and the webhook delivery log Pro feature, requires a Vakanssi Pro license.
vakanssi/date_format
src/Support/helpers.phpapply_filters( 'vakanssi/date_format', string $format )The date format for Vakanssi’s human-facing dates. Default: the WordPress date_format option.
vakanssi/datetime_format
src/Support/helpers.phpapply_filters( 'vakanssi/datetime_format', string $format )The date-and-time format for Vakanssi’s human-facing timestamps. Default: the filtered vakanssi/date_format value and the WordPress time_format option, joined with a middle dot.
apply_filters( 'vakanssi/docs_url', string $url, string $path, string $anchor )- $path
- docs path, e.g. security/server-compatibility
- $anchor
- sanitized fragment, without the #, or empty
The public docs URL Vakanssi links to: the Documentation item in the Hiring menu, the Learn more link on Settings and Tools cards, Site Health, the REST-blocked admin notice, and more. Default: https://docs.vakanssi.com/{$path}/, mirroring this site’s own URL structure.
The filter runs before the #$anchor fragment is appended, so a callback that rewrites $url cannot land its change inside the fragment. Since v0.31.0; before that it received ( $url, $slug ) and always built a /guide/{slug}/ URL.
apply_filters( 'vakanssi/stage_events/backfill_chunk', int $chunk )Applications processed per pass when backfilling historical stage events: one chunk right after a schema migration, then one chunk per admin request until the backfill is done. A value below 1 falls back to the default of 200.
Premium build only: the backfill class lives in the Analytics Pro feature, requires a Vakanssi Pro license module’s folder, but it runs whether or not Analytics is licensed or switched on.
apply_filters( 'vakanssi/schema/pro_tables', string[] $pro )- $pro
- paid-only logical table names
Test-only, premium build only (the free build strips the call): the logical names of the tables only the premium build creates. Lets a test simulate the free build’s empty list against the full source.
apply_filters( 'vakanssi/schema/table_exists', bool $exists, string $logical_name )Test-only: whether a plugin table is considered present. Lets a test simulate a paid table being physically absent, as on a fresh free install, which the WordPress test database cannot reproduce.
apply_filters( 'vakanssi/schema/innodb_available', bool $available )Test-only: whether the InnoDB storage engine is available, so a test can simulate a host without it.
apply_filters( 'vakanssi/transaction/supported', bool $protected, string $name )- $protected
- whether a real transaction or savepoint started
- $name
- the transaction's label
Test-only: whether a transaction is reported as genuinely backed by the storage engine, so a test can simulate an engine that gives no real rollback.
apply_filters( 'vakanssi/data_reset/delete_table', string $delete_table, string $name )- $delete_table
- fully-qualified table name
- $name
- logical table name
Test-only: points one table’s DELETE FROM in the Data Reset tool somewhere else, so a test can exercise the rollback path against a real database error. The value is bound as an identifier, never concatenated into the SQL.
JavaScript hooks
Vakanssi exposes five client-side filters and three actions through the wp.hooks API (the @wordpress/hooks package). Enqueue your script on the Applications admin screen with wp-hooks and wp-element as dependencies. The two presence actions only fire on the Pro build with Presence Pro feature, requires a Vakanssi Pro license active, and vakanssi.pipeline.state exists only in the premium build.
vakanssi.pipeline.drawer_panels
assets/js/pipeline.jswp.hooks.applyFilters( 'vakanssi.pipeline.drawer_panels', panels, data, helpers )- panels
- array, default []
- data
- drawer payload
- helpers
- { reload, setDirty }
Add panels (tabs) to the application drawer. Return the panels array with your entries appended. An entry is either a titled descriptor, { id, title, node, count } (count is optional and draws the same badge the Activity tab uses), or a bare React element, which gets a “Details” title.
Since v0.34.4, a panel that holds unsaved work must report it through helpers.setDirty( id, dirty ), or closing the drawer or stepping to the next candidate discards it without asking.
// Add a "Quick links" tab to the application drawer.
wp.hooks.addFilter(
'vakanssi.pipeline.drawer_panels',
'acme/quick-links',
function ( panels, data, helpers ) {
var el = wp.element.createElement;
var email = ( data.application && data.application.email ) || '';
return panels.concat( [ {
id: 'acme-quick-links',
title: 'Quick links',
node: el( 'p', null,
el( 'a', { href: 'mailto:' + email }, 'Email this candidate' )
),
} ] );
}
);vakanssi.pipeline.details_sections
assets/js/pipeline.jswp.hooks.applyFilters( 'vakanssi.pipeline.details_sections', sections, data, helpers )- sections
- { id, node }[], default []
- data
- drawer payload
- helpers
- { reload }
Add person-level sections to the drawer’s Details tab. The tab renders only when at least one section is present. Return the sections array with your { id, node } descriptor(s) appended.
vakanssi.pipeline.header_controls
assets/js/pipeline.jswp.hooks.applyFilters( 'vakanssi.pipeline.header_controls', controls, data, helpers )- controls
- { id, node }[], default []
- data
- drawer payload
- helpers
- { reload }
Add controls to the drawer header row, ahead of Edit details, Print / PDF, and Delete. For compact application-state controls that should stay visible whichever tab is open, not deep content. Return the controls array with your { id, node } descriptor(s) appended; an entry without a node is dropped.
vakanssi.pipeline.rail_sections
assets/js/pipeline.jswp.hooks.applyFilters( 'vakanssi.pipeline.rail_sections', sections, data, helpers )- sections
- { id, node }[], default []
- data
- drawer payload
- helpers
- { reload }
Add compact sections to the drawer’s identity rail, the always-visible candidate card on the left. For small identity metadata only: keep it short, since the rail must not grow a scrollbar. Return the sections array with your { id, node } descriptor(s) appended; an entry without a node is dropped, and returning nothing adds no section at all.
vakanssi.pipeline.card_markers
assets/js/pipeline.jswp.hooks.applyFilters( 'vakanssi.pipeline.card_markers', markers, app )- markers
- { id, node, label }[], default []
- app
- one shaped application row
Since v0.33.43, quiet markers on a pipeline board card, beside the built-in Withdrawn one. For application-level flags a recruiter must see while scanning a column, without opening each card; Screening Pro feature, requires a Vakanssi Pro license uses it for its knockout flag. Keep markers to a word or two. An entry without a node is dropped.
app is whatever vakanssi/rest/applications_payload left on the row, so add your field there and render it here. label is optional plain text appended to the card’s accessible name, and it is not decoration: the card’s own aria-label replaces its inner text for assistive technology, so a marker without a label is silent to a screen reader.
// Flag referral applications on the board card.
wp.hooks.addFilter( 'vakanssi.pipeline.card_markers', 'acme/referral', function ( markers, app ) {
if ( 'referral' !== app.source ) {
return markers;
}
return markers.concat( [ {
id: 'acme-referral',
node: wp.element.createElement( 'span', { className: 'acme-referral' }, 'Referral' ),
label: 'Referral',
} ] );
} );vakanssi.pipeline.state
assets/js/pipeline.jswp.hooks.applyFilters( 'vakanssi.pipeline.state', state )- state
- { screen, job, open }
Fired whenever the reader moves: by the Board when [jobId, drawer] changes, and by the list view’s drawer host when [jobId, openItem] changes. An action, not a filter, so the return value is ignored.
screen names the surface, job is the current job filter (0 for all), and open is the application whose drawer is open (0 for none). Pro Presence subscribes to it to feed the next heartbeat, which is how the live “who’s viewing” indicators know where everyone is.
Premium build only: the free build strips both calls, so a listener there is never called. On the premium build it fires whether or not Presence is active, and Presence is the only built-in listener.
wp.hooks.addAction( 'vakanssi.pipeline.state', 'acme/track-view', function ( state ) {
if ( state.open ) {
console.log( 'Now reading application', state.open, 'on job', state.job );
}
} );wp.hooks.applyFilters( 'vakanssi.presence.update', payload )- payload
- { server_time, presence, changed, deleted, refresh }
Fired on every WordPress Heartbeat tick that carries a Vakanssi presence payload, which is how the board and list learn who else is looking at what. pipeline.js consumes it to patch the board in place and drive the presence chips.
Dormant on the free plugin, which does not include Presence, so the script that fires it does not exist there. On the Pro build Presence shares Collaboration’s license feature. See vakanssi/presence/interval above for the beat rate.
wp.hooks.applyFilters( 'vakanssi.presence.connection', state )- state
- string, 'lost' or 'restored'
Relayed one for one from WordPress core’s own heartbeat.connection-lost and heartbeat.connection-restored events, under a single namespaced action, so a subscriber only has to know Vakanssi’s event names rather than core’s.
Vakanssi uses it to dim the presence chips while the connection is down, marking them stale instead of leaving a stale roster looking live.
data is the drawer payload returned by GET /wp-json/vakanssi/v1/applications/{id} after the vakanssi/rest/application_payload filter, so anything you add there is available here. Vakanssi’s own modules use the same filters:
drawer_panels: the Notes and Interviews tabs, plus Scorecard Pro feature, requires a Vakanssi Pro license and Screening Pro feature, requires a Vakanssi Pro license. Scheduling Pro feature, requires a Vakanssi Pro license merges its Propose times section into the Interviews tab.header_controls: Collaboration’s Assignment Pro feature, requires a Vakanssi Pro license and Screening’s “Does not meet requirements” badge Pro feature, requires a Vakanssi Pro license.details_sections: Sourcing’s Candidate tags Pro feature, requires a Vakanssi Pro license.rail_sections: the Scorecards Pro feature, requires a Vakanssi Pro license score summary, once anyone has scored the candidate.card_markers: Screening’s knockout flag Pro feature, requires a Vakanssi Pro license.