Extending LicenceForge

LicenceForge is designed to be extended without modifying core plugin files. This guide walks through the most common extension patterns -- custom validation logic, webhook listeners, email templates, REST API endpoints, and custom analytics -- with complete, production-ready code examples.

Note

All code examples on this page should be placed in a custom plugin or in your theme's functions.php. Never edit LicenceForge core files directly, as changes will be lost during updates.

Custom validation logic

The wplf_client_is_valid filter runs on the client side after the licence validation API call completes. By hooking into this filter you can layer additional checks on top of the server response -- for example, restricting activation to an approved list of domains.

Example: domain whitelist

The following snippet ensures the licence is only treated as valid on domains you explicitly allow. All other domains receive a false result even if the server confirms the key is valid.

add_filter( 'wplf_client_is_valid', 'myprefix_domain_whitelist', 10, 2 );

/**
 * Restrict licence validity to a set of approved domains.
 *
 * @param bool  $is_valid Whether the server confirmed the licence is valid.
 * @param array $response Full decoded API response body.
 * @return bool
 */
function myprefix_domain_whitelist( $is_valid, $response ) {
    if ( ! $is_valid ) {
        return false; // Already invalid -- nothing to override.
    }

    $allowed_domains = array(
        'example.com',
        'staging.example.com',
        'client-site.org',
    );

    $current_host = wp_parse_url( home_url(), PHP_URL_HOST );

    // Allow any *.test or *.local TLD for development.
    if ( preg_match( '/\.(test|local)$/', $current_host ) ) {
        return true;
    }

    if ( ! in_array( $current_host, $allowed_domains, true ) ) {
        error_log( 'LicenceForge: domain not whitelisted -- ' . $current_host );
        return false;
    }

    return $is_valid;
}

Warning

Returning true unconditionally bypasses all licence enforcement. Always include defensive checks and restrict broad bypasses to development environments only.

Custom webhook listeners

LicenceForge fires actions when licences are created through payment integrations. Hook into these to trigger external workflows -- CRM syncs, Slack notifications, mailing list subscriptions, and more.

Example: Stripe sale to Slack

Send a Slack notification every time a new licence is created from a Stripe checkout.

add_action( 'wplf_license_created_from_stripe', 'myprefix_notify_slack_stripe', 10, 2 );

/**
 * Post a Slack message when a Stripe licence is created.
 *
 * @param array $result  Licence data (license_id, license_key, product_slug, etc.).
 * @param array $session Raw Stripe Checkout Session as associative array.
 */
function myprefix_notify_slack_stripe( array $result, array $session ) {
    $webhook_url = defined( 'MY_SLACK_WEBHOOK_URL' )
        ? MY_SLACK_WEBHOOK_URL
        : '';

    if ( empty( $webhook_url ) ) {
        return;
    }

    $message = sprintf(
        "New licence sold via Stripe.\nProduct: %s\nCustomer: %s (%s)\nTier limit: %d sites\nStripe session: %s",
        $result['product_slug'],
        $result['customer_name'],
        $result['customer_email'],
        $result['activation_limit'],
        $session['id']
    );

    wp_remote_post( $webhook_url, array(
        'headers' => array( 'Content-Type' => 'application/json' ),
        'body'    => wp_json_encode( array( 'text' => $message ) ),
        'timeout' => 5,
    ) );
}

Example: WooCommerce sale to CRM

Push new licence data into an external CRM whenever a WooCommerce order generates a licence.

add_action( 'wplf_license_created_from_woocommerce', 'myprefix_sync_crm_wc', 10, 2 );

/**
 * Sync new WooCommerce licence to an external CRM.
 *
 * @param array    $license_data Licence data array.
 * @param WC_Order $order        The WooCommerce order object.
 */
function myprefix_sync_crm_wc( array $license_data, WC_Order $order ) {
    $crm_endpoint = 'https://crm.example.com/api/v1/contacts';
    $crm_api_key  = defined( 'MY_CRM_API_KEY' ) ? MY_CRM_API_KEY : '';

    if ( empty( $crm_api_key ) ) {
        return;
    }

    $payload = array(
        'email'      => $license_data['customer_email'],
        'name'       => $license_data['customer_name'],
        'product'    => $license_data['product_slug'],
        'license_id' => $license_data['license_id'],
        'order_id'   => $order->get_id(),
        'order_total' => $order->get_total(),
        'currency'   => $order->get_currency(),
        'source'     => 'woocommerce',
    );

    $response = wp_remote_post( $crm_endpoint, array(
        'headers' => array(
            'Content-Type'  => 'application/json',
            'Authorization' => 'Bearer ' . $crm_api_key,
        ),
        'body'    => wp_json_encode( $payload ),
        'timeout' => 10,
    ) );

    if ( is_wp_error( $response ) ) {
        error_log( 'CRM sync failed: ' . $response->get_error_message() );
    }
}

Tip

Webhook actions fire during request processing. For slow external calls, schedule them asynchronously with wp_schedule_single_event() to avoid delaying the webhook response.

Custom email templates

LicenceForge sends transactional emails for events like licence creation, expiry warnings, and key rotation. You can override any email template by storing custom HTML in a WordPress option keyed by wplf_email_template_{slug}.

Available template slugs

Slug Triggered when
license_createdA new licence is issued to a customer.
license_expiringA licence is approaching its expiry date.
license_expiredA licence has expired.
license_renewedA subscription renewal extends the licence.
key_rotatedA licence key has been rotated.
trial_startedA free trial has been activated.
trial_endingA trial is about to expire.

Example: override the licence-created template

Store a custom HTML template in the database. LicenceForge replaces placeholders like {{customer_name}}, {{product_name}}, {{license_key}}, and {{activation_limit}} at send time.

add_action( 'init', 'myprefix_register_custom_email_template' );

/**
 * Override the licence-created email template with custom branding.
 */
function myprefix_register_custom_email_template() {
    // Only set once -- check if already customised.
    if ( get_option( 'wplf_email_template_license_created_customised' ) ) {
        return;
    }

    $template = '<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <div style="background: #1a1a2e; color: #ffffff; padding: 24px; text-align: center;">
    <h1 style="margin: 0;">Welcome to MyProduct</h1>
  </div>
  <div style="padding: 24px;">
    <p>Hi {{customer_name}},</p>
    <p>Your licence for <strong>{{product_name}}</strong> is ready.</p>
    <p>Licence key: <code>{{license_key}}</code></p>
    <p>You can activate this licence on up to <strong>{{activation_limit}}</strong> sites.</p>
    <p>Need help? Reply to this email or visit our support portal.</p>
  </div>
</body>
</html>';

    update_option( 'wplf_email_template_license_created', $template );
    update_option( 'wplf_email_template_license_created_customised', true );
}

Note

You can also edit email templates from the LicenceForge admin panel under Settings → Email Templates. The option-based approach shown here is useful for deploying templates programmatically across multiple installations.

Extending the REST API

LicenceForge registers all its endpoints under the wplf/v1 namespace. You can add your own endpoints to this namespace using the standard WordPress register_rest_route() function, giving you access to LicenceForge's authentication and rate-limiting infrastructure.

Example: custom licence statistics endpoint

Register a read-protected endpoint that returns licence counts grouped by status for a given product.

add_action( 'rest_api_init', 'myprefix_register_custom_endpoints' );

/**
 * Register a custom endpoint under the wplf/v1 namespace.
 */
function myprefix_register_custom_endpoints() {
    register_rest_route( 'wplf/v1', '/custom/license-stats/(?P<product_slug>[a-z0-9\-]+)', array(
        'methods'             => 'GET',
        'callback'            => 'myprefix_license_stats_callback',
        'permission_callback' => 'myprefix_license_stats_permissions',
        'args'                => array(
            'product_slug' => array(
                'required'          => true,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_title',
            ),
        ),
    ) );
}

/**
 * Permission check: require an authenticated user with manage_options
 * or a valid LicenceForge API key with read permission.
 *
 * @param WP_REST_Request $request
 * @return bool|WP_Error
 */
function myprefix_license_stats_permissions( WP_REST_Request $request ) {
    if ( current_user_can( 'manage_options' ) ) {
        return true;
    }

    // Check for LicenceForge API key in the Authorization header.
    $auth_header = $request->get_header( 'Authorization' );
    if ( $auth_header && str_starts_with( $auth_header, 'Bearer ' ) ) {
        $key  = substr( $auth_header, 7 );
        $hash = hash( 'sha256', $key );

        global $wpdb;
        $api_key = $wpdb->get_row( $wpdb->prepare(
            "SELECT permissions FROM {$wpdb->prefix}wplf_api_keys
             WHERE api_key_hash = %s AND is_active = 1",
            $hash
        ) );

        if ( $api_key && in_array( $api_key->permissions, array( 'read', 'write', 'admin' ), true ) ) {
            return true;
        }
    }

    return new WP_Error( 'rest_forbidden', 'Authentication required.', array( 'status' => 401 ) );
}

/**
 * Return licence counts grouped by status for a product.
 *
 * @param WP_REST_Request $request
 * @return WP_REST_Response
 */
function myprefix_license_stats_callback( WP_REST_Request $request ) {
    global $wpdb;

    $slug = $request->get_param( 'product_slug' );

    $results = $wpdb->get_results( $wpdb->prepare(
        "SELECT l.status, COUNT(*) AS total
         FROM {$wpdb->prefix}wplf_licenses l
         INNER JOIN {$wpdb->prefix}wplf_products p ON p.id = l.product_id
         WHERE p.slug = %s
         GROUP BY l.status
         ORDER BY total DESC",
        $slug
    ) );

    if ( empty( $results ) ) {
        return new WP_REST_Response( array(
            'product_slug' => $slug,
            'counts'       => new stdClass(),
        ), 200 );
    }

    $counts = array();
    foreach ( $results as $row ) {
        $counts[ $row->status ] = (int) $row->total;
    }

    return new WP_REST_Response( array(
        'product_slug' => $slug,
        'counts'       => $counts,
    ), 200 );
}

Tip

Registering under the wplf/v1 namespace keeps your custom endpoints grouped with the core LicenceForge API. If you prefer a separate namespace, use something like wplf-custom/v1 instead.

Custom analytics

LicenceForge records events in the wplf_analytics table. You can hook into licence lifecycle actions to track custom metrics -- version adoption, geographic distribution, feature usage, and more.

Example: track activation events with metadata

Record enriched activation data every time a licence is activated on a client site.

add_action( 'wplf_client_activated', 'myprefix_track_activation_analytics' );

/**
 * Record a custom analytics event when a licence is activated.
 *
 * @param array $response Decoded API response from the activation endpoint.
 */
function myprefix_track_activation_analytics( $response ) {
    global $wpdb;

    $event_data = array(
        'wp_version'  => get_bloginfo( 'version' ),
        'php_version' => PHP_VERSION,
        'locale'      => get_locale(),
        'multisite'   => is_multisite(),
        'active_theme' => get_stylesheet(),
    );

    $wpdb->insert(
        $wpdb->prefix . 'wplf_analytics',
        array(
            'license_id'  => $response['activation_id'] ?? null,
            'site_origin' => home_url(),
            'event_type'  => 'custom_activation',
            'event_data'  => wp_json_encode( $event_data ),
            'recorded_at' => current_time( 'mysql', true ),
        ),
        array( '%d', '%s', '%s', '%s', '%s' )
    );
}

Example: track feature usage

Log which gated features are being checked and accessed across client sites.

add_filter( 'wplf_client_has_feature', 'myprefix_track_feature_checks', 10, 2 );

/**
 * Log feature checks to the analytics table for usage tracking.
 *
 * @param bool   $has_feature Whether the licence includes the feature.
 * @param string $feature_slug The feature being checked.
 * @return bool Unchanged value.
 */
function myprefix_track_feature_checks( $has_feature, $feature_slug ) {
    // Throttle: only record once per hour per feature to avoid table bloat.
    $cache_key = 'wplf_feature_tracked_' . $feature_slug;
    if ( get_transient( $cache_key ) ) {
        return $has_feature;
    }

    global $wpdb;

    $wpdb->insert(
        $wpdb->prefix . 'wplf_analytics',
        array(
            'site_origin' => home_url(),
            'event_type'  => 'feature_check',
            'event_data'  => wp_json_encode( array(
                'feature'     => $feature_slug,
                'has_access'  => $has_feature,
                'wp_version'  => get_bloginfo( 'version' ),
            ) ),
            'recorded_at' => current_time( 'mysql', true ),
        ),
        array( '%s', '%s', '%s', '%s' )
    );

    set_transient( $cache_key, true, HOUR_IN_SECONDS );

    return $has_feature;
}

Warning

Be mindful of the volume of data written to wplf_analytics. Use transients or rate limiting (as shown above) to prevent excessive row growth on high-traffic sites. Consider a scheduled cleanup cron to purge old records.

Best practices

  • Use a custom plugin. Place extension code in a dedicated mu-plugin or standard plugin so it survives LicenceForge and theme updates.
  • Prefix everything. Use a unique function prefix (e.g. myprefix_) to avoid name collisions with other plugins.
  • Keep hooks fast. Webhook actions fire during HTTP request processing. Offload slow operations (API calls, file I/O) to scheduled events.
  • Log errors. Use error_log() for debugging. Check the Audit Log for server-side events.
  • Test on staging. Always test custom validation filters and webhook listeners on a staging environment before deploying to production.