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 pays | smb2b_resolved_price |
| Bust my own cache when pricing changes | smb2b_pricing_data_changed |
| Hide prices from more people | smb2b_hide_prices |
| Change the “log in to see prices” text | smb2b_hidden_price_message |
| Add a field to the trade registration form | smb2b_registration_fields |
| Sync approved buyers to a CRM | smb2b_registration_approved |
| Add a line or note to the PDF invoice | smb2b_invoice_data |
| Ship my own invoice design | smb2b_invoice_templates |
| Save my own option through the settings screen | smb2b_editable_settings |
| Run a daily job alongside the plugin’s | smb2b_housekeeping |
| Hold certain orders for approval | smb2bpro_approval_hold_reason |
| Push ledger entries to accounting software | smb2bpro_ledger_posted |
| React to any quote transition | smb2bpro_quote_* |
| Hide extra categories from some buyers | smb2bpro_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.
| Parameter | Type | Notes |
|---|---|---|
$resolved | ResolvedPrice | The resolved price plus the trail of levels considered |
$request | array | product_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.
| Hook | Payload | Fires 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 trailing10, 2means your callback never receives$request, and anullargument 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_priceruns 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 onsmb2bpro_register_modulesor 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 →