Extending Free - The Canonical Pro Contract
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.
The four invariants
Section titled “The four invariants”Pro’s check script (composer arch-checks) enforces A1 to A3. Your addon should aim for the same:
A1 - Lockstep version
Section titled “A1 - Lockstep version”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;}A2 - Dependency guard
Section titled “A2 - Dependency guard”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.
A4 - No source modification
Section titled “A4 - No source modification”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 REST routes
Section titled “Pro REST routes”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.
Mobile/companion-app push routes
Section titled “Mobile/companion-app push routes”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.
How Pro consumes Free’s hooks
Section titled “How Pro consumes Free’s hooks”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.phpadd_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.”
How Pro extends Free’s blocks
Section titled “How Pro extends Free’s blocks”Free’s blocks render server-side. Pro extends them via two mechanisms:
1 - Form field injection
Section titled “1 - Form field injection”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.
2 - REST response filtering
Section titled “2 - REST response filtering”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.
How Pro adds new database tables
Section titled “How Pro adds new database tables”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.
How Pro extends the credit-purchase flow
Section titled “How Pro extends the credit-purchase flow”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.
Local checks
Section titled “Local checks”bin/architecture-checks.sh runs the architecture checks, including A1, A2 and A3. If you’re authoring against Pro:
composer arch-checks # Run the gate manually anytimecomposer ci # Run the full local pipelinecomposer install-hooks turns on the repository’s git hooks, including a pre-push hook that runs the local checks.
When the contract doesn’t fit
Section titled “When the contract doesn’t fit”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:
- Open a PR against Free to add the missing extension point, or
- Build the feature inside Pro using a different mechanism, or
- 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.

