Skip to content

Extending Free - The Canonical Pro Contract

WP Career Board overviewFree + ProGet WP Career Board →

Use this page to build an addon that extends WP Career Board Free the way Pro does, without forking it. Every pattern here is something a third-party addon can copy.

Pro’s check script (composer arch-checks) enforces A1 to A3. Your addon should aim for the same:

Pro’s plugin header version matches Free’s, and the check script fails on drift. Why: shipping one updated and the other not means a customer has a half-built release, and cross-plugin hook signatures go out of sync. Pro also refuses to boot against a Free older than its minimum supported version.

For an addon, your equivalent is “what’s the minimum Free version I work against?” Declare it as a constant (MYADDON_MIN_WCB = '1.0.0'), check at boot:

if ( ! defined( 'WCB_VERSION' )
|| version_compare( WCB_VERSION, MYADDON_MIN_WCB, '<' ) ) {
add_action( 'admin_notices', 'myaddon_min_wcb_notice' );
return;
}

Pro defines wcbp_free_active() and uses it inside the boot path:

function wcbp_free_active(): bool {
return defined( 'WCB_VERSION' );
}
if ( ! wcbp_free_active() ) {
add_action( 'admin_notices', 'wcbp_missing_free_notice' );
return;
}

The guard runs on plugins_loaded at priority 20. Free loads at priority 10, so by the time Pro’s check fires Free has already booted. If a customer deactivates Free via WP-CLI (which bypasses the Requires Plugins: header), Pro detects it and gracefully skips its hooks instead of fataling.

A3 - REST namespace shared, paths disjoint

Section titled “A3 - REST namespace shared, paths disjoint”

Both plugins register under the same wcb/v1 namespace - that’s intentional so the API surface stays cohesive to consumers. Disjointness is what matters:

Free: /jobs, /jobs/{id}, /candidates/{id}, /candidates/{id}/applications ...
Pro: /resumes, /boards/{id}, /applications/{id}/stage, /alerts ...

The check script compares the route lists of both plugins and fails if a path appears in both. If you’re adding routes from an addon, pick a unique sub-path and document it.

Pro does not patch Free’s classes. All extension goes through documented filters and actions. The contract is one-way: Free exposes the hooks; Pro and other addons consume them.

Pro registers its routes under the shared wcb/v1 namespace, one endpoint class per group in api/endpoints/ (these classes extend WCB\Pro\Api\ProRestController). The setup wizard routes are registered by the Pro setup wizard. License status never gates these routes: the license drives automatic updates only. The full list, with who may call each route and its parameters, is in the Pro REST API reference.

POST and DELETE /wcb/v1/push/register-device back the native (Expo) push feature for the mobile app. Both require a logged-in member (is_logged_in()). Like the notification bell, a member’s own device registration keeps working regardless of license status.

// POST /wcb/v1/push/register-device
// Body: { "expo_push_token": "ExponentPushToken[xxxxxxxx]", "platform": "ios"|"android", "device_name": "..." }
// -> 201 { "registered": true } | 400 wcb_invalid_push_token | 500 wcb_push_register_failed
// DELETE /wcb/v1/push/register-device
// Body: { "expo_push_token": "ExponentPushToken[xxxxxxxx]" }
// -> 200 { "unregistered": true }

The token is validated against Expo’s ExponentPushToken[...] / ExpoPushToken[...] shape before it’s stored. PushModule does not author its own notifications or keep a separate queue - it listens on the existing wcb_notification_created action and fans each message out to the caller’s registered devices via the shared AsyncScheduler. See 03-hooks-reference.md.

The SDK’s own checkout/webhook/refund routes (a separate wbcom-credits/v1 namespace, not wcb/v1) are documented in 04-credits-sdk.md.

The cleanest examples in the codebase:

Returning Pro’s status to Free’s gate filters

Section titled “Returning Pro’s status to Free’s gate filters”

Free fires apply_filters( 'wcb_pro_active', false ) to check whether Pro is running. Pro registers:

// In core/class-free-coordination.php
add_filter( 'wcb_pro_active', '__return_true' );

Pro registers all of these in core/class-free-coordination.php: wcb_pro_active, wcb_pro_licensed, wcb_pro_version, wcb_pro_ai_enabled, wcb_pro_alerts_enabled, wcb_pro_resumes_enabled, and wcb_pro_settings_saved_notice. Each returns a value Pro alone can authoritatively answer.

Reading credit balances and pricing from the SDK

Section titled “Reading credit balances and pricing from the SDK”

The credit system is owned by the Wbcom Credits SDK, not by a Free placeholder filter. Pro’s blocks and endpoints read the balance directly:

$balance = \Wbcom\Credits\Credits::get_balance( 'wp-career-board', $user_id );
$url = \Wbcom\Credits\Credits::get_purchase_url( 'wp-career-board' );

The one extension point Pro exposes for pricing is the wcbp_consumer_cost filter, applied inside each consumer’s cost callback when Pro registers with the SDK:

// Args: ( int $base_cost, int $user_id, int $item_id, int $board_id, string $consumer_slug )
add_filter( 'wcbp_consumer_cost', function ( $cost, $user_id, $item_id, $board_id, $consumer ) {
// 'my_pro_tier_check' stands for your own membership test.
if ( 'job_post' === $consumer && my_pro_tier_check( $user_id ) ) {
return max( 0, (int) ( $cost / 2 ) );
}
return $cost;
}, 10, 5 );

See 04-credits-sdk.md for how Pro registers its consumers, adapters, and gateways with the SDK.

Hooking the board picker to filter by group membership

Section titled “Hooking the board picker to filter by group membership”

Free’s job-form template fires apply_filters( 'wcb_board_options_for_employer', $options, $user_id ). Pro’s BP-groups integration consumes it to drop boards whose linked BuddyPress group the employer is not a member of:

add_filter( 'wcb_board_options_for_employer',
array( BpGroupBoards::class, 'restrict_boards_to_user_groups' ),
10, 2
);

This is the canonical pattern for “Pro adds a constraint to a Free control surface.”

Free’s blocks render server-side. Pro extends them via two mechanisms:

Free’s forms expose declarative field-schema filters that Pro’s field builder hooks to inject custom field groups: wcb_job_form_fields, wcb_company_form_fields, wcb_candidate_form_fields, and wcb_resume_form_fields. Each passes the current field array plus a context id: the board id for jobs, the company id for companies, the candidate’s user id for candidates, and the resume id for resumes:

add_filter( 'wcb_job_form_fields', function ( array $fields, int $board_id ) {
$fields['my_group'] = array( /* field definitions */ );
return $fields;
}, 10, 2 );

Pro persists the submitted values on the wcb_job_created / wcb_job_updated actions.

REST responses go through wcb_rest_prepare_* filters. Pro adds Pro-specific fields to the board, board-stage, resume, and notification responses (wcb_rest_prepare_board, wcb_rest_prepare_board_stage, wcb_rest_prepare_resume, wcb_rest_prepare_notification):

add_filter( 'wcb_rest_prepare_resume', function ( $row, $resume, $request, $context ) {
$row['my_extra_field'] = get_post_meta( $resume->ID, '_my_extra', true );
return $row;
}, 10, 4 );

These two patterns cover most of Pro’s UI extensions. Anything they can’t handle is a real gap in Free’s hook surface - file a Free PR to add the hook, then consume it from Pro.

Pro creates these tables: wcb_field_groups, wcb_field_definitions, wcb_field_values, wcb_job_alerts, wcb_application_stages, wcb_ai_vectors, wcb_notifications and wcb_push_devices. The credit ledger and gateway log tables belong to the bundled Credits SDK, which creates them itself. Pro creates its tables with dbDelta() in core/class-pro-install.php:

private static function create_field_groups_table( $wpdb ): void {
$table_name = $wpdb->prefix . 'wcb_field_groups';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} ( ... ) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}

The pattern (one private method per table) makes the schema greppable. Schema version is tracked in wcbp_db_version option.

Pro registers everything (slug, consumers, and settings) with the Wbcom Credits SDK through its wbcom_credits_sdk_registry action in wp-career-board-pro.php:

add_action( 'wbcom_credits_sdk_registry', function ( \Wbcom\Credits\Registry $registry ) {
$registry->register( array(
'slug' => 'wp-career-board',
'prefix' => 'wcb',
'version' => WCBP_VERSION,
'file' => WCBP_FILE,
'user_type' => 'employer',
'consumers' => array( /* job_post, featured_upgrade */ ),
'settings' => array( /* low_threshold, purchase_url, admin_settings_hook */ ),
) );
} );

The SDK ships the e-commerce adapters (WooCommerce, WC Subscriptions, WC Memberships, PMPro, MemberPress); each adapter listens for that plugin’s “order completed” event and writes a topup row to the ledger. Adapters self-discover when the host plugin is active - Pro does not register them one by one. To add support for a new e-commerce plugin, write a new adapter class that implements AdapterInterface. See 04-credits-sdk.md.

bin/architecture-checks.sh runs the architecture checks, including A1, A2 and A3. If you’re authoring against Pro:

Terminal window
composer arch-checks # Run the gate manually anytime
composer ci # Run the full local pipeline

composer install-hooks turns on the repository’s git hooks, including a pre-push hook that runs the local checks.

If you find yourself wanting to do something the four invariants don’t allow (e.g. modify Free source, register a colliding REST path), STOP and either:

  1. Open a PR against Free to add the missing extension point, or
  2. Build the feature inside Pro using a different mechanism, or
  3. Talk to the team - there’s usually a third option we’d rather ship than break the contract.

The contract keeps the two plugins independent: each can be updated and tested without patching the other.