Skip to content

Developer hooks

The stable filter and action surface for extending B2B Suite — signatures, parameters, and working examples for every supported hook.

Everything on this page is a supported extension point: filters change data and behaviour, actions let you react to events. You should never need to edit a plugin file.

Before you start

Where the code goes. A small custom plugin is better than functions.php — your customisations then survive a theme change. Anything in this document can go straight into either.

Prefixes. Core hooks are smb2b_ and work whenever the free plugin is active. Pro hooks are smb2bpro_ and need B2B Suite Pro. A handful of core filters are marked Pro-aware: they live in the free plugin specifically so Pro — or your code — can extend core behaviour, and they are safe to use either way.

Stability. The hooks listed here are supported across minor versions. Undocumented apply_filters / do_action calls inside the plugin may change without notice. If you need an extension point that is not here, ask and we will consider promoting it.

Namespaces. SoftminalB2B\ for core, SoftminalB2BPro\ for Pro.

Guard for Pro if your code might run on a site without it:

if (apply_filters('smb2b_is_pro', false)) {
    // Pro-only wiring here.
}

Which hook do I need?

I want to…Use
Change the final price a buyer payssmb2b_resolved_price
Bust my own cache when pricing changessmb2b_pricing_data_changed
Hide prices from more peoplesmb2b_hide_prices
Change the “log in to see prices” textsmb2b_hidden_price_message
Add a field to the trade registration formsmb2b_registration_fields
Sync approved buyers to a CRMsmb2b_registration_approved
Add a line or note to the PDF invoicesmb2b_invoice_data
Ship my own invoice designsmb2b_invoice_templates
Save my own option through the settings screensmb2b_editable_settings
Run a daily job alongside the plugin’ssmb2b_housekeeping
Hold certain orders for approvalsmb2bpro_approval_hold_reason
Push ledger entries to accounting softwaresmb2bpro_ledger_posted
React to any quote transitionsmb2bpro_quote_*
Hide extra categories from some buyerssmb2bpro_hidden_category_ids

Pricing

smb2b_resolved_price

apply_filters('smb2b_resolved_price', ResolvedPrice $resolved, array $request): ResolvedPrice

The last word on a product’s price for a given buyer, applied everywhere a price is resolved — catalogue, cart, tier tables, the Price Explainer. It runs on cache hits as well as fresh resolutions, so your override is never skipped and you never have to think about the pricing cache.

ParameterTypeNotes
$resolvedResolvedPriceThe resolved price plus the trail of levels considered
$requestarrayproduct_id, variation_id, qty, user_id, group_id, company_id

Return the original $resolved to leave it alone, or a new one to override. Anything else is ignored and the original is kept — use withPrice() rather than constructing a ResolvedPrice yourself, so the trail stays intact.

add_filter('smb2b_resolved_price', function ($resolved, $request) {
    // An extra 5% for one strategic account, on top of whatever rule won.
    if ($request['company_id'] === 42) {
        return $resolved->withPrice($resolved->price() * 0.95);
    }

    return $resolved;
}, 10, 2);

A second example — round every trade price down to the nearest whole unit, which trade customers often prefer on invoices:

add_filter('smb2b_resolved_price', function ($resolved, $request) {
    if ($request['group_id'] === null) {
        return $resolved; // retail, leave it alone
    }

    return $resolved->withPrice(floor($resolved->price()));
}, 20, 2);

smb2b_pricing_data_changed

do_action('smb2b_pricing_data_changed'): void

Fires whenever a pricing input changes — a rule, a group, or a company membership created, updated or deleted. There is no payload; treat it as “prices may differ now”.

add_action('smb2b_pricing_data_changed', function () {
    my_cache_flush('catalog-prices');
    wp_cache_delete('my_price_table', 'my_plugin');
});

smb2b_quantity_rule_for_product — Pro-aware

apply_filters(
    'smb2b_quantity_rule_for_product',
    ?QuantityRule $rule,
    int $productId,
    ?int $variationId,
    ?int $groupId,
    int $userId
): ?QuantityRule

Supply a minimum/maximum/pack-size rule from a broader scope when no product-specific rule matched. Core resolves only the product scope; Pro uses this filter to add category rules. Precedence is preserved by convention: if $rule is already set, a product-specific rule won — return it untouched.

add_filter('smb2b_quantity_rule_for_product', function ($rule, $productId, $variationId, $groupId, $userId) {
    if ($rule) {
        return $rule;
    }

    return my_category_quantity_rule_for($productId, $groupId);
}, 10, 5);

smb2b_pricing_level_{level} — Pro-aware

Injects an entire precedence level into the resolver walk. {level} is the level key (quote, customer, company, taxonomy). This is how Pro adds its levels without patching core, and it is the advanced seam — read Pricing\Resolver::levels() before using it.

smb2b_pricing_context_company / smb2b_pricing_context_user — Pro-aware

Supply the company (or the effective user) that pricing should resolve for. Pro’s Companies module answers the first with ['company_id' => int, 'group_id' => int|null]; view-as-customer uses the second.

// Price everyone in a legacy CRM account as if they were company 7.
add_filter('smb2b_pricing_context_company', function ($context, $userId) {
    if ($context !== null) {
        return $context;
    }

    $legacyId = get_user_meta($userId, 'legacy_account', true);

    return $legacyId ? ['company_id' => 7, 'group_id' => null] : null;
}, 10, 2);

smb2b_max_groups — Pro-aware

The cap on customer groups. Free returns a finite number; Pro filters it to 0, which means unlimited.

smb2b_rule_set_bulk_threshold

The rule count above which the resolver switches from bulk-loading rules to querying per product. Tune only if you are profiling a very large catalogue.

smb2b_skip_price_filters

Return true to leave WooCommerce’s native prices untouched for the current request — useful in an export or a REST context where you want list prices. Use sparingly: it disables all B2B pricing while active.

add_filter('smb2b_skip_price_filters', function ($skip) {
    return $skip || (defined('DOING_CRON') && DOING_CRON && my_is_feed_generation());
});

Price and catalogue visibility

smb2b_hide_prices — Pro-aware

apply_filters('smb2b_hide_prices', bool $hidden): bool

Whether prices are hidden for the current visitor. Core hides from guests; Pro extends it to catalogue mode and per-group visibility.

add_filter('smb2b_hide_prices', function ($hidden) {
    // Also hide from logged-in users who are not approved trade customers.
    if (!$hidden && is_user_logged_in()) {
        return !SoftminalB2B\Registration\Applicant::isApproved(get_current_user_id());
    }

    return $hidden;
});

smb2b_hidden_price_message

The HTML shown in place of a hidden price. It is wp_kses_post-filtered before output, but return trusted markup regardless.

add_filter('smb2b_hidden_price_message', fn () => 'Trade pricing — <a href="/apply/">apply for an account</a>');

Registration and approval

smb2b_registration_fields

apply_filters('smb2b_registration_fields', array $fields): array

The business fields on the registration form. Each entry is keyed by field name:

'company_name' => [
    'label'    => 'Company name',
    'required' => true,
    'type'     => 'text', // text | tel | textarea
]

Submitted values are stored together in a single smb2b_business user-meta array and shown to the admin on the approval screen. Read them back with Applicant::business($userId) rather than looking for one meta key per field.

add_filter('smb2b_registration_fields', function ($fields) {
    $fields['reseller_number'] = [
        'label'    => 'Reseller certificate #',
        'required' => true,
        'type'     => 'text',
    ];

    // Make the phone number optional for our market.
    $fields['phone']['required'] = false;

    return $fields;
});

smb2b_registration_spam_check

Return true to reject a submission as spam before it is stored. The honeypot and minimum fill time already run; this is where a captcha or a rate limiter goes.

add_filter('smb2b_registration_spam_check', function ($isSpam, $data) {
    if ($isSpam) {
        return true;
    }

    return my_recaptcha_failed($_POST['g-recaptcha-response'] ?? '');
}, 10, 2);

smb2b_registration_submitted

Fires after a valid application is stored with status pending. Receives the user id.

smb2b_registration_approved

do_action('smb2b_registration_approved', int $userId, int $groupId): void

The pricing group is already assigned by the time this runs.

add_action('smb2b_registration_approved', function ($userId, $groupId) {
    my_crm_tag_user($userId, 'b2b-approved');
    my_slack_notify(sprintf('%s approved for trade', get_userdata($userId)->user_email));
}, 10, 2);

smb2b_registration_rejected

Receives ($userId, $reason). Any B2B pricing has already been removed by the time it fires.


Invoices and PDFs

smb2b_invoice_data

apply_filters('smb2b_invoice_data', array $data, WC_Order $order): array

The assembled invoice data, before it reaches the template. This is the right seam for extra totals rows, altered seller details or additional line-item columns.

add_filter('smb2b_invoice_data', function ($data, $order) {
    $data['note'] = 'Thank you for your business — terms net 30.';

    if ($po = $order->get_meta('_smb2b_po_number')) {
        $data['note'] = "PO {$po}\n" . $data['note'];
    }

    return $data;
}, 10, 2);

smb2b_invoice_templates

The selectable invoice designs as slug => label. Add your own slug here and put the matching slug.php view in the templates directory — or return the HTML from smb2b_invoice_html instead.

smb2b_invoice_html — Pro-aware

Receives (null, $order, $data). Return a non-empty string to bypass the built-in templates entirely, or null to keep the default rendering.


Settings

smb2b_editable_settings — Pro-aware

apply_filters('smb2b_editable_settings', array $editable): array

The map of key => type that the settings endpoint will read and write. Types: bool, int, text, textarea, email, html.

Keys not declared here are silently ignored, so this filter doubles as the allow-list that stops the settings endpoint writing arbitrary options.

add_filter('smb2b_editable_settings', function ($editable) {
    $editable['my_addon_option'] = 'bool';
    $editable['my_addon_intro']  = 'textarea';

    return $editable;
});

Scheduled jobs

smb2b_housekeeping

Fires on the plugin’s daily maintenance run through Action Scheduler, tagged smb2b. Hook your own periodic cleanup here instead of scheduling a separate cron — it is cancelled cleanly on uninstall along with everything else.

add_action('smb2b_housekeeping', function () {
    my_prune_stale_exports();
});

smb2b_fixtures_generated / smb2b_fixtures_reset

Fire after the benchmark fixture generator creates or clears its catalogue. Test setups only.

smb2b_seed_generated / smb2b_seed_reset

Fire at the end of wp smb2b seed run and wp smb2b seed reset. seed_generated receives the run’s Manifest.

Record anything you create on the manifest — record() for post, user and term ids, recordRow($table, $id) for rows in the plugin’s own tables — and seed reset will remove it for you. Reset deletes only what the manifest lists; it never truncates a table.


Companies (Pro)

smb2bpro_company_membership_changed

Fires when a user joins, leaves, or changes role in a company. It also triggers smb2b_pricing_data_changed, because membership affects pricing.

smb2bpro_register_modules

Fires at the end of Pro’s boot (plugins_loaded priority 11), after core is populated and before rest_api_init. This is the seam for a third-party add-on to register its own modules and REST routes into the shared smb2b/v1 namespace.

add_action('smb2bpro_register_modules', function () {
    My\Addon\Module::init();
});

Order approvals and spend limits (Pro)

smb2bpro_approval_hold_reason

apply_filters('smb2bpro_approval_hold_reason', ?string $reason, WC_Order $order): ?string

Whether — and why — an order is held for approval. The default holds orders over a buyer’s per-order or monthly limit. Return a non-empty string to hold for your own reason, or null to release an order the limits would have held.

add_filter('smb2bpro_approval_hold_reason', function ($reason, $order) {
    // Always review the first order from a brand-new company.
    if ($reason === null && my_is_first_company_order($order)) {
        return 'First order from this company — please review.';
    }

    return $reason;
}, 10, 2);

smb2bpro_order_held / _approved / _rejected

  • held($orderId, $reason). Status has moved to the approval status, stock is reserved, approvers have been notified.
  • approved($orderId, $actorId). This is the seam net terms hooks to complete eligible orders on account rather than emailing a payment link.
  • rejected($orderId, $actorId). Stock has already been released.

Net terms and credit (Pro)

smb2bpro_ledger_posted

do_action('smb2bpro_ledger_posted', CreditTransaction $transaction, int $accountId): void

Fires whenever a row is appended to the ledger — a debit, payment, refund credit or adjustment. The ledger is append-only: react to entries, never mutate them. Calling delete() on a transaction is refused; record a reversing adjustment instead.

The entry carries type (debit | credit | adjustment), amount (a signed delta to what is owed — debits positive, credits and refunds negative), order_id, refund_id, due_date, note and created_by.

add_action('smb2bpro_ledger_posted', function ($transaction, $accountId) {
    my_accounting_api()->postJournalEntry([
        'account' => $accountId,
        'type'    => $transaction->type,
        'amount'  => $transaction->amount,
        'order'   => $transaction->order_id,
        'memo'    => $transaction->note,
    ]);
}, 10, 2);

smb2bpro_net_terms_order_placed

($orderId, $accountId) — fires when an order is confirmed on account. The debit is already posted and the due date stamped.

smb2bpro_invoice_overdue

($orderId, $daysLate) — fires each time an overdue reminder goes out. Use it to escalate beyond the built-in email cadence.

add_action('smb2bpro_invoice_overdue', function ($orderId, $daysLate) {
    if ($daysLate >= 30) {
        my_crm_create_task("Call about overdue order #{$orderId}");
    }
}, 10, 2);

smb2bpro_statement_sent

($accountId, $balance) — fires after a monthly statement email is sent.


Quotes / RFQ (Pro)

Every transition fires an action carrying the quote id, so you can sync a CRM or trigger automation at any point in the lifecycle.

HookPayloadFires when
smb2bpro_quote_submitted($quoteId)A buyer submits their basket for review
smb2bpro_quote_offered($quoteId)You send a counter-offer with line prices and an expiry
smb2bpro_quote_accepted($quoteId, $orderId)The buyer accepted and the order was created
smb2bpro_quote_declined($quoteId, $byUserId)Either party declined
smb2bpro_quote_expired($quoteId)An offer passed its expiry date on the daily sweep
smb2bpro_quote_message($quoteId, $userId)A message was posted to the thread

smb2bpro_quote_accepted is the seam for attaching quote-specific data to the new order:

add_action('smb2bpro_quote_accepted', function ($quoteId, $orderId) {
    $order = wc_get_order($orderId);
    $order->update_meta_data('_my_quote_ref', "Q-{$quoteId}");
    $order->add_order_note(sprintf('Converted from quote #%d.', $quoteId));
    $order->save();
}, 10, 2);

Catalogue visibility (Pro)

smb2bpro_hidden_category_ids

apply_filters('smb2bpro_hidden_category_ids', array $hiddenIds, int $userId): array

The product_cat term ids hidden from a viewer, applied to every product query — shop, search, feeds and the Store API. $userId is 0 for a guest. Add ids to hide more, remove ids to reveal.

add_filter('smb2bpro_hidden_category_ids', function ($hidden, $userId) {
    if ($userId === 0) {
        $hidden[] = my_members_only_category_id();
    }

    return $hidden;
}, 10, 2);

Storefront (Pro)

smb2bpro_tier_table_rows

apply_filters('smb2bpro_tier_table_rows', array $rows, WC_Product $product): array

The quantity-tier rows before the bulk-pricing table renders. Each row is ['from' => int, 'to' => int|null, 'price' => float, 'price_html' => string]. Return [] to suppress the table for a product.

add_filter('smb2bpro_tier_table_rows', function ($rows, $product) {
    if (has_term('clearance', 'product_cat', $product->get_id())) {
        return [];
    }

    return $rows;
}, 10, 2);

Tax and EU VAT (Pro)

smb2bpro_vies_response

apply_filters('smb2bpro_vies_response', ?string $override, string $countryCode, string $number): ?string

Short-circuit the live VIES call. Return 'valid', 'invalid' or 'unavailable' to skip the network entirely — for tests, or to point at a self-hosted VIES proxy. Return null to let the real lookup happen.

add_filter('smb2bpro_vies_response', function ($override, $country, $number) {
    return my_vies_proxy_check($country, $number);
}, 10, 3);

Recipes

Give one company a contract price on a single product

Rules cover almost every case — reach for code only when the logic is genuinely dynamic:

add_filter('smb2b_resolved_price', function ($resolved, $request) {
    $isTargetLine = $request['company_id'] === 42 && $request['product_id'] === 1180;

    if ($isTargetLine && $request['qty'] >= 100) {
        return $resolved->withPrice(8.75); // negotiated pallet price
    }

    return $resolved;
}, 10, 2);

Post every new trade application to Slack

use SoftminalB2B\Registration\Applicant;

add_action('smb2b_registration_submitted', function ($userId) {
    $user    = get_userdata($userId);
    // Every business field lives in one meta array — read it through the model.
    $company = Applicant::business($userId)['company_name'] ?? '';

    wp_remote_post(MY_SLACK_WEBHOOK, [
        'body'     => wp_json_encode([
            'text' => sprintf('New trade application: %s (%s)', $company, $user->user_email),
        ]),
        'headers'  => ['Content-Type' => 'application/json'],
        'blocking' => false, // never make a visitor wait on Slack
    ]);
});

Mirror the credit ledger into an accounting system

Because the ledger is append-only and idempotent per order and refund, a one-way mirror stays correct without reconciliation:

add_action('smb2bpro_ledger_posted', function ($transaction, $accountId) {
    as_enqueue_async_action('my_push_ledger_entry', [$transaction->id, $accountId], 'my-plugin');
}, 10, 2);

add_action('my_push_ledger_entry', function ($transactionId, $accountId) {
    // Runs out of band via Action Scheduler, retried on failure — so a slow
    // accounting API never holds up a checkout.
    my_accounting_api()->post($transactionId, $accountId);
}, 10, 2);

Add a field to registration and use it to auto-assign a group

add_filter('smb2b_registration_fields', function ($fields) {
    $fields['trade_type'] = [
        'label'    => 'What do you do?',
        'required' => true,
        'type'     => 'text',
    ];

    return $fields;
});

add_action('smb2b_registration_approved', function ($userId, $groupId) {
    $business = SoftminalB2B\Registration\Applicant::business($userId);

    if (strtolower($business['trade_type'] ?? '') === 'installer') {
        my_move_user_to_group($userId, MY_INSTALLER_GROUP_ID);
    }
}, 10, 2);

Check what a customer would pay, from your own code

You do not need a hook for this — call the resolver:

use SoftminalB2B\Pricing\Resolver;

// resolve(int $productId, ?int $variationId, ?int $userId = null, int $qty = 1)
$resolved = Resolver::resolve(1180, null, 45, 10);

$resolved->price();   // what they pay
$resolved->source();  // which rule won
$resolved->trail();   // every level considered, in order

The same data is available over HTTP — see the REST API.


Debugging

  • A filter seems to have no effect. Check the argument count. add_filter('smb2b_resolved_price', $fn) without the trailing 10, 2 means your callback never receives $request, and a null argument error usually follows.
  • A price is not what you expect. Use Pricing → Check a price before reading any code — it shows the whole precedence walk including your filter’s effect, because smb2b_resolved_price runs inside the resolution the explainer reports on.
  • Your override works on the product page but not in the cart. That should not happen with smb2b_resolved_price, which runs at every resolution. If you see it, you are probably filtering a WooCommerce price hook directly — move to the B2B filter.
  • Nothing fires at all. Confirm the plugin is active and, for smb2bpro_ hooks, that Pro is too. Loading order matters: register Pro hooks on smb2bpro_register_modules or later.

Conventions

  • Filters returning HTML are escaped or wp_kses_post-filtered before output — return trusted markup regardless.
  • Money values are in the store’s base currency. Convert for display, never for storage.
  • Pro-aware core filters work whether or not Pro is installed.
  • If you need an extension point that is not here, ask — that is how most of these got added.

Was this page missing something?

Documentation gaps are treated as bugs here. Tell us →