Skip to content

REST: Spaces

Reference for the core Spaces REST routes under buddynext/v1 - space lifecycle (CRUD), membership and roles, join/leave/cancel, pending requests, invites, bans, ownership transfer, archive, permissions, avatar/cover images, per-space notification preference, the space feed, and the space-category routes. This page is for developers calling or extending the Spaces surface.

A Space home driven by the Spaces REST routes - lifecycle, membership, feed, and category - documented on this page

All routes live under the buddynext/v1 namespace. They follow the shared response envelope, authentication, and pagination rules described on the REST Contract page - read that first. In short:

  • Auth. Routes marked Public use __return_true and need no authentication. Routes marked Auth require a logged-in user (cookie + X-WP-Nonce, or an application password). Routes marked Owner/Mod additionally check space ownership or manage_options. Routes marked manage_options require a site administrator.
  • Space types drive behaviour. open spaces join immediately ({"joined": true}); private spaces create a pending request ({"requested": true}); secret spaces are invite-only and return 403 unless the caller holds a pending invite.
  • Pagination. List routes accept page and per_page. GET /spaces caps per_page at 50 (default 12). The members roster (GET /spaces/{id}/members) is keyset-paginated: pass cursor + per_page and read the next cursor from the X-BN-Next-Cursor header (X-WP-Total is still sent; there is no X-WP-TotalPages for the roster). Pending-request lists remain page-based.
  • Bans are canonical on the plural route. Space bans are served by the Moderation controller at /spaces/{id}/bans. The old singular /ban routes were removed.

The Spaces surface spans two controllers: SpaceController (lifecycle, membership, images, preferences) and ModerationController (the three ban routes). The space feed is served by FeedController.

Method Path Auth Purpose
GET /spaces Public List spaces with filters/sort (per_page capped at 50, default 12).
POST /spaces Auth (space-creation role) Create a space. Caller must hold a role allowed to create spaces.
GET /spaces/{id} Public Get a single space by ID.
PUT /spaces/{id} Auth (owner) Update name, description, type, and other settings.
DELETE /spaces/{id} Auth (owner) Delete a space.

The create route’s permission callback is require_space_creation_role: the caller must be logged in and hold a role permitted to create spaces (configured on the Roles and Capabilities tab). Update and delete enforce owner/manage checks inside the service layer.

Method Path Auth Purpose
GET /spaces/{id}/members Public List members (paginated).
GET /spaces/{id}/pending-requests Auth (owner/mod) List pending join requests (paginated).
POST /spaces/{id}/members/{user_id}/approve Auth (owner/mod) Approve a pending join request.
POST /spaces/{id}/members/{user_id}/decline Auth (owner/mod) Decline a pending join request.
POST /spaces/{id}/approve-request Auth (owner/mod) Legacy approve route (kept for backwards compatibility).
PUT /spaces/{id}/members/{user_id}/role Auth (owner/mod) Change a member’s role within the space.
DELETE /spaces/{id}/members/{user_id} Auth (owner/mod) Remove a member from the space.
POST /spaces/{id}/invite Auth (owner/mod) Invite a user to the space.
POST /spaces/{id}/transfer-ownership Auth (owner) Transfer ownership to another member.
POST /spaces/{id}/transfer Auth (owner) Alias of transfer-ownership (used by the space-home action row).

approve, decline, role, remove, invite, and transfer use the require_auth permission callback; the owner/moderator check is enforced inside SpaceMemberService / SpaceService so a non-manager receives a 403 from the service rather than the gate.

Method Path Auth Purpose
POST /spaces/{id}/join Auth Join (open) or request to join (private); invite-only for secret. Accepts an optional invite token (see Invite links).
DELETE /spaces/{id}/join Auth Leave the space (same handler as the leave route).
POST /spaces/{id}/leave Auth Leave the space.
POST /spaces/{id}/join/cancel Auth Withdraw a pending join request.

Join outcomes by space type:

  • Open - membership becomes active immediately. Response: {"joined": true}.
  • Private - a pending request is created. Response: {"requested": true}.
  • Secret - 403 unless the caller already has a pending invited status, in which case the invite is accepted and the response is {"joined": true}.

Invite token (invite). When the body carries a valid invite token for this space, the join takes the direct path regardless of type - membership becomes active immediately ({"joined": true}) with no approval and no invite-only check. A valid token does NOT bypass a space ban, the paid-space gate (buddynext_can_join_space), or onboarding: a member who still owes onboarding gets 403 onboarding_incomplete. An invalid, expired, reset, or used-up token returns 403 invite_link_invalid.

One shareable invite link per space, managed by anyone who passes SpaceMemberService::can_invite() (owner/moderator per the who_can_invite setting, or a site admin). Stored in bn_space_meta (no dedicated table). See the user guide, “Invite people with a link”.

Method Path Auth Purpose
GET /spaces/{id}/invite-link Auth (can_invite) Return the current link, or {"invite_link": null} when none exists.
POST /spaces/{id}/invite-link Auth (can_invite) Create or reset the link (a reset issues a fresh token, killing the old one). Body: expires (1d|7d|30d|never, default 7d), max_uses (0|1|10|100, 0 = unlimited).

Both routes use the require_auth permission callback; the can_invite() check is enforced inside the handler, so a non-inviter receives a 403. The link object is:

{
"invite_link": {
"url": "https://example.com/spaces/book-club/?invite=…",
"token": "…32 chars…",
"expires": "7d",
"expires_at": "2026-10-01 12:00:00",
"max_uses": 0,
"uses": 3,
"status": "active",
"created_at": "2026-09-24 12:00:00"
}
}

status is active, expired, or limit_reached. Timestamps are GMT; the REST layer also adds ISO-8601 *_gmt variants. A visitor joins by opening url and calling POST /spaces/{id}/join with the invite token (above).

Owner-curated, ordered spaces shown first in the directory sidebar, the phone strip and onboarding. Managed by a site admin; the same option every member-facing surface resolves from via SpaceService::featured_spaces().

Method Path Auth Purpose
GET /settings/featured-spaces Auth (manage_options) Current featured ids + hydrated rows in owner order + the cap.
POST /settings/featured-spaces Auth (manage_options) Replace with a validated, ordered set. Body: ids (ordered int[]); missing/archived ids are dropped, de-duplicated, capped at buddynext_featured_spaces_limit (default 6, 1–12).

Response shape:

{
"ids": [12, 8],
"spaces": [
{ "id": 12, "name": "", "slug": "", "member_count": 34, "avatar_url": "", "type": "open" }
],
"limit": 6
}

Non-admins receive 403. The resolver/filters are documented in 29-hooks-spaces.md.

Served by ModerationController. The permission callback require_space_owner_or_admin requires the caller to be logged in and either a site administrator (manage_options) or the space owner/manager.

Method Path Auth Purpose
GET /spaces/{id}/bans Auth (owner/admin) List banned users for the space (ordered by created_at).
POST /spaces/{id}/bans Auth (owner/admin) Ban a user from the space. Body: user_id (required), reason (optional).
DELETE /spaces/{id}/bans/{user_id} Auth (owner/admin) Lift a user’s ban from the space.
Method Path Auth Purpose
POST /spaces/{id}/archive Auth (owner/admin) Archive the space.
DELETE /spaces/{id}/archive Auth (owner/admin) Restore (unarchive) the space.
PUT /spaces/{id}/permissions Auth (owner) Update permission-only settings (for example require_join_approval).
GET /spaces/{id}/notification-pref Auth Get the current user’s per-space notification preference.
POST /spaces/{id}/notification-pref Auth Set the current user’s per-space notification preference (pref).

The owner/admin check for archive lives inside SpaceService::archive(); the route only requires authentication to reach it. update_permissions re-checks buddynext-manage-space for the caller and stores each flag as a bn_space_{id}_{key} option.

Avatar and cover uploads are multipart and routed through the image storage service. They produce per-owner WebP variations on disk (not WordPress attachments); DELETE removes the stored files and clears the column.

Method Path Auth Purpose
POST /spaces/{id}/avatar Auth (owner) Upload the space avatar (icon).
DELETE /spaces/{id}/avatar Auth (owner) Remove the space avatar.
POST /spaces/{id}/cover Auth (owner) Upload the space cover image.
DELETE /spaces/{id}/cover Auth (owner) Remove the space cover image.

Served by FeedController.

Method Path Auth Purpose
GET /spaces/{id}/feed Public Return the activity feed for a space.

Served by SpaceController. These read routes power the directory’s suggested-spaces rail and sub-space navigation.

Method Path Auth Purpose
GET /spaces/suggestions Auth Ranked suggested spaces for the current viewer. Query: limit (default 6, capped at 24). Returns directory-shaped rows (category, membership state, sub-space count, cover tone) - the same card the directory renders.
GET /spaces/{id}/subspaces Public Visibility-scoped child spaces of a parent. Query: page (default 1), per_page (default 24, capped at 50). Returns { subspaces, total, page, per_page }. A secret parent returns 404; a private parent returns 403 to non-members.

/spaces/suggestions and /spaces/fields are registered before the /spaces/{id} route so their literal path segments are matched unambiguously; because the {id} pattern is [\d]+, the words suggestions and fields can never collide with it.

Served by SpaceController. Space custom fields are defined by a site-wide registry (SpaceFieldRegistry); the definitions are the form schema the app and web both render from, and values are stored per space.

Method Path Auth Purpose
GET /spaces/fields Public The registered field definitions (form schema, no values): key, label, description, type, options, section, sort_order, visibility, is_required. Returns { fields: [...] }.
POST /spaces/{id}/fields Auth (owner/mod) Save field values for a space. Body: fields (object of key => value); optional tabs (array of field keys to promote to space tabs - owner-only, ignored for moderators).

The POST route’s gate is require_auth; the real authority is per field inside SpaceFieldRegistry::save_for_space(). A space moderator (buddynext-moderate-space) may write the moderation-scoped fields; anything else returns 403. The whole submit is atomic: a required-field failure rejects it with 422 and a per-field message map, otherwise all values save and the route returns 200. The tabs promotion only applies when the values validated and the caller holds buddynext-manage-space (owner-level).

Registered by JetonomyBridge and present only when the Jetonomy companion is active. They are still part of the Free plugin’s buddynext/v1 namespace - the space forum is Jetonomy’s discussion surface, wired into a BuddyNext space.

Method Path Auth Purpose
POST /spaces/{id}/forum Auth (can-provision) Provision (or fetch) the space’s forum and return { forum_id, forum_url }. Requires login (401 otherwise) and the space-forum provision permission (403 otherwise).
GET /spaces/{id}/discussion-search Auth (can-provision) Typeahead for the “link an existing discussion” picker. Query: q (optional). Returns { results: [...] }. Scope is derived server-side from the caller’s role - a site admin searches all discussions, any other manager only the space owner’s own; the client cannot widen it.

The member-scoped counterpart, GET /members/{id}/discussions, is documented on the REST: Members and Profiles page.

Served by SpaceCategoryController. Categories are a site-wide taxonomy for organising the spaces directory.

Method Path Auth Purpose
GET /space-categories Public List all categories ordered by sort_order.
POST /space-categories manage_options Create a category.
PUT /space-categories/{id} manage_options Edit a category.
DELETE /space-categories/{id} manage_options Delete a category (returns 409 if any space uses it).
Terminal window
curl -X POST https://example.com/wp-json/buddynext/v1/spaces \
-H "Content-Type: application/json" \
-H "X-WP-Nonce: <nonce>" \
--cookie "<auth cookies>" \
-d '{
"name": "Photography Club",
"slug": "photography-club",
"type": "open",
"description": "Share shots, lenses, and edits.",
"category_id": 3
}'

Accepted body fields: name (required, 100 chars max), slug (optional - derived from name when omitted), type (open / private / secret, default open), description (optional, 160 chars max), category_id (optional), parent_id (optional - creates a sub-space; the caller must manage the parent).

A successful create returns 201 with the full space object:

{
"id": 42,
"name": "Photography Club",
"slug": "photography-club",
"type": "open",
"description": "Share shots, lenses, and edits.",
"member_count": 1,
"category_id": 3,
"parent_id": 0
}

Validation failures return 422 with a params map, for example:

{
"code": "rest_invalid_param",
"message": "Validation failed.",
"data": {
"status": 422,
"params": { "slug": "This slug is already in use." }
}
}
Terminal window
curl -X POST https://example.com/wp-json/buddynext/v1/spaces/42/join \
-H "X-WP-Nonce: <nonce>" \
--cookie "<auth cookies>"

Response for an open space:

{ "joined": true }

Response for a private space (a pending request is created):

{ "requested": true }

For a secret space without a pending invite, the response is 403.

  • Two leave paths exist. DELETE /spaces/{id}/join and POST /spaces/{id}/leave both call the same leave handler; pick whichever fits your client.
  • transfer and transfer-ownership are equivalent, as are the members/{user_id}/approve route and the legacy approve-request route. New clients should prefer the spec-conformant transfer and members/{user_id}/approve forms.
  • Owner/moderator enforcement for membership writes happens in the service layer, not the route gate. The gate only checks that the caller is authenticated; a non-manager still receives a 403 from the service.
  • Bans are owned by Moderation. Do not look for ban routes on SpaceController - they live in ModerationController under the plural /bans path.
  • Pro filters on the directory. Advanced member/space filtering is layered in by buddynext-pro via REST filter seams; Free ignores Pro-only parameters when Pro is inactive.