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.
Namespaces
Section titled “Namespaces”| 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/v1Authentication
Section titled “Authentication”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' );Error shape
Section titled “Error shape”Every route returns core’s error envelope. Codes are prefixed bpmb_.
{ "code": "bpmb_cannot_edit", "message": "You cannot edit this post.", "data": { "status": 403 }}Pagination
Section titled “Pagination”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 index
Section titled “Route index”| 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 |
DashboardController
Section titled “DashboardController”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.
GET /dashboard
Section titled “GET /dashboard”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=2X-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.
GET /dashboard/counts
Section titled “GET /dashboard/counts”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.
EngagementController
Section titled “EngagementController”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.
POST /bookmarks
Section titled “POST /bookmarks”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 }DELETE /bookmarks/{post_id}
Section titled “DELETE /bookmarks/{post_id}”Permission: can_mutate(). No existence check: unsaving a post that has since been deleted must
still work.
{ "bookmarked": false, "count": 37 }GET /bookmarks
Section titled “GET /bookmarks”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.
POST /follows
Section titled “POST /follows”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 }DELETE /follows/{object_type}/{object_id}
Section titled “DELETE /follows/{object_type}/{object_id}”Permission: can_mutate(). No existence check, for the same reason as unbookmarking.
{ "following": false, "followers": 128 }GET /follows
Section titled “GET /follows”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 ] }POST /reactions
Section titled “POST /reactions”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 ).
FeaturedController
Section titled “FeaturedController”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}/featuredis the editor’s-pick flag./posts/{id}/featured-imageis the post thumbnail (onFrontendController, below).
GET /featured
Section titled “GET /featured”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}POST /posts/{id}/featured
Section titled “POST /posts/{id}/featured”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 }DELETE /posts/{id}/featured
Section titled “DELETE /posts/{id}/featured”Permission: can_feature().
{ "post_id": 101, "featured": false, "total": 6 }FrontendController
Section titled “FrontendController”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.
GET /posts/{id}/related
Section titled “GET /posts/{id}/related”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" } }]GET /posts/render
Section titled “GET /posts/render”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.
POST /autosave
Section titled “POST /autosave”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" }GET /draft
Section titled “GET /draft”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.
DELETE /posts/{id}/featured-image
Section titled “DELETE /posts/{id}/featured-image”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 }POST /categories
Section titled “POST /categories”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" }Image endpoints
Section titled “Image endpoints”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().
POST /upload-image
Section titled “POST /upload-image”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).
POST /attach-image
Section titled “POST /attach-image”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." } }The core-REST gate
Section titled “The core-REST gate”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 }}Filters
Section titled “Filters”| 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.

