Skip to content

REST API Reference

Every route the plugin registers, its parameters, its permission callback, and what it returns.

Source of truth: src/Rest/*.php and the image routes in public/class-buddypress-member-blog-public.php.

Namespace Status
wbcom-member-blog/v1 The only namespace. Everything lives here.

Removed in 4.0.1: the bpmb/v1 alias. /upload-image and /attach-image were also registered under bpmb/v1, pointing at the same callbacks. Nothing called it - not our JS, not Pro, no integration - so it was two names for one route and one more place for the upload policy to drift. Requests to /wp-json/bpmb/v1/* now 404. Use wbcom-member-blog/v1.

Constant: Wbcom\MemberBlog\Rest\RestController::NAMESPACE_V1.

Base URL:

https://example.com/wp-json/wbcom-member-blog/v1

All write routes and all private read routes are cookie plus nonce. Send the standard WordPress REST nonce in the X-WP-Nonce header:

fetch( wpApiSettings.root + 'wbcom-member-blog/v1/bookmarks', {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-WP-Nonce': wpApiSettings.nonce,
'Content-Type': 'application/json'
},
body: JSON.stringify( { post_id: 123 } )
} );

The plugin already localizes the root and the nonce for you. Any script that declares bpmb-ui-helpers as a dependency gets window.bpmbRestConfig ({ root, nonce }) and the window.bpmbRest( method, path, data ) wrapper, which attaches the header, unwraps the payload on success, and rejects with an Error carrying .code, .status and .data on failure. Use it rather than hand-rolling a fetch.

Application Passwords are refused on writes by default

Section titled “Application Passwords are refused on writes by default”

RestController::can_mutate() rejects any write authenticated with an Application Password with 403 bpmb_app_password_write_denied.

An Application Password is a bearer credential carrying the user’s full capabilities, usable from any script with no browser and no nonce. Every member-facing write here is a UI action a member performs on a page they are looking at, so the default is: interactive sessions write, long-lived tokens do not.

A site that genuinely needs token-driven writes (a mobile client, an integration) opts back in:

add_filter( 'bpmb_rest_allow_app_passwords', '__return_true' );

Every route returns core’s error envelope. Codes are prefixed bpmb_.

{
"code": "bpmb_cannot_edit",
"message": "You cannot edit this post.",
"data": { "status": 403 }
}

Collection routes accept page (default 1) and per_page (default 10, maximum 50) and return the standard headers X-WP-Total and X-WP-TotalPages.

Route Method Access
/posts/{id}/related GET Public
/posts/render GET Public
/featured GET Public
/dashboard GET Authenticated (own, or edit_others_posts)
/dashboard/counts GET Authenticated (own, or edit_others_posts)
/bookmarks GET, POST Authenticated
/bookmarks/{post_id} DELETE Authenticated
/follows GET, POST Authenticated
/follows/{object_type}/{object_id} DELETE Authenticated
/reactions POST Authenticated
/autosave POST Authenticated + create/edit gate
/draft GET Authenticated
/posts/{id}/featured-image DELETE Authenticated + edit gate on the post
/posts/{id}/featured POST, DELETE Capability: bpmb_feature_capability (default edit_others_posts)
/categories POST Authenticated + manage_categories
/upload-image POST Authenticated + upload_files or the plugin create gate
/attach-image POST Authenticated + edit gate on the post and on the attachment

src/Rest/DashboardController.php

A member’s dashboard is not “posts where post_author = me”. It is “posts I can edit”, which includes posts I merely co-author. Core’s author param cannot express that, which is why this route exists rather than riding /wp/v2/posts. It delegates to DashboardService, which applies the frozen bp_member_blog_dashboard_query_args filter Pro uses to fold in co-authored posts.

Permission: can_read_dashboard(). Must be logged in. Passing another user’s user_id requires edit_others_posts (the drafts and pending tabs expose unpublished work).

Param Type Default Notes
user_id integer 0 0 resolves to the current user.
tab string published published, drafts or pending.
search string ''
page integer 1
per_page integer 10 Maximum 50.
GET /wp-json/wbcom-member-blog/v1/dashboard?tab=drafts&page=1&per_page=2
X-WP-Nonce: <nonce>
[
{
"id": 412,
"title": "Draft about indexes",
"status": "draft",
"date": "2026-07-02T09:14:00",
"link": "https://example.com/?p=412",
"edit_link": "https://example.com/write/?post_id=412",
"views": 0,
"is_owner": true
}
]

Headers: X-WP-Total: 7, X-WP-TotalPages: 4.

Same permission callback. Accepts user_id.

{
"published": 12,
"drafts": 3,
"pending": 1,
"total": 16,
"views": 4821
}

Each count goes through the same service the list does, so the badge cannot disagree with the list under it.

src/Rest/EngagementController.php

Bookmarks, follows and claps. Core has no concept of “save for later” or “follow a writer”, so these are genuine gaps rather than a re-implementation of something in /wp/v2.

Permission: can_mutate(). Logged in, no Application Password.

Param Type Required
post_id integer yes

The post must exist and be publish, otherwise 404 bpmb_not_found. Without that check, the bookmark table becomes a way to confirm a private or draft post exists.

{ "bookmarked": true, "count": 38 }

Permission: can_mutate(). No existence check: unsaving a post that has since been deleted must still work.

{ "bookmarked": false, "count": 37 }

Permission: can_mutate(). A reading list is private, so this is authenticated even though it is a read.

Param Type Default
user_id integer 0 (the current user)
page integer 1
per_page integer 10 (max 50)

Passing someone else’s user_id requires edit_others_posts, otherwise 403 bpmb_forbidden.

[
{
"id": 88,
"title": "How we index engagement",
"link": "https://example.com/how-we-index-engagement/",
"date": "2026-06-30T11:02:00",
"thumbnail": "https://example.com/wp-content/uploads/2026/06/x-300x200.jpg",
"author": { "id": 4, "name": "Jane Doe" }
}
]

Headers: X-WP-Total, X-WP-TotalPages.

Permission: can_mutate(). One route shape for both target kinds, because following a writer and following a series are the same relation.

Param Type Required Notes
object_type string yes Enum: user, series (FollowService::TYPES).
object_id integer yes Must exist, otherwise 404 bpmb_not_found.
{ "following": true, "followers": 129 }

Permission: can_mutate(). No existence check, for the same reason as unbookmarking.

{ "following": false, "followers": 128 }

Permission: can_mutate(). Returns your own list only. Who somebody follows is theirs.

Param Type Default
object_type string user
{ "object_type": "user", "ids": [ 4, 19, 22 ] }

Permission: can_mutate(). Claps.

Param Type Default Notes
post_id integer required Must be publish.
claps integer 1 Minimum 1, maximum 50 per request.

One request per burst, not one per tap: the client accumulates taps and sends the total. The server adds them atomically and caps in SQL against bpmb_max_claps (default 50 per member per post).

You cannot clap your own post: 403 bpmb_own_post.

POST /wp-json/wbcom-member-blog/v1/reactions
{ "post_id": 88, "claps": 7 }
{ "claps": 7, "total": 214, "maxed": false }

claps is the member’s own count for that post after the cap; total is the post’s. Fires bpmb_post_clapped ( $post_id, $user_id, $mine, $total ).

src/Rest/FeaturedController.php

Editor’s picks. Every route is a thin wrapper over FeaturedService, which is also what the admin row action calls, so a post cannot end up featured over REST while an identical one is refused in wp-admin.

Note the two similar paths, which mean different things:

  • /posts/{id}/featured is the editor’s-pick flag.
  • /posts/{id}/featured-image is the post thumbnail (on FrontendController, below).

Permission: __return_true. Public - a featured strip is a public surface.

Param Type Default
page integer 1
per_page integer 10 (max 50)
{
"posts": [
{
"id": 101,
"title": "The member blog rebuild",
"permalink": "https://example.com/the-member-blog-rebuild/",
"author": {
"id": 4,
"name": "Jane Doe",
"url": "https://example.com/author/jane/"
},
"date": "2026-07-01T08:00:00"
}
],
"total": 6,
"max_pages": 1
}

Permission: can_feature(), which is user_can( $user_id, bpmb_feature_capability ). The default capability is edit_others_posts - an editorial capability. An author capability here would let every member promote themselves onto the front page. Returns 401 when logged out, 403 when logged in without the capability.

Param Type Default Notes
order integer 0 0 to 9999. Lower floats to the top.

Only a published post can be featured. A draft returns 400 bpmb_not_published rather than silently accepting a flag that would shove the post onto the front page weeks later when somebody publishes it.

{ "post_id": 101, "featured": true, "total": 7 }

Permission: can_feature().

{ "post_id": 101, "featured": false, "total": 6 }

src/Rest/FrontendController.php

The routes that replace admin-ajax.php for the member-facing UI.

One design call worth stating: /posts/render returns rendered HTML, not JSON. The dashboard is server-rendered and its markup is a frozen contract that themes style against. Moving the transport to REST is the goal; rewriting the dashboard into a client-side renderer is a different project.

The old wp_ajax_* handlers stay registered as deprecated wrappers, because custom code on a live site may call them.

Permission: can_read(). Public. Wraps PostQuery::related(), the same method the shortcode and the block call.

Param Type Default Notes
id integer required Must be publish, otherwise 404 bpmb_not_found.
per_page integer 4 1 to 12.
[
{
"id": 77,
"title": "Indexing follows",
"link": "https://example.com/indexing-follows/",
"date": "2026-06-21T10:00:00",
"thumbnail": "",
"author": { "id": 4, "name": "Jane Doe" }
}
]

Permission: can_read(). Public - the service decides what an anonymous viewer may see.

Param Type Default
user_id integer 0 (the current user)
tab string published
page integer 1
search string ''
{
"html": "<div class=\"bpmb-blog-post\">...</div>",
"page": 2,
"max_pages": 4,
"total": 31,
"pagination": "<nav class=\"bpmb-pagination\">...</nav>"
}

Headers: X-WP-Total, X-WP-TotalPages.

Permission: can_autosave(). Logged in; editing an existing post requires bpmb_access()->can_edit() on that post, and a brand-new draft requires bpmb_access()->can_create() (which is where Pro’s post limits and credit balance live).

Returns 403 bpmb_autosave_disabled when the site owner has turned autosave off. The endpoint fails closed itself - a stale tab or a direct POST would walk past any client-side guard.

This route never changes a post’s status. It saves content and nothing else, so autosaving a post that is pending, publish, future or private leaves it exactly where it was. Deciding a status is PostSubmissionService::resolve_status()’s job and only a real submission asks it - the member has pressed nothing here, so there is no intent to resolve. The single exception is auto-draft, which is promoted to draft on the first save that carries real content: an auto-draft is a placeholder that the member’s Drafts tab and the draft-recovery banner both ignore, and that WordPress deletes after seven days.

Until 4.0.2 it hardcoded draft on the update as well as the insert, so one autosave tick took a live post off the site and pulled a submitted post out of the review queue. tests/flows/14-autosave-status.php fails if that comes back.

Nor does it write to a post the public can already see. publish, private and future are saved onto a per-author autosave REVISION (wp_create_post_autosave()), leaving the live row’s content, terms and status untouched; draft, pending and auto-draft still save in place. This is WordPress’s own rule - core states it in WP_REST_Autosaves_Controller - with one deliberate difference: core treats pending as needing a revision, and this route does not, because a pending post has no readers, only a moderation queue, and a reviewer should see what the member last typed.

The response says which happened, so a client can word its indicator honestly:

{ "saved": true, "post_id": 42, "stored": "post", "time": "2026-08-12 09:47:11" }
{ "saved": true, "post_id": 42, "stored": "revision", "revision_id": 43, "time": "" }

post_id is always the POST, never the revision - the form must go on editing the live post. Categories and tags are NOT written on the revision path (a revision carries no taxonomy, and writing them to the live post would re-open the same hole one field to the left), and the Editor.js block JSON is attached with update_metadata() rather than update_post_meta(), which redirects revision ids to the parent by design.

Param Type Default Notes
post_id integer 0 0 creates a new draft.
title string ''
content string '' Sanitized through PostSubmissionService::sanitize_content().
categories integer[] [] Excluded categories are stripped via enforce_excluded_categories().
tags string[] []
editor_data string '' Editor.js block JSON. Stored to _bpmb_editorjs_blocks if it decodes.

An empty title and empty content saves nothing:

{ "saved": false, "post_id": 0 }

Otherwise:

{ "saved": true, "post_id": 412, "time": "2026-07-11 22:19:04" }

Permission: can_mutate(). Powers the “restore your unsaved work?” banner. One question asked in two places, so the banner stays a single implementation:

Call Means context
GET /draft The member’s most recent stray draft, for an empty new-post form. draft
GET /draft?post_id=N The unpublished changes parked on post N’s autosave revision, for a member reopening something the public can already see. unpublished_changes

With post_id, the answer requires can_edit() on that post, reads only THIS member’s autosave (wp_get_post_autosave() without an author returns whoever saved last, which on a co-authored post would hand one member the other’s unfinished sentence), and suppresses a revision older than the post - that one is superseded, not pending. A tie is offered back: both timestamps have one-second granularity, and losing an edit silently is worse than a banner the member dismisses.

post_id in the response is always the POST, never the revision.

Without post_id, returns the member’s most recently modified draft, but only if it was modified within the last 24 hours and is not empty. Otherwise:

{ "has_draft": false }
{
"has_draft": true,
"post_id": 412,
"title": "Draft about indexes",
"content": "<p>...</p>",
"modified": "2026-07-11T22:19:00",
"modified_display": "10:19 pm",
"editor_data": "{\"blocks\":[...]}"
}

modified is the machine value; modified_display is what the banner prints.

Permission: can_edit_post() - can_mutate() plus bpmb_access()->can_edit( $id ).

Clears the post thumbnail. This is not the editor’s-pick flag; see /posts/{id}/featured.

{ "removed": true, "post_id": 412 }

Permission: can_mutate(), and the callback additionally requires manage_categories. The frontend form is not an excuse to hand every member term-creation rights.

Param Type Required
name string yes
parent integer no (default 0)

An already-existing term is not an error - the existing term is returned. A term the site owner has excluded returns 403 bpmb_category_excluded.

{ "id": 19, "name": "Engineering" }

public/class-buddypress-member-blog-public.php, registered in wbcom-member-blog/v1.

These exist because admin-ajax.php image upload broke on sites using a custom login form: the wp-admin cookie scope produced a 302. The wp_ajax_bpmb_upload_image / wp_ajax_bpmb_attach_image handlers that predated them were removed in 4.0.1: the editor picked its transport at page load from rest_url()/wp_create_nonce(), which are never empty, so the AJAX branch was unreachable.

Permission for both: rest_upload_permission() - logged in, and either upload_files or bpmb_access()->can_create().

multipart/form-data. Field name: image. Optional post_id.

The MIME allowlist comes from bpmb_media()->allowed_mime_types(), which honours the site owner’s “Allowed image types” content setting, always strips SVG, and applies the bpmb_allowed_image_mime_types and bpmb_rest_upload_allowed_types filters. The size limit comes from bpmb_media()->max_upload_bytes() (the max_upload_size setting in MB, filterable through bpmb_max_upload_size and bpmb_rest_upload_max_size).

If post_id is given, bpmb_access()->can_edit( $post_id ) must pass.

{
"success": true,
"data": {
"file": {
"url": "https://example.com/wp-content/uploads/2026/07/photo.jpg",
"width": 1600,
"height": 900
},
"attachment_id": 913
}
}

Errors: no_file (400), invalid_file (400), invalid_type (400), file_too_large (400), no_permission (403), upload_failed (500).

JSON body. Re-parents an existing attachment onto a post.

Param Type Required
post_id integer yes
attachment_id integer yes

Requires bpmb_access()->can_edit( $post_id ), and the caller must be able to edit the attachment or own it - otherwise another member’s media ID could be re-parented through this route.

{ "success": true, "data": { "message": "Image attached to post." } }

src/Rest/RestGate.php is not a route. It hooks core’s own post controller so that POST /wp/v2/posts obeys the same rules as the submission form.

Hook What it enforces
rest_pre_insert_post On create: bpmb_access()->can_create(). On update: bpmb_access()->can_edit(). Then bpmb_submission()->resolve_status() decides what the post becomes - a request asking for publish is clamped down to pending under moderation. It only clamps down; a request for draft stays a draft.
rest_after_insert_post Strips excluded categories through bpmb_submission()->enforce_excluded_categories(). Core attaches terms after the insert, so this cannot live in the pre-insert filter.

Without the gate, a member whose create gate returns false (post limit reached, out of credits) could create and publish a post over POST /wp/v2/posts regardless. This was measured, not assumed.

Denial responses carry the actual reason where Pro supplies one through bpmb_get_denial_reasons():

{
"code": "bpmb_rest_cannot_create",
"message": "You have reached your limit of 5 posts this month.",
"data": { "status": 403 }
}
Filter Default Effect
bpmb_rest_allow_app_passwords false Allow Application Passwords to perform plugin writes.
bpmb_feature_capability edit_others_posts The capability /posts/{id}/featured demands.
bpmb_max_claps 50 The per-member clap cap enforced by /reactions.
bpmb_allowed_image_mime_types Setting-derived The upload allowlist for every surface.
bpmb_rest_upload_allowed_types Setting-derived The upload allowlist for the REST path specifically.
bpmb_max_upload_size Setting-derived (MB) The size limit for every surface.
bpmb_rest_upload_max_size Setting-derived (bytes) The size limit for the REST path specifically.
bp_member_blog_dashboard_query_args - Widens the dashboard query. This is how Pro folds in co-authored posts, and it applies to /dashboard and /posts/render because both go through DashboardService.

See the Hook Reference for the full list.