Hooks: Moderation, Trust, and Authentication
The action and filter seams for content moderation (reports, removals, strikes, suspensions, shadow bans, appeals), the automated content-safeguard pipeline, and the authentication surface (two-factor, registration spam control, email verification, social login). This page is for developers building moderation tooling, anti-spam integrations, trust-and-safety dashboards, or custom sign-in providers. Every hook below is fired or applied by BuddyNext Free, so it is available without Pro - the same seams are where BuddyNext Pro’s Moderation Rules engine plugs in.

Overview / Contract
Section titled “Overview / Contract”- Actions fire after the state change commits. A removal, strike, or suspension action runs after the database write succeeds. Listeners that need the full row should re-fetch by ID through the relevant service (for example
buddynext_service( 'moderation' )), not reconstruct it from the passed scalars. - The safeguard pipeline is a single filter. Every automated content rule - banned words, blocked links, rate limits, duplicate holds, the new-member gate, and Pro’s keyword/ML blocklists - resolves through one filter,
buddynext_safeguard_check. It runs on create and on edit. Return aWP_Errorto block, or pass the value through to allow. - Registration filters are gates or scores.
buddynext_spam_protection_enabledandbuddynext_registration_challenge_enabledreturn booleans;buddynext_registration_spam_scorereturns an integer score; the domain-list filters return arrays. BuddyNext never blocks a sign-up by calling addon code directly - it reads these values. - Two-factor is advisory, never a hard block.
buddynext_2fa_required_rolessurfaces a UI hint for the listed roles; BuddyNext does not refuse sign-in for a member who has not enabled 2FA. - The code is the contract. Free ships no
docs/specs/HOOKS.md; an earlier revision of this page cited one as a “locked” source of truth, and it does not exist. Where a signature below differs from the livedo_action()/apply_filters()call site, the live code wins.
The safeguard pipeline: buddynext_safeguard_check
Section titled “The safeguard pipeline: buddynext_safeguard_check”This is the single most important seam on this page. Every post (and every edit) runs through SafeguardService, which executes the built-in automated checks in order and then applies buddynext_safeguard_check as the final gate. This is the extension point BuddyNext Pro’s Moderation Rules engine hooks to add keyword blocklists and ML scoring, and the same seam your own anti-spam logic plugs into.
The built-in checks that run before the filter, in order (SafeguardService::check()):
- Blocked IP (option
buddynext_blocked_ips) - Banned words, site-wide (option
buddynext_banned_words) and per-space (the space’s ownbanned_wordsfield, read throughbuddynext_get_space_field()) - Blocked link domains (option
buddynext_blocked_domains) - Post rate limit per user (option
buddynext_post_rate_limit) - Banned hashtags (resolved through
HashtagService::first_banned_in_text())
The filter runs here. Two gates run after it, and both return a hold-for-review verdict rather than a rejection:
- Duplicate-content hold (option
buddynext_duplicate_post_window) - New-member review gate (option
buddynext_new_member_post_threshold)
The filter is deliberately placed before 6 and 7: a hold must never outrank a hard block, so a Pro severity=block rule wins over a new-member hold.
On a create, all of the above run. On an edit, SafeguardService::check_content() re-runs only the content-based checks (banned words, blocked domains, banned hashtags), because the rate-limit, duplicate, and new-member gates are create-time concerns. Either way, buddynext_safeguard_check runs, so your filter applies to both create and edit.
Because it runs on both, the filter is passed a final $context argument - 'create' or 'edit' - so your callback can draw the same line the built-ins do. If your rule is a “how many/how often” rule, check it. Anything counting an author’s recent activity (rate limits, flood control, cooldowns) must skip 'edit': an edit is not a new post, and re-asking the question there means an author who has hit your cap can no longer edit the posts they already published. Content rules (banned words, links, ML scoring) should keep running on edits, or editing becomes a way to smuggle content past you.
| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_automatic_sanction_failed |
action | An automatic sanction (strike, suspension, shadow-ban) could not be applied. Fires so a site can alert a human rather than let the moderation rule fail silently — the report stays open and nothing was applied. | int $user_id, string $sanction, string $reason |
buddynext_safeguard_check |
filter | A post is about to be saved (create or edit), after the built-in automated checks pass | true|WP_Error $result, int $user_id, string $content, string $link_url, string $context |
buddynext_client_ip |
filter | The safeguard service resolves the request IP for the blocked-IP check | string $ip |
buddynext_report_reasons |
filter | The report reason list is built (default: spam, harassment, misinformation, inappropriate, fake, impersonation, other) |
string[] $reasons |
buddynext_moderation_auto_actions |
filter | A report has just been inserted, deciding which automated actions to apply (Free returns empty; Pro stacks actions here) | array $actions, array $report |
Note:
$resultistruewhen every built-in check passed. To block content, return aWP_Error. To allow it, return$resultunchanged. Returning a non-WP_Error, non-truevalue is treated as allow. Thepending_reviewerror code is intentionally non-fatal upstream - callers save the post with a pending status rather than discarding it - so reserveWP_Errorreturns from your filter for content you genuinely want rejected.
Auto-action shapes for buddynext_moderation_auto_actions
Section titled “Auto-action shapes for buddynext_moderation_auto_actions”Each entry in the returned array is an associative array with at least an action key. The supported shapes:
array( 'action' => 'remove', 'reason' => 'string' );array( 'action' => 'warn', 'user_id' => 123, 'reason' => 'string' );array( 'action' => 'suspend', 'user_id' => 123, 'reason' => 'string', 'duration_days' => 7 );Pre-moderation (1.1.6)
Section titled “Pre-moderation (1.1.6)”Pre-moderation holds a post for review before it appears. It is developer-only by design: there is no owner setting, and the retired buddynext_premod_mode option is deliberately not read. Honouring a stale stored value would leave a site silently holding posts with no UI to turn it off or to find them, so the mode comes from a filter or not at all.
| Hook | Type | Default | Parameters |
|---|---|---|---|
buddynext_premod_mode |
filter | 'off' |
string $mode - one of off, new_members, links, all |
buddynext_premod_new_member_count |
filter | 1 |
int $limit - how many of a new member’s first posts to hold |
Hold the first three posts from every new member:
add_filter( 'buddynext_premod_mode', fn() => 'new_members' );add_filter( 'buddynext_premod_new_member_count', fn() => 3 );Held posts surface in the moderation queue’s Pending tab. Turning the mode back to off releases nothing on its own - anything already held still needs a decision there, which is the reason this is not a setting an owner can toggle blind.
Moderation event actions
Section titled “Moderation event actions”These fire after a moderator (or an auto-action) acts on content or a member. Trust-and-safety integrations and gamification penalties hook these.
| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_report_created |
action | A member submits a report | int $report_id, string $object_type, int $object_id, int $reporter_id |
buddynext_content_removed |
action | Reported content is removed (by a moderator or an auto-action) | string $object_type, int $object_id, int $actor_id |
buddynext_post_auto_hidden |
action | A post reaches the report threshold and is put “Under review” (reversible; not a takedown) | int $post_id |
buddynext_post_restored |
action | An auto-hidden post is restored when its reports are cleared | int $post_id, int $actor_id |
buddynext_comment_hidden |
action | A comment reaches the report threshold and is put “Under review” (the comment mirror of buddynext_post_auto_hidden) |
int $comment_id, int $actor_id (actor 0 = the automatic threshold) |
buddynext_comment_restored |
action | An auto-hidden comment is restored when its reports are cleared | int $comment_id, int $actor_id |
buddynext_user_warned |
action | A member is issued a warning | int $user_id, int $actor_id, string $reason |
buddynext_strike_issued |
action | A strike is recorded against a member | int $strike_id, int $user_id, int $actor_id |
buddynext_user_suspended |
action | A member is suspended | int $user_id, int $actor_id, string $reason, ?string $expires_at |
buddynext_user_unsuspended |
action | A suspension is lifted | int $user_id |
buddynext_member_suspended |
action | Member-domain mirror of a suspension | int $user_id, int $by_user_id |
buddynext_member_unsuspended |
action | Member-domain mirror of an unsuspension | int $user_id, int $by_user_id from every call site, including the wp-admin Members screen. That screen used to fire $user_id alone, which killed a typed two-parameter listener with an ArgumentCountError; it now passes get_current_user_id() as the actor. No default is needed. |
buddynext_user_shadow_banned |
action | A member is shadow-banned (their content stays visible only to themselves) | int $user_id, int $actor_id, string $reason |
buddynext_user_shadow_ban_removed |
action | A shadow ban is lifted | int $user_id, int $actor_id |
buddynext_appeal_submitted |
action | A member appeals a moderation decision | int $user_id, int $appeal_id, string $type, int $suspension_id |
buddynext_appeal_resolved |
action | An appeal is decided | int $appeal_id, int $user_id, string $decision |
buddynext_space_user_banned |
action | A member is banned from a space | int $space_id, int $user_id, int $banned_by |
buddynext_space_user_unbanned |
action | A space ban is lifted | int $space_id, int $user_id |
Note: Some events have two flavours. The
buddynext_user_*suspension events are the moderation-domain canonical events; thebuddynext_member_*variants are the member-domain mirrors fired from the admin Members screens. Hook whichever matches where you need to react; do not assume both fire on every code path.
Reporter-side visibility (1.1.6)
Section titled “Reporter-side visibility (1.1.6)”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_reporter_hidden_statuses |
filter | Building a member’s feed, resolving which report statuses keep the reported content hidden from the member who reported it | string[] $statuses, int $viewer_id |
Reporting something removes it from the reporter’s own feed, and by default it stays gone for every report status - including dismissed and resolved.
That default is deliberate. Reporting is a statement about what the member wants in their own feed, and a moderator disagreeing about the content does not change what that member asked for. Dismissing a report therefore does not put the post back in front of the person who reported it. This matches Facebook, X and Instagram, and it matches the promise the UI already makes when it removes the card on report.
An owner who reads a dismissal as “we checked, it is fine, show it again” can drop dismissed (and/or resolved) from the list:
add_filter( 'buddynext_reporter_hidden_statuses', function ( array $statuses, int $viewer_id ): array { // Treat a dismissed report as "no longer hidden" on this site. return array_values( array_diff( $statuses, array( 'dismissed' ) ) );}, 10, 2 );This filter only affects the reporter’s own view. It never reveals content moderation has actually removed, and it never hides content from anyone else.
Moderation-queue render seams
Section titled “Moderation-queue render seams”The admin moderation queue and the member-facing report modal expose theming seams so you can add columns, row actions, or panel content without forking the templates.
| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_mod_queue_columns |
filter | The moderation-queue table header is built | array $columns |
buddynext_mod_queue_row_actions |
action | A moderation-queue row’s action cell renders | row context args |
buddynext_moderation_queue_before |
action | Before the moderation-queue list renders | - |
buddynext_part_member_report_modal_before / _after |
action | Around the member report modal markup | array $args |
buddynext_part_member_report_modal_args / _classes |
filter | Shape the report modal’s args / wrapper classes | array $args / array $classes, array $args |
buddynext_part_space_settings_panel_moderation_before / _after |
action | Around the space moderation settings panel | array $args |
Tip: The
_part_*modal and panel seams follow the same four-hook contract as every other BuddyNext template part (_before,_after,_args,_classes). For the full convention, see Hooks: Template Parts.
Authentication: two-factor
Section titled “Authentication: two-factor”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_2fa_enabled |
action | A member turns on two-factor authentication | int $user_id |
buddynext_2fa_disabled |
action | A member turns off two-factor authentication | int $user_id |
buddynext_2fa_required_roles |
filter | Deciding whether 2FA is advised for a user (default: empty = advised for nobody) | array $roles |
buddynext_2fa_issuer |
filter | Building the otpauth:// provisioning URI; sets the label shown in the authenticator app (default: site name) |
string $issuer |
Authentication: registration and spam control
Section titled “Authentication: registration and spam control”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_spam_protection_enabled |
filter | Deciding whether registration spam protection runs | bool $enabled |
buddynext_register_rate_limit |
filter | Resolving the max registrations per window (default from option buddynext_reg_rate_limit) |
int $max |
buddynext_registration_spam_score |
filter | Scoring a registration attempt; higher is spammier | int $score, array $ctx |
buddynext_registration_blocked |
action | A registration is rejected as spam | array $ctx, int $score |
buddynext_registration_honeypot_field |
filter | The honeypot field name on the sign-up form (default bn_website) |
string $field |
buddynext_registration_allowed_domains |
filter | The email-domain allowlist for sign-up | array $allowed, string $email |
buddynext_disposable_domains |
filter | The disposable-email-domain blocklist | array $domains |
buddynext_registration_challenge_enabled |
filter | Whether a registration challenge (captcha-style) is shown | bool $on |
buddynext_registration_pending |
action | A new account is created but awaits verification or approval | int $user_id, string $email |
buddynext_registration_fields_saved |
action | Custom registration field values are stored | int $user_id, array $values, array $fields |
buddynext_member_approved |
action | A pending registration is approved | int $user_id |
Authentication: email verification and social login
Section titled “Authentication: email verification and social login”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_send_verification_email |
action | A verification email is about to be sent | int $user_id, string $token_url |
buddynext_user_verified |
action | A member completes verification | int $user_id |
buddynext_email_verified |
action | A member’s email address is confirmed via the verify link | int $user_id |
buddynext_email_change_requested |
action | A member requests an email-address change (pending confirmation) | int $user_id, string $new_email |
buddynext_email_changed |
action | An email-address change is confirmed | int $user_id, string $new_email |
buddynext_oauth_providers |
filter | The OAuth provider definitions are assembled | array $providers |
buddynext_auth_social_providers |
filter | The social provider buttons rendered on login / signup / connected-accounts | array $providers |
buddynext_social_icon |
filter | A social-LOGIN provider’s button icon is resolved (login / signup / connected accounts). It does NOT affect the profile hero’s social-link chips - those icons are a hardcoded map with no filter | string $icon, string $provider_id |
buddynext_social_user_created |
action | A new account is created from a social login | int $user_id, string $provider_id, array $profile |
Authentication: private community access (1.0.7)
Section titled “Authentication: private community access (1.0.7)”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_private_community_can_access |
filter | Deciding whether the current visitor may access the community, while Private Community mode is on (Settings → Privacy & Data → Private Community) - gates both the hub-page redirect and the REST 401 response | bool $can_access (default: is_user_logged_in()) |
PrivateCommunity::can_access() is the single access seam for membership plugins. By default it grants access to any logged-in visitor. A membership plugin (Paid Memberships Pro, WP Fusion, MemberPress) filters this to decide on its own terms - for example, requiring an active plan or a required tag, not just a login. Return false to send a visitor to the login (or upgrade) page like a guest; return true to grant access. The filter only runs when Private Community mode is enabled - it has no effect while the community is public.
// Require an active "pro" membership tag, not just a login, once Private// Community mode is on.add_filter( 'buddynext_private_community_can_access', function ( bool $can_access ): bool { if ( ! is_user_logged_in() ) { return false; } return (bool) wpf_has_tag( get_current_user_id(), 'pro-member' ); });Authentication: login and logout redirects (1.0.7)
Section titled “Authentication: login and logout redirects (1.0.7)”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_redirect_url |
filter | Resolving the login / logout / onboarding redirect URL, after the owner’s Settings > Registration & Login value (or the built-in default) has already been applied - runs last, so it has the final say | string $url, string $context, string $fallback |
buddynext_content_removal_handled |
filter | Moderation asks whether reported content was actually taken down. Core answers for post, comment and message; return true for an object type you own |
bool $handled, string $object_type, int $object_id, int $actor_id |
buddynext_private_community_exempt_routes |
filter | The private-community gate resolves which route prefixes are exempt, for callers that are legitimately never logged in | string[] $exempt, WP_REST_Request $request |
buddynext_terms_consent_recorded |
action | A member’s terms consent is recorded. Hook it if you owe a stricter compliance duty, such as a written audit log or an external consent store | int $user_id, int $terms_page, string $source |
RedirectSettings::resolve() is the one place every login, logout, and onboarding redirect passes through - the BuddyNext auth hub, wp-login.php, a theme login form, and programmatic sign-in all resolve through it. $context is one of 'login', 'logout', or 'onboarding'; $fallback is the built-in default for that context (for example, the activity feed for login). Send a member to a custom dashboard or an external portal without touching the settings UI:
add_filter( 'buddynext_redirect_url', function ( string $url, string $context, string $fallback ): string { if ( 'login' === $context ) { return home_url( '/dashboard/' ); } return $url; }, 10, 3);Note: WordPress’s own
wp_safe_redirect()at the apply point is the safety net - an off-site target still needsallowed_redirect_hostsor it falls back to$fallback.
Examples
Section titled “Examples”Add a custom safeguard via buddynext_safeguard_check
Section titled “Add a custom safeguard via buddynext_safeguard_check”The example below blocks any post that contains more than two links, on top of BuddyNext’s built-in checks. Because the filter runs on both create and edit, this rule applies to edited posts too.
add_filter( 'buddynext_safeguard_check', function ( $result, $user_id, $content, $link_url ) { // Respect any earlier rejection (built-in check or another plugin). if ( is_wp_error( $result ) ) { return $result; }
// Trusted roles bypass the rule. if ( user_can( $user_id, 'manage_options' ) ) { return $result; }
// Reject posts with more than two URLs - a common spam pattern. if ( preg_match_all( '#https?://#i', $content ) > 2 ) { return new WP_Error( 'too_many_links', __( 'Posts can include at most two links. Please remove some and try again.', 'my-ext' ) ); }
return $result; }, 10, 4);Warning: Always return
$resultunchanged when you do not want to block, and short-circuit on an existingWP_Errorso you do not mask a rejection from a higher-priority rule (including BuddyNext Pro’s Moderation Rules). Returningtrueunconditionally would silently override every other safeguard.
Penalise the recipient on a strike (gamification)
Section titled “Penalise the recipient on a strike (gamification)”add_action( 'buddynext_strike_issued', function ( $strike_id, $user_id, $actor_id ) { // Deduct trust points from the struck member. my_gamification_adjust_points( $user_id, -50, 'moderation_strike' ); }, 10, 3);Reject sign-ups from a disposable-email domain you maintain
Section titled “Reject sign-ups from a disposable-email domain you maintain”add_filter( 'buddynext_disposable_domains', function ( $domains ) { $domains[] = 'throwaway.example'; return $domains; });Notes and gotchas
Section titled “Notes and gotchas”- The safeguard filter does not see the post ID - the post has not been saved yet on create. If you need post context, key your logic off
$user_idand$content. buddynext_moderation_auto_actionsis empty in Free. Returning actions from it is how Pro (or you) drive automatic remove/warn/suspend off a report. Each action you add executes immediately after the report row is written, so guard against double-acting on the same report.- Suspension and member events come in pairs. Listen on the event that matches your trigger surface (moderation queue vs admin Members screen); do not register the same handler on both expecting one fire.
- 2FA is never a hard gate. If your integration must enforce 2FA, enforce it in your own login flow - BuddyNext only surfaces it as advisory via
buddynext_2fa_required_roles. - Free vs Pro. Every hook on this page is Free. BuddyNext Pro adds no new safeguard seam - it extends the same
buddynext_safeguard_checkandbuddynext_moderation_auto_actionsfilters, which is exactly why your custom rules and Pro’s rules engine coexist on one pipeline. For the search and admin seams, see Hooks: Search, Hashtags, Sidebar, and Admin.

