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.

Overview / Contract
Section titled “Overview / Contract”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
Publicuse__return_trueand need no authentication. Routes markedAuthrequire a logged-in user (cookie +X-WP-Nonce, or an application password). Routes markedOwner/Modadditionally check space ownership ormanage_options. Routes markedmanage_optionsrequire a site administrator. - Space types drive behaviour.
openspaces join immediately ({"joined": true});privatespaces create a pending request ({"requested": true});secretspaces are invite-only and return403unless the caller holds a pending invite. - Pagination. List routes accept
pageandper_page.GET /spacescapsper_pageat 50 (default 12). The members roster (GET /spaces/{id}/members) is keyset-paginated: passcursor+per_pageand read the next cursor from theX-BN-Next-Cursorheader (X-WP-Totalis still sent; there is noX-WP-TotalPagesfor 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/banroutes were removed.
The Spaces surface spans two controllers:
SpaceController(lifecycle, membership, images, preferences) andModerationController(the three ban routes). The space feed is served byFeedController.
Space lifecycle (CRUD)
Section titled “Space lifecycle (CRUD)”| 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.
Membership and roles
Section titled “Membership and roles”| 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, andtransferuse therequire_authpermission callback; the owner/moderator check is enforced insideSpaceMemberService/SpaceServiceso a non-manager receives a403from the service rather than the gate.
Join, leave, and request management
Section titled “Join, leave, and request management”| 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 -
403unless the caller already has a pendinginvitedstatus, 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.
Invite links
Section titled “Invite links”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).
Featured spaces
Section titled “Featured spaces”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. |
Archive and settings
Section titled “Archive and settings”| 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.
Images
Section titled “Images”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. |
Space feed
Section titled “Space feed”Served by FeedController.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /spaces/{id}/feed |
Public | Return the activity feed for a space. |
Discovery and structure
Section titled “Discovery and structure”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/suggestionsand/spaces/fieldsare registered before the/spaces/{id}route so their literal path segments are matched unambiguously; because the{id}pattern is[\d]+, the wordssuggestionsandfieldscan never collide with it.
Custom fields
Section titled “Custom fields”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).
Jetonomy bridge routes (companion-gated)
Section titled “Jetonomy bridge routes (companion-gated)”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.
Space categories
Section titled “Space categories”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). |
Examples
Section titled “Examples”Create a space
Section titled “Create a space”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." } }}Join a space
Section titled “Join a space”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.
Notes and gotchas
Section titled “Notes and gotchas”- Two
leavepaths exist.DELETE /spaces/{id}/joinandPOST /spaces/{id}/leaveboth call the same leave handler; pick whichever fits your client. transferandtransfer-ownershipare equivalent, as are themembers/{user_id}/approveroute and the legacyapprove-requestroute. New clients should prefer the spec-conformanttransferandmembers/{user_id}/approveforms.- 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
403from the service. - Bans are owned by Moderation. Do not look for ban routes on
SpaceController- they live inModerationControllerunder the plural/banspath. - 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.

