Hooks: Feed and Content
BuddyNext overviewFree + ProGet BuddyNext →
The action and filter seams for the activity feed and everything members post into it: posts, comments, reactions, polls, shares, bookmarks, and the composer template. This page is for developers building gamification plugins, moderation integrations, notification bridges, or theme extensions that react to feed activity or modify content before it is written. Every hook below is fired or applied by BuddyNext Free, so it is available without Pro. Several of them are the documented Free-to-Pro extension seams (buddynext_post_pin_limit, buddynext_reaction_types, buddynext_reaction_meta).

Overview / Contract
Section titled “Overview / Contract”- Actions are notifications, not callbacks. BuddyNext fires actions after the database write has committed. Listeners that need the full record should re-fetch by ID (for example
buddynext_service( 'post_service' )->get( $post_id )); the actions pass IDs, not hydrated rows. *_before_savefilters transform or reject. They run after the built-in safeguard checks and receive the candidate data array. Return a modified array to alter what is written, or return aWP_Errorto reject the write outright. Only known columns are persisted; extra keys are ignored.- Actor vs recipient. For engagement that targets someone else’s content, BuddyNext fires an actor-perspective event (who did the thing) and a recipient-mirror alongside it (the content author). Recipient mirrors fire only when the actor is not the author. Gamification systems usually award the recipient.
- Idempotent events fire once. Bookmarks, shares, and votes use
INSERT IGNOREagainst a uniqueness key, and their actions fire only when a row was actually created or changed. Duplicate calls are silent no-ops. - Render and
_part_*template hooks echo at the call site. Filters that return HTML (for examplebuddynext_composer_tools) default to an empty string, so BuddyNext renders nothing unless a plugin hooks. Hooked plugins must return escaped HTML.
Post lifecycle
Section titled “Post lifecycle”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_hide_dead_reshares |
filter | A reshare is rendered whose original post is gone (deleted, hidden, blocked, or its author suspended). Default false: the card collapses to a tombstone so the sharer’s action stays legible and the feed does not silently lose posts. Return true to drop it from the feed entirely. |
bool $hide, array $post |
buddynext_post_before_save |
filter | A post is about to be written on create or update, after safeguard checks | array $data, int $user_id, int|null $post_id ($post_id is null on create) |
buddynext_feed_allowed_post_types |
filter | The feed resolves which post types it will store and render, so an add-on can register its own card/post type instead of being dropped as unknown | string[] $types (defaults to the built-in set) |
buddynext_post_created |
action | A new post goes live (also fired for a share, with $type = 'share') |
int $post_id, int $user_id, string $type |
buddynext_post_updated |
action | A post is edited | int $post_id, int $user_id, array $fields (columns written this update) |
buddynext_post_deleted |
action | A post is deleted by its owner | int $post_id, int $user_id |
buddynext_purge_page_cache |
action | A feed change purged one of its pages from the page caches BuddyNext knows (WP Rocket, LiteSpeed Cache, W3 Total Cache, WP Super Cache). Hook it to purge the same URL from a cache BuddyNext does not know, such as Cloudflare APO or a proxy. Fires once per URL per request. | string $url (absolute) |
buddynext_link_preview_max_redirects |
filter | Fetching a link preview, capping how many redirect hops are followed (default 5; every hop is re-validated against SSRF rules before it is followed) |
int $max, string $url |
buddynext_post_approved |
action | A held post is approved by a moderator | int $post_id, int $author |
buddynext_post_rejected |
action | A held post is rejected by a moderator | int $post_id, int $author, string $reason |
buddynext_post_pin_limit |
filter | A user pins a post, to read the per-scope pin cap | int $limit, int|null $space_id, int $user_id (default 1) |
buddynext_user_mentioned |
action | An @username mention in a post (or comment) resolves to a real user |
int $mentioned_user_id, int $mentioner_id, int $post_id |
Note:
buddynext_post_createdis the catch-all “a post exists now” event and fires for every post type, including shares. If you specifically want the share action with the original post ID, listen tobuddynext_post_sharedinstead.
Comments
Section titled “Comments”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_comment_before_save |
filter | A comment is about to be written on create or update | array $data, int $user_id, int|null $comment_id ($comment_id is null on create; on update $data carries only content) |
buddynext_comment_created |
action | A new comment is created | int $comment_id, string $object_type, int $object_id, int $user_id |
buddynext_post_comment_received |
action | The author’s post receives a comment from someone else (recipient mirror) | int $comment_id, int $post_id, int $author_id, int $commenter_id |
buddynext_comment_updated |
action | A comment is edited | int $comment_id, int $user_id |
buddynext_comment_deleted |
action | A comment is deleted | int $comment_id, int $user_id |
$object_type is the thing the comment is attached to ('post', 'media', etc.). The post-counter and recipient-mirror logic only run when $object_type === 'post'.
Reactions
Section titled “Reactions”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_reaction_added |
action | A reaction is added to an object | string $object_type, int $object_id, int $user_id, string $emoji |
buddynext_post_reaction_received |
action | The author’s post receives a reaction from someone else (recipient mirror) | int $post_id, int $author_id, int $reactor_id, string $emoji |
buddynext_reaction_removed |
action | A reaction is removed from an object | string $object_type, int $object_id, int $user_id, string $emoji |
buddynext_reaction_types |
filter | The allowed reaction-type list is resolved | string[] $types (the owner-enabled subset of the canonical six) |
buddynext_reaction_meta |
filter | A reaction label/char/colour is resolved | array $meta, string $slug ($meta has label, char, color) |
buddynext_reactors_limit |
filter | The reactors list for an object is queried | int $max, string $object_type, int $object_id (default 100) |
The canonical six reaction slugs are like, love, haha, wow, sad, angry. The site owner can disable a subset through Settings, and buddynext_reaction_types runs after that, so the list you receive is already the owner-enabled set. See the example below for adding a custom type.
Polls, shares, and bookmarks
Section titled “Polls, shares, and bookmarks”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_poll_vote_before_save |
filter | A poll vote is about to be written | array $data, int $user_id ($data has user_id, post_id, option_id) |
buddynext_poll_voted |
action | A poll vote is cast or switched (not on toggle-off) | int $post_id, int $option_id, int $user_id |
buddynext_share_before_save |
filter | A share is about to be written | array $data, int $user_id ($data has user_id, post_id, content) |
buddynext_post_shared |
action | A post is shared | int $share_id, int $original_post_id, int $user_id |
buddynext_post_bookmarked |
action | A post is bookmarked (first time only) | int $post_id, int $user_id |
buddynext_post_unbookmarked |
action | A bookmark is removed (only when a row was deleted) | int $post_id, int $user_id |
Note:
buddynext_poll_voteddoes not fire when a member clicks the same option again to remove their vote (toggle-off returns early). A vote switch to a different option fires once, after the new vote is recorded.
Feed query and rendering
Section titled “Feed query and rendering”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_feed_query_args |
filter | Before a feed query runs (home, profile, space, explore) | array $args, string $scope, int $viewer_id ($args has per_page, cursor, user_id) |
buddynext_feed_order_by |
filter | The home feed ORDER BY clause is built |
string $order_by, int $user_id, array $query_args |
buddynext_for_you_priority_days |
filter | The For you feed is being ordered. Sets how many days a post by a connection, or in a space matching the member’s interests, stays above newer posts from everyone else. Return 0 for no limit. Since 1.2.5. |
int $days, int $user_id (default 7) |
buddynext_feed_items |
filter | A feed page is about to be returned | array $items, string $scope, int $viewer_id, array $args |
buddynext_post_impression |
action | Each item on a served feed page is rendered | int $post_id, int $viewer_id, string $surface (home_feed, profile_feed, space_feed, explore_feed) |
Warning:
buddynext_post_impressionfires once per item per served page and can run dozens of times per request. Keep listeners cheap: defer counting work to Action Scheduler rather than writing to the database inside the hook.
Composer template seams
Section titled “Composer template seams”The composer partial exposes wrapper hooks for adding tools and modals to the post box. These are template-level and run wherever the composer is rendered.
| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_part_composer_args |
filter | The composer args are assembled | array $args (user_id, space_id, and render flags) |
buddynext_part_composer_before |
action | Immediately before the composer markup | array $args |
buddynext_composer_tools |
filter | The composer toolbar row is rendered | string $html, array $args (return escaped HTML to append a tool button) |
buddynext_composer_modals |
action | After the composer, where tool modals belong | array $args |
buddynext_part_composer_after |
action | Immediately after the composer markup | array $args |
buddynext_comment_descendant_cap |
filter | One page of a comment thread is loaded, bounding how many descendant rows come with it | int $cap, string $object_type, int $object_id |
buddynext_explore_all_deck |
filter | The blended Explore first-page deck is assembled, so an add-on can inject its own cards among the posts | array $items, array $post_cards |
buddynext_explore_excluded_post_types |
filter | Explore’s deck and its pulse count are queried. Post types listed here are left off. Default array( 'document' ): a document post stays in its space feed, the space Files tab and the author’s profile. Return array() to show documents on Explore. Reshares are always left off and cannot be re-added (1.2.2) |
string[] $types |
buddynext_post_link_meta_resolved |
action | A queued link preview finished resolving and was stored on the post. The post was already visible without it, so anything that renders the preview should refresh here | int $post_id, array $meta ($meta has title, description, thumbnail) |
buddynext_scheduled_post_published |
action | A scheduled post is published ahead of its schedule | int $post_id |
Note: Every feed template partial (
post-actions,post-body,post-byline,post-comment-form, and so on) also exposes the standardbuddynext_part_<name>_before/_afteractions andbuddynext_part_<name>_args/_classesfilters. They follow the same convention as the composer hooks and are the safe seams for theme overlays. See Hooks: Overview for the template-part naming contract.
More feed and content seams
Section titled “More feed and content seams”Narrower hooks around announcements, members-only posts, the feed templates, and avatar and cover storage. Same table shape as above.
Posts, announcements, and members-only content
Section titled “Posts, announcements, and members-only content”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_can_create_announcement |
filter | A user tries to create an announcement. Default: a site admin, or a moderator of the target space. | bool $can_announce, int $user_id, int $space_id ($space_id is 0 for a site-wide announcement) |
buddynext_announcement_published |
action | An announcement goes live, after the cached home feeds have been flushed so it shows on screens now. | int $post_id, int $user_id, int $space_id (0 = site-wide) |
buddynext_post_publicly_shareable |
filter | BuddyNext decides whether a post may be shared outside the community (the share modal and public link). | bool $shareable, array $post |
buddynext_members_only_has_access |
filter | A members-only post is rendered for a viewer who is not its author. Default: any logged-in viewer has access. Return false to lock it for plan-limited members. |
bool $has_access, int $viewer_id, array $post_ctx (id, user_id, space_id, members_only) |
buddynext_members_only_cta |
filter | The locked card for a members-only post builds its call to action. Default: a “Log in to view” link for guests, nothing for logged-in members. | array $cta, int $viewer_id, array $post_ctx ($cta has label and url) |
buddynext_members_only_teaser_fraction |
filter | The teaser shown on a locked members-only post is cut. The default comes from the teaser-percent setting and is capped at 0.95 so something is always withheld. |
float $fraction |
buddynext_render_post_body_{type} |
filter | A post card renders the body of a post whose type is {type} (for example media, document, badge). Return the pre-escaped card HTML for that type; an empty string falls through to the default body. |
string $html, array $args ($args has the post, id, link meta, and content) |
buddynext_scheduled_publish_failed |
action | A scheduled post is abandoned after repeated publish failures and returned to draft. | int $post_id, int $attempts |
buddynext_site_tracking_publish |
filter | A published WordPress post is about to post an activity card (site tracking). Runs last, so it has the final say per post. | bool $publish, WP_Post $post, int $author_id |
buddynext_partner_card_requires_verification |
filter | A partner plugin’s activity card (a forum topic, an event, a course) is being saved for an author who has not verified their email. Return true to hold it, as a member’s own post is held. |
bool $required, int $user_id, array $data (default false) |
buddynext_sync_blog_comments |
filter | Comments on a published WordPress post and on its feed card are mirrored. Return false to stop the mirroring. |
bool $enabled (default true) |
buddynext_post_preview_char_limit |
filter | A post card decides how many characters show before “Show more” (default 300). |
int $limit, string $context (feed context the card renders in) |
buddynext_post_preview_line_limit |
filter | A post card decides how many line breaks show before “Show more” (default 6). |
int $limit, string $context |
buddynext_bookmark_list_cap |
filter | The “all my saved post ids” list is read (used by the native app). Caps the size of the returned set. | int $cap, int $user_id |
buddynext_reaction_choices |
filter | The Engagement settings build the list of reactions the owner can switch on: the built-in six plus any an extension adds. buddynext_reaction_types then receives the owner’s pick. |
string[] $types |
buddynext_feed_viewer_state_primed |
action | Core viewer state has been primed for a page of feed items, so an add-on can batch-load its own per-author data in one query. | array $items, int $viewer_id |
Runnable snippet: letting another membership plugin decide who reads members-only posts (
buddynext_members_only_has_access,buddynext_members_only_cta) is inbuddynext/buddynext-snippetsatmembership/unlock-members-only-posts-with-another-plugin.php. Its header says what to change and how it was checked.
Feed templates
Section titled “Feed templates”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_feed_home_before / buddynext_feed_home_after |
action | Before and after the home feed’s inner content. | int $current_user_id |
buddynext_feed_explore_before / buddynext_feed_explore_after |
action | Before and after the Explore feed’s inner content. | int $current_user_id |
buddynext_explore_grid_cards |
action | After the Explore grid cards on first paint. Pro appends its own cards here. | string $explore_filter, int $current_user_id |
buddynext_can_view_explore |
filter | The Explore deck is about to be built for a viewer. Return false to withhold it (Pro does this for a plan without the Explore entitlement). |
bool $can, int $current_user_id |
buddynext_explore_locked_title |
filter | The title of the empty state shown when buddynext_can_view_explore returned false. |
string $title, int $current_user_id |
buddynext_explore_locked_body |
filter | The body text of that same empty state. | string $body, int $current_user_id |
buddynext_bookmarks_before / buddynext_bookmarks_after |
action | Before and after the bookmarks hub renders. | int $current_user_id |
buddynext_single_post_thread |
action | After the single-post card, so a bridge can render an alternative thread UI. | int $post_id, array $post |
buddynext_feed_client_pagination |
filter | A feed, Explore, or bookmarks template decides whether “Load more” swaps in place through the client router. Return false to fall back to a plain link. |
bool $enabled (default true) |
buddynext_feed_new_count_interval |
filter | The home feed sets how often the “N new posts” pill polls, in seconds (default 60). |
int $seconds |
buddynext_share_networks |
filter | The share modal builds the list of networks for sharing a public post. Return an empty array to offer only “Copy link”. | array $networks (key => icon, label, url template with {url} and {text}) |
Avatar and cover storage
Section titled “Avatar and cover storage”| Hook | Type | Fired when | Parameters |
|---|---|---|---|
buddynext_image_stored |
action | An avatar or cover and all its variations have been written to disk. A CDN or offload integration pushes the files from here. | string $kind, string $owner, int $id, array $written ($kind is avatar or cover, $owner is user or space, $written maps variation to absolute path) |
buddynext_image_deleted |
action | Just before an owner’s image folder is removed, so a CDN integration can purge its remote copies. | string $kind, string $owner, int $id, string $dir |
buddynext_stored_image_url |
filter | The public URL of a stored avatar or cover is resolved. Rewrite it onto a CDN host here. | string $url, string $kind, string $owner, int $id |
buddynext_replaced_image_grace |
filter | An avatar or cover is being replaced. Sets how long the old file stays on disk (so pages held in a page cache still show an image) before it is removed. Return 0 to remove it at once. |
int $seconds, string $kind, string $owner, int $id (default one day) |
buddynext_feed_image_variant |
filter | A feed card or link preview is choosing which MediaVerse image size to show. Return thumb_medium for lighter pages; the single-image 2x source (the full original) is then left out too. The lightbox still opens the full image. Since 1.2.5. |
string $variant (default thumb_large; also thumb_medium, thumb_thumb) |
buddynext_trending_topics_hours |
filter | The Trending Topics sidebar card is being built. Sets how far back it looks; a slower community can widen it. | int $hours (default 168, seven days) |
buddynext_announce_space_album_upload |
filter | Photos are added to a space album. Return false to stop the upload posting an activity card to that space. |
bool $announce, int $space_id, int $album_id |
buddynext_media_max_bytes |
filter | The composer and Media tab compute the upload ceiling they offer (the lower of the MediaVerse limit and the server limit). | int $max_bytes, int $user_id |
Examples
Section titled “Examples”Add a custom reaction type
Section titled “Add a custom reaction type”buddynext_reaction_types adds the slug; buddynext_reaction_meta supplies its label (and optional emoji char / colour). The slug must be lowercase alphanumeric, and for a complete UI it needs an icon at assets/icons/reaction-{slug}.svg and a --bn-reaction-{slug} colour token. Adding the slug without the icon and CSS leaves the picker broken, so ship both.
// 1. Register the slug.add_filter( 'buddynext_reaction_types', function ( array $types ): array { $types[] = 'celebrate'; return $types;} );
// 2. Give it a human label (and optionally a fallback emoji char + colour).add_filter( 'buddynext_reaction_meta', function ( array $meta, string $slug ): array { if ( 'celebrate' === $slug ) { $meta['label'] = __( 'Celebrate', 'my-addon' ); $meta['char'] = '🎉'; $meta['color'] = '#f5a623'; } return $meta;}, 10, 2 );Raise the pin cap for premium members
Section titled “Raise the pin cap for premium members”Free allows one pinned post per profile (pinning is profile-only). Return a higher integer to lift the cap. $space_id is always null.
add_filter( 'buddynext_post_pin_limit', function ( int $limit, ?int $space_id, int $user_id ): int { // Members in the 'premium' role may pin up to five posts per scope. if ( user_can( $user_id, 'bn_premium_member' ) ) { return 5; } return $limit;}, 10, 3 );Award the post author when their post is reacted to
Section titled “Award the post author when their post is reacted to”Listen to the recipient mirror, not the actor event, so you award the author rather than the reactor.
add_action( 'buddynext_post_reaction_received', function ( int $post_id, int $author_id, int $reactor_id, string $emoji ): void { my_gamification_award_points( $author_id, 5, 'reaction_received' );}, 10, 4 );Reject a post before it is written
Section titled “Reject a post before it is written”Return a WP_Error from buddynext_post_before_save to block the write. The error surfaces in the REST response body.
add_filter( 'buddynext_post_before_save', function ( $data, int $user_id, ?int $post_id ) { if ( null === $post_id && str_contains( (string) ( $data['content'] ?? '' ), 'banned-phrase' ) ) { return new WP_Error( 'blocked', __( 'That phrase is not allowed.', 'my-addon' ), [ 'status' => 422 ] ); } return $data;}, 10, 3 );Notes / gotchas
Section titled “Notes / gotchas”- Free vs Pro. Every hook on this page is fired by Free. The Pro-facing extension seams are
buddynext_post_pin_limit(raise pin caps for plans),buddynext_reaction_types/buddynext_reaction_meta(add reaction types), and thebuddynext_feed_query_args/buddynext_feed_order_by/buddynext_feed_itemstrio (plan-based feed filtering, reranking, sponsored injection). - Re-fetch, do not assume. Action listeners receive IDs. Hydrate via the relevant service (
post_service,comment_service) instead of caching the data you wish had been passed. buddynext_post_createdfires for shares too. Branch on$typeif you only want native posts; usebuddynext_post_sharedfor the share-specific signal with the original post ID.- Counter writes already happen. BuddyNext updates
reaction_count,comment_count, andshare_countitself before firing these actions. Do not re-increment denormalised counters from a listener.

