Skip to main content
Community is scoped to a hub. It gives members places to talk, react, message, and discover each other.

Spaces

Admins create and order spaces:
Members list, join, leave, mute, and mark spaces read:

Updating a space

PATCH /api/v1/admin/teams/{team_id}/hubs/{hub_id}/spaces/{space_id} changes only the attributes you send. An explicit null clears icon, description, color or segment_id, so a space can go back to having no icon. null on any other attribute (name, slug, position, access_level, posting_permission, is_default, is_pinned, allow_moderators) is a 422 pointing at it, and nothing is written. Clearing segment_id on a restricted space leaves it restricted with no segment.

Who can see a restricted space

A space with access_level: "restricted" admits a member through any one of three arms:
  • the member is in the space’s segment;
  • the member holds an active access grant on the space;
  • the space’s allow_moderators is on and the member is one of the hub’s moderators.
A restricted space whose segment can’t be resolved (missing, deleted, or another team’s) denies access on every path, grantees and moderators included. A failed membership check never turns into an allow. Segments are always checked against the hub’s own team, and a restricted space whose team isn’t the hub’s team (or any restricted space on a hub whose team was deleted) is hidden from every member on every read and refused on every write (join, post, react, vote, comment), whatever its segment, grants or allow_moderators setting. Its members get no notifications, broadcasts or realtime events for it, and it isn’t joined as a default space. A grant adds to the segment; it never replaces it, and it doesn’t join the contact to the space. Admins (team owners) manage grants under /api/v1/admin/teams/{team_id}/hubs/{hub_id}/spaces/{space_id}. Here {hub_id} must be the hub’s exact id: a hub slug returns 404 with no code.
Grant a contact access:
The 201 response is a space_access_grants resource:
Refusals write nothing: GET .../grants lists active grants, paginated with page[after] and page[size] (1-100), and accepts filter[contact_id]. Add include_revoked=true to include the history; a revoked grant keeps its row with revoked_at, revoked_by_user_id and a revoke_reason. A grant closed with hub_member_removed because a refund, or a cancelled, unpaid or paused subscription, removed the member becomes active again (the same row, revoked_at and revoke_reason cleared) when the subscription recovers, or when the member buys again or starts a new trial. It stays revoked if, in between, the member left the hub, was removed from it, or their contact was deleted. If an admin deletes that grant while the member is removed, it becomes an admin_revoke and is never restored. An admin re-adding the member, or a free join, restores nothing. GET .../members lists everyone who can view the space right now as space_access_members resources, each with access_source (public, segment, granted or moderator, in that precedence), has_active_grant, granted_at, is_joined and membership_role. It takes filter[access_source], page[after] and page[size]. meta.segment_status is resolved, none (the space has no segment) or unresolved (nobody can view it, and the page is empty). If the segment can’t be checked right now, the route answers 503 with code access_unverified instead of a partial list.

Letting moderators in

PATCH .../spaces/{space_id} accepts allow_moderators (boolean). With it on, the hub’s moderators can view and join the restricted space without a grant or segment membership. Send false to turn it off; null is ignored. Admin space responses carry allow_moderators; POST .../spaces ignores it and stores false. Member responses never include it.

Hub owners and admins

A hub owner or admin can see every restricted space of the hub’s team, with or without a segment, even outside the segment and without a grant, as long as their membership of the hub is active and unbanned and they’re still a live contact of the hub’s team. They see it in GET .../spaces and on its detail route, and they can read its discussions, comments, reactions, reactors, reposters, poll voters and attachments, find them in the hub-wide feed and in search, and call POST .../spaces/{space_id}/mark-read, which stores their own read position. This is visibility only. On such a space they get viewer_can_post: false and is_member: false, and joining, posting, commenting, reacting, voting, reposting and reporting are refused (403, 404 for a poll vote, 422 for a report) with nothing written. They aren’t counted in member_count or the avatar preview, aren’t listed by the admin GET .../spaces/{space_id}/members route, don’t see the space on member profile cards or in their notification preferences, and get no notifications, broadcasts or realtime events for it. A restricted space whose segment can’t be resolved (missing, deleted or another team’s) stays closed to them too. Moderators get none of this: they reach a restricted space only through allow_moderators. The MCP check_access tool reports this case as access_source: "oversight".

When grants end

Grants survive segment changes and a member leaving and rejoining the space. They are revoked, and never restored automatically, when the contact is banned or soft-banned in the hub, removed from the hub or leaves it, is removed from the team, or is deleted or erased; when the hub is deleted; and when the space is made public (making it restricted again restores none of them). Admins can also revoke a grant directly. A closed grant records one of these revoke_reason values:

Public (anonymous) read

On a public hub (is_private: false), logged-out visitors can read community discussions and comments — no authentication required. These endpoints return a sanitized subset of the member payload (no internal ids, no per-viewer reaction state, no attachments) and are rate-limited by client IP:
Mention tokens (@[Name](contact_id)) inside discussion titles, discussion bodies, and comment bodies are rewritten for anonymous readers: the contact UUID is stripped and the label is re-resolved under the same author safe floor (a hide_profile member reads as @[A member]()). Each anonymous discussion payload (hub feed, space feed, and detail) also carries its space’s anon-safe reference: space_slug and space_name (both required, non-null). Use them to render a space badge or build /discussions/c/{space_slug}/{discussion_id} URLs without a second request. The internal space_id UUID remains stripped; only spaces already visible via GET /public/spaces (public, non-deleted, same hub) can ever populate these fields. Visibility rules (all denials return 404, never 401/403, to avoid leaking existence):
  • Only public hubs are readable. A private, missing, or deleted hub returns 404.
  • Only spaces with access_level: "public" are exposed. restricted spaces are invisible to anonymous readers, whether gated by a segment, access grants or moderators (they behave like a viewer with no membership, no segments and no grants).
  • Only published discussions with a published_at in the past are returned; drafts and scheduled (future) posts are hidden.
  • Author identity: anonymous readers see a member’s community display_name and avatar only. Members who set hide_profile (and members with no community display name) appear as "A member" with no avatar. The member’s real/legal name is never exposed.
  • Writing (posting, commenting, reacting) always requires authentication — the write endpoints below are unchanged and reject anonymous callers with 401.

Former members

A former member is someone who was removed from the hub, left it, or whose account or contact was deleted. They look the same everywhere, and the API never says which of those happened. Banned and soft-banned members are still members and keep their name. Their content stays readable, but their identity does not. On every member-facing read the API returns a former member’s name as null, every avatar field as null and author_role as null, and sets a boolean flag to true beside them. The flag is false for everyone else. Ids (author_contact_id, contact_id) and author_membership_status are unchanged, so list keys and Report and Remove on the content keep working. Render a “Former member” label and your default avatar from the flag, never from the nulls: a null name also means “no name set”. Rows already anonymised for privacy (anonymized: true) stay exactly as they were and carry no flag. Anonymous readers see a former author as "A member" with no avatar, with author_is_former_member set. You can’t ban, soft-ban, warn or block a former member: those requests are refused exactly like a request about someone who is no longer a member (404 member_not_found on the moderation routes, 422 member_not_found when resolving a report, 404 target_member_not_found on a block), and nothing is written. Unban still works, and so do Report and Remove on their content. Former members never appear in the directory, member counts, follower lists, avatar previews, mention or DM candidates, or member search, and their profile answers 404.
Mention tokens naming a former member (@[Name](contact_id) in a title, body, preview or search excerpt) read as @[Former member]() in member responses: the label is replaced and the destination is emptied, so the chip is not clickable. Mentions of everyone else are unchanged, and stored content is never rewritten. The label is English copy, so a German hub shows “Former member” there.

Discussions and comments

Members create discussions inside spaces and comments under supported targets:

Reply depth

Comment threading is exactly two levels: root comments and their direct replies.
  • To post a root comment, omit parent_comment_id.
  • To post a reply, set parent_comment_id to a root comment’s id.
  • Setting parent_comment_id to a comment that is itself a reply is rejected with 422 and code nested_reply_not_allowed. There is no reply-to-a-reply — when a user replies to a reply, send the root comment’s id, not the reply’s own id.
GET /api/v1/comments/{comment_id}/replies therefore only ever returns the direct replies of a root comment, and a reply never has replies of its own. comment_count on a discussion or content node counts a comment when both of these hold: the comment itself has not been deleted, and — if it is a reply — the root comment it hangs off has not been deleted either. Deleting a root comment therefore decrements the count by that comment and by each of its replies that had not already been deleted, since those replies are no longer reachable once their parent is gone. Deleting a reply whose parent was already deleted does not change the count — it had already stopped counting.

Comment attributes

Comment resources returned by list and replies endpoints include:

Whether the viewer can comment

GET /api/v1/comments (the list of root comments on a target) carries an optional meta.viewer_can_comment boolean next to has_more and next_cursor. It tells you whether the signed-in viewer could post a top-level comment on that target right now: When it is false, hide or disable your comment composer instead of letting the member type a comment that will bounce. The flag is a hint for your UI. POST /api/v1/comments stays the authority and still returns the real error if the state changed between the list and the post. Hub staff outside a lesson’s segment also no longer get the bell notification for new comments on that lesson, since they can’t open it to reply.

Hub-wide discussion feed

Fetch published discussions across all accessible spaces in one call — useful for activity feeds and notification inboxes:
The feed returns all published discussions from every space the caller can access, sorted by last_activity_at DESC, id DESC (most recently active first). Unpublished drafts and restricted-space discussions the caller cannot access are excluded automatically. For a hub owner or admin, “can access” includes the restricted spaces they see through owner and admin visibility. Query parameters: Response shape:
Pass meta.next_cursor as page[after] to fetch the next page. When meta.has_more is false, you have reached the last page. An invalid cursor format returns 422. Discussion resources in this feed include author_photo_url and author_photo_thumbnail_url (both nullable). When non-null these are imgproxy-signed URLs with the same 24-hour expiry as other media variants — check author_photo_url_expires_at and author_photo_thumbnail_url_expires_at companion fields and re-fetch the resource before expiry to get fresh URLs.

Drafts, scheduling, and publishing

A discussion has a computed status of draft, scheduled, or published, derived from whether it has been published and whether a future publish time is set. The status and scheduled_at attributes appear on every discussion resource.
  • Draft — create with is_published: false. It stays private to the author until published. List your drafts with GET /api/v1/hubs/{hub_id}/me/discussion-drafts.
  • Scheduled — set a future scheduled_at on create (or schedule an existing draft, see below). The post stays private and auto-publishes at that time via a background worker. List your scheduled posts with GET /api/v1/hubs/{hub_id}/me/scheduled-discussions.
  • Published — visible in the space. Publish a draft immediately with POST /api/v1/discussions/{discussion_id}/publish.
scheduled_at must be an absolute ISO-8601 instant with a timezone offset or Z — a naive datetime is rejected with 422. The value is stored in UTC; convert the member’s local time to an instant on the client. Schedule, reschedule, or cancel an existing draft’s schedule:
PUT .../schedule is also how you reschedule (send a new scheduled_at). Cancelling returns the post to draft. Scheduling and publishing are author-only; acting on an already-published post returns 409. Scheduling is restricted to hub owners, admins, and moderators. A regular member who sends scheduled_at on create, or calls PUT .../schedule on their own draft, receives 403 schedule_permission_denied (pointer /data/attributes/scheduled_at). On create nothing is created at all, and on PUT the post is left as it was. The role is checked before the already-published check, so a member’s PUT on a published post also gets this 403 rather than 409. Regular members can still save drafts and publish them immediately. Cancelling a schedule (DELETE .../schedule) and listing your scheduled posts stay open to the author whatever their role, so someone who scheduled a post and later lost the role can still find and cancel it.

Pinning discussions (elevated contacts)

Hub owners, admins, and moderators can pin and unpin discussions globally. Regular members receive 403.
  • POST is idempotent: re-pinning an already-pinned discussion returns 200 without error.
  • DELETE returns 409 discussion_not_pinned when the discussion is not currently pinned.
  • Both routes require a contact JWT with hub owner/admin/moderator role (require_hub_elevated_contact).
This sets the global pin visible to all hub members. It is distinct from the per-member “unpin override” — that separate action hides a pinned discussion only for the individual caller and returns 422 if misused in place of the global pin.

Broadcasts

A broadcast is a discussion that also notifies hub members (it fans out through the email pipeline). Broadcasting is restricted to hub admins/owners. A regular member who sets is_broadcast: true when creating a discussion receives a 403 — the post is not created as a broadcast.

Attachments

A member can attach media to a discussion or comment at create time. This is a dedicated three-step upload flow scoped to the specific composer context — it does not reuse the general member upload endpoint (POST /api/v1/hub/{hub_id}/files/upload-url); each attachment is uploaded through its own session tied to exactly the discussion/comment it’s destined for. 1. Initiate an upload session:
  • attachment_context is discussion or comment.
  • For discussion, context_target_type is hub or space — the upload scopes to the hub or space the discussion will be posted in (the discussion row doesn’t need to exist yet).
  • For comment, context_target_type is the commentable target type (discussion, content_node, or section) with its id in context_target_id; pass parent_comment_id as well when attaching to a reply.
  • mime_type must be one of the allowlisted image (image/jpeg, image/png, image/webp, image/gif), video (video/mp4, video/webm, video/quicktime), or document (application/pdf) types. size_bytes must be > 0 and ≤ 25 MiB (26,214,400 bytes). filename is 1–255 characters.
The response (201) returns a presigned S3 PUT URL plus the new media_id/file_id:
2. Upload directly to S3 using meta.upload_url, sending the header(s) in required_headers (the Content-Type must match what you sent at initiate). The upload session expires 1 hour after initiate if it’s never finalized. 3. Finalize:
Finalize re-validates the uploaded object (size and MIME type via an S3 HEAD) and transitions the media to READY; it’s idempotent once it has succeeded. 4. Reference the media_id(s) when creating the post:
Each media_id in attachments must correspond to a READY, unexpired upload session that was initiated for this exact context and target (and, for a comment reply, this exact parent_comment_id) — a session initiated for one discussion/space or comment thread can’t be redeemed against another. On success, each session is atomically consumed (READY → CONSUMED) in the same transaction as the discussion/comment insert, so a session is redeemable exactly once. Duplicate media IDs in one request are de-duplicated. If any media ID fails validation — no matching session, wrong context/target, expired, already consumed, or not owned by the caller — the entire create is rejected and nothing is persisted; attachments are atomic with the post. All of these failure modes return the same opaque error so a caller can’t distinguish one cause from another. The created resource — and the resource on subsequent reads (single GET, the hub feed, space lists, and comment lists/replies) — carries an inline attachments array in its attributes. Each entry:

Editing attachments and video embeds

Author-only. attachments and external_video_embeds are both full-replacement, and behavior is decided by key presence on the request body, not by value — an omitted key, an empty array, and a populated array are three distinct signals: external_video_embeds additionally rewrites position across the whole submitted sequence, so sending the same id set in a different order re-orders the embeds (attachments has no position concept). At most 5 embeds are allowed on one post — 422 too_many_embeds past that. body is optional on this route too, so an attachment/embed-only edit is valid; the service still enforces the existing invariant that a comment can’t end up with neither a body nor at least one attachment. The same replacement contract applies to PATCH /api/discussions/{discussion_id} and to DM message edits (PATCH /api/messages/{message_id}) — comment, discussion, and message editing all share this shape.

Polls

A discussion can carry a single-choice poll, attached atomically at create time — 2–10 options, a duration of 1–7 days.
The created (and every subsequently read) discussion carries a poll object in attributes whenever one is attached — null/absent otherwise: Result visibility is server-enforced, not a client-side hide. options[].vote_count (and, by extension, per-option results) is included only when the requesting member has already voted on this poll or the poll is closed — otherwise every option omits vote_count entirely. A closed poll always reveals results, even to a member who never voted. Anonymous (public) reads never see vote_count or the viewer_* fields, regardless of poll state — the public poll shape is id, label, position, total_votes, deadline_at, closed only. Cast a vote:
A vote is single-choice and immutable — once cast it cannot be changed or withdrawn. The response (201) is the full, now-revealed poll:
Note the resource id (data.id) is the poll id — it is not repeated inside attributes. Errors: 404 poll_not_found (the discussion has no poll), 404 poll_option_not_found (the option doesn’t belong to this poll), 409 poll_closed (deadline passed), 409 poll_already_voted (this contact already voted — including with a different option_id). A discussion in a restricted space the caller can’t access also returns 404 discussion_not_found — the exact same shape as a genuinely missing discussion, not 403. This is deliberate: a member probing discussion ids for this endpoint can’t tell “doesn’t exist” from “exists but you can’t access it” by diffing status codes. (Most other member routes on this API — reactions, reactors, reposters — do return 403 space_access_denied for the equivalent restricted-space case; this endpoint and the voter roster below are the exception.) Voter roster (“who voted”):
Same visibility rule as the poll’s own results — a 403 poll_results_hidden is returned if the requesting member hasn’t voted and the poll is still open, even though they can otherwise view the discussion. That check only ever runs once the discussion is confirmed visible, so it isn’t an enumeration risk. A restricted-space discussion the caller can’t access, by contrast, returns 404 discussion_not_found (same reasoning as the vote endpoint above — not 403). Member-only; there is no anonymous roster endpoint. Keyset-paginated (page[after] is "<created_at ISO>|<id>", default page size 20, max 100). Optional option_id filters the returned rows only — meta.option_counts always reflects every option, including votes from members hidden below. Privacy: a voter whose profile is hidden (hide_profile: true) or who is blocked in either direction relative to the requesting member is excluded from the roster rows — meta.option_counts still counts their vote in the tally, it just never surfaces their identity in the list.
Editing a poll: PATCH .../discussions/{discussion_id} accepts an optional poll key alongside title/body/attachments — same key-presence rule as everything else on that endpoint. Poll editing is replacement-only: there is no way to rename, reorder, or add/remove a single option in place. Editing always removes the whole poll and replaces it with a brand new one (or simply creates one, if the discussion doesn’t have a poll yet):
  • key absent → poll untouched.
  • poll: null → the poll is removed entirely (its options and votes are deleted). Always allowed — regardless of votes or closed state (see below).
  • poll: {options: [...], duration_days} → the existing poll (if any) is replaced wholesale with a brand new one built from these options — the exact same shape as creating a poll. Allowed only while the existing poll (if any) is vote-free and not closed (see below).
options is a plain array of label strings — identical to the create-time shape, and identical validation: 2–10 options, each trimmed to a non-empty string of at most 280 characters, duration_days between 1 and 7. There are no option ids on this endpoint at all; every option in a replacement is new. The response’s attributes.poll.id and every options[].id are brand new values, distinct from whatever the poll (or its options) had before the edit. deadline_at is always recomputed as now + duration_days when you send a poll object — every save re-anchors the deadline. Removal (poll: null) is always allowed — regardless of votes or closed state. Product ruling: once a poll is attached, there is no way to edit it in place, but removing it entirely is never blocked — an author who wants to change a locked poll’s options deletes it and attaches a fresh one on their next edit, or deletes the whole post. Blocking removal on a voted/closed poll would just push authors toward deleting the entire post instead, which is strictly worse. A replacement (poll: {options: [...], duration_days}) is gated by two locks, checked in this order, before any other part of the same PATCH (e.g. a bundled title change) is applied. Neither lock applies to poll: null — only to the options-object form:
  1. Closed poll — 409 poll_closed. If the existing poll’s deadline_at has already passed, the replacement is rejected. Checked first: an ended poll always reports poll_closed, even if it also collected votes before closing.
  2. Poll has votes — 409 poll_has_votes. If the existing poll has collected at least one vote, the replacement is rejected. Votes are immutable once cast, so a remove-and-recreate replacement is not allowed to disturb a poll that has any votes — removing it outright (poll: null) is still fine.
Either error rejects the whole request together — a bundled title/attachments change in the same PATCH is rolled back too, nothing partial is applied. A poll with zero votes and an open deadline can always be replaced, freely, as many times as you like — this is the fastest path for changing a poll’s options or duration. A voted or closed poll can still be removed (poll: null) and a brand new one attached on a later PATCH. Editing is author-only, via the same authorization the rest of this endpoint already uses — there’s no separate poll-specific permission. The response is the usual updated discussion resource, with attributes.poll reflecting the fresh, post-edit state (including gating — see above).

Admin fetch a single discussion

Auth is require_team_owner, with the hub verified to belong to the team (same gate as the admin PATCH/DELETE routes above). Fetches one thread for operator inspection — an admin sees everything, including drafts, soft-deleted, and removed discussions. Returns 404 if the discussion doesn’t exist under this hub.

Admin welcome post (create a discussion)

Admins can author a single welcome discussion — typically the first thread in a new hub, created during CLI-driven hub scaffolding:
Auth is require_hub_admin_or_contact — either a platform team owner (or a team-bound API key), or a contact who is this hub’s owner. The author is always derived server-side; there is no author field in the request body. A contact-owner caller authors as their own contact; a team-owner or API-key caller authors as the hub owner’s contact. An admin can never author a discussion on another member’s behalf. Request body (JSON:API envelope, type: "discussions"):
space_id and title are required; body is optional; is_published defaults to true. Any other attribute (including an author_contact_id) is rejected with 422. The post is created with admin privileges, so it can land in any space regardless of that space’s posting rules; the author must still be an active member of the hub. Responses: 201 with the created discussion (its author_contact_id is the resolved author); 404 (space_not_found) if the space doesn’t exist in this hub; 422 (author_not_active_member) if the resolved hub owner is not an active member of the hub (e.g. a hub created before owners were auto-enrolled).

Direct messages

Hub members can send private messages to one another (1:1 or group) without leaving the hub. Every conversation is member-scoped — a caller only ever sees and acts on their own conversations, gated on active hub membership.

List and fetch conversations

Each conversation resource includes: participants is an array of plain objects (not JSON:API resources):
For a 1:1 conversation the counterpart is the non-caller entry in participants — no extra audiences call is needed. last_seen_at is sourced from hub membership presence (the same value the online-indicator uses). The conversation audiences resource (GET /api/v1/conversations/{id}/audiences) exposes the same photo_url and last_seen_at fields per audience member.

Unread badge total

To display an unread badge without fetching the full list, use the unread-total endpoint:
Response:
total equals the sum of unread_count across the caller’s conversations in the hub, excluding the ones the caller has archived. Use it to drive badge counts in tab bars and sidebar nav without hydrating the list.

Archived unread total

Archived conversations don’t count toward unread-total, so an unread message in an archived conversation would otherwise have nowhere to show. Use this endpoint to badge an Archived tab:
Response:
total is the number of the caller’s archived conversations in the hub that hold at least one unread message. It counts conversations, not messages: an archived conversation with three unread messages adds one. Archiving an unread conversation moves it from unread-total to this total, and reading or unarchiving it moves it back out. Like unread-total, it returns 403 for a banned or non-member caller and 404 when the hub doesn’t exist or has direct messages turned off.

Mark all conversations read

Mark every conversation in a hub read in one call:
Request body (JSON:API envelope):
up_to is optional (omit it to use the current server time). When supplied it must be timezone-aware — include a UTC offset or Z. A naive datetime (no offset) returns 422, the same rule as scheduled_at on discussions. The operation is idempotent and monotonic: calling it multiple times is safe, and a read cursor is never moved backward. Submitting an up_to older than an existing cursor is a no-op. The operation succeeds silently; any realtime badge-clear event will be added as this surface ships to production. Response 200:
marked_count is the number of conversations whose read cursor actually advanced (already-read conversations are excluded from the count).

Profiles and directory

Members manage profile data and avatars. The directory exposes hub members who are allowed to appear.

Member resource attributes

Each member resource returned by GET /api/v1/hubs/{hub_id}/members includes:

Query parameters

All filters and filter[search] combine with AND.

Custom attribute filters

You can narrow the member list to contacts that match one or more custom attribute values. Each filter is an indexed group of query parameters — i is an integer starting at 0. Up to 5 attribute filters per request; multiple filters are AND-composed. Use GET /api/hubs/{hub_id}/directory-filters to discover which attribute definitions are searchable for the hub and to obtain their slugs and (for multiple-type attributes) their option slugs. Shared parameters for every filter: Additional parameters by attribute type: Validation rules — all violations return 422:
  • The i index must be a non-negative integer; non-integer index values (e.g. x) are rejected.
  • Scalar fields (slug, operator, value, to) must appear at most once per index group.
  • Unknown subfield names under filter[attributes][i] are rejected.
  • Duplicate slugs across filter groups are rejected.
  • Each option slug in option_slugs[] must exist for the definition.
  • Extraneous fields for the resolved type are rejected (e.g. to on a text filter).
Example — members in London with hiking interest:

Pagination and cursors

When sort is supplied, meta.next_cursor is an opaque composite cursor. Treat it as an opaque string — do not parse or construct cursors. Echo the cursor back via page[after] unchanged. Mixing a cursor that was issued under one sort order with a different sort value returns 422.

First-page meta

The first page (no page[after] cursor) includes three extra counts in meta that drive tab badges in the member directory UI: These counts ignore the current filter[*] and filter[search] params by design — they reflect All/Online/Moderators tab badges, not the filtered result count. Hidden profiles are excluded for non-admin viewers. Cursor pages omit these fields. Response shape (first page):

Hiding your activity

PATCH /api/v1/portal/hubs/{hub_id}/me/profile also accepts hide_activity (boolean, default false), separate from hide_profile:
hide_activity hides a member’s passive-consumption activity — watched, completed, viewed content, and searches — from peers viewing their profile. It does not affect hide_profile (which hides the whole profile) and it never hides a member’s own discussion activity (posts, comments, replies, reactions, mentions) or their account-created event — those stay visible to any peer who can already see the profile. Only the member themself always sees everything regardless of hide_activity — this includes elevated viewers (hub owner/admin/moderator), who no longer bypass hide_activity for another member’s passive activity.

Admin-adding a member

Admins can enroll a contact as an active hub member directly, without the contact going through self-service join — useful for CLI-driven or backoffice population:
Auth is require_hub_admin_or_contact — either a platform team owner, or a contact who is this hub’s owner. Request body (JSON:API envelope, type: "hub_memberships"):
contact_id is required. role is optional — "admin" or "moderator"; omit it or send null for a regular member. role is only assigned once the membership is active. The route writes the full membership: an active hub_memberships row, a linked team_contacts row, and emits MemberAdded so a community_profiles row is created — the member becomes login-capable and directory-visible (a resolvable name on their team_contacts row is what makes them appear in GET .../members). Responses: 201 on first enrollment or on reactivating a previously-left member; 200 on an idempotent re-add of an already-active member; 409 if the contact is banned or soft-banned (unban first); 404 if the hub is missing/soft-deleted, or if contact_id does not resolve to an active (non-soft-deleted) contact.

Admin member list

Auth is require_hub_admin_or_contact (same gate as admin-add above). Lists hub members of all statuses — active, banned, soft_banned, left — for operator verification. This differs from the contact-facing GET /api/v1/hubs/{hub_id}/members directory (above), which is privacy-filtered to active, identifiable members only. Query parameters: Resource type is hub_memberships. Each row has the admin-add response’s attributes above, plus three more: Only this operator list carries these fields. The contact-facing membership reads do not.

Transfer hub ownership

Atomically moves the owner role from the current owner to another existing hub member. This is the escape hatch for revoking a hub owner’s authority — hub_memberships.role = 'owner' is the sole ownership marker (there is no owner column on the hub itself), and until this route shipped there was no way to take it away once granted. Auth is platform team-owner JWT only — stricter than every sibling route on this router. A team-bound API key is rejected (this operation is not exposed to keys), and a hub-owner contact JWT is rejected too (an owner cannot transfer themselves out from under a compromised session using the same credential that may be compromised). Request body (JSON:API envelope, type: "hub_memberships"):
force semantics — read carefully: when force: true, the outgoing owner is always stripped to a regular member (no role at all), regardless of what demote_to says. A demoted owner left at "admin" still has delegated-moderation ban-immunity and pin/lock/move power — force exists for the compromised-owner case, and honoring a lesser demotion there would defeat the point. force also widens the outgoing-owner eligibility check to admit an owner who is currently banned, soft-banned, left, or whose contact was soft-deleted; it never affects the incoming-owner eligibility check (see below). Why demote_to has no default: "admin" sits in most of this codebase’s elevated-power role sets (protected-from-ban, moderation-console access, pin/lock/move power). A silent "admin" default would mean a caller who never thought about it ends up unable to have the new owner ban the person they just replaced. Every caller must choose "admin", "moderator", or null explicitly. Naming the current, healthy owner as new_owner_contact_id is treated as an idempotent no-op (200, meta.idempotent_noop: true) rather than an error — safe to retry. Response meta (in addition to the standard data resource, which is the promoted member’s updated hub_memberships row):
demote_to in the response reports the role actually applied — under force: true this is always null, even if the request body sent a non-null demote_to. When the applied value differs from what was requested, the response also includes requested_demote_to with the raw request value; it is omitted when the two match, and on the idempotent-no-op path. Responses: 200 on success (including the idempotent no-op case) — this route never creates a new resource, so there is no 201. 404 if the hub doesn’t resolve under this team, or if new_owner_contact_id has no membership row on this hub at all. 409 for state conflicts, distinguished by errors[0].code: no_current_owner / multiple_owners (a corrupted precondition — the hub has zero or 2+ role='owner' rows), current_owner_not_eligible (the outgoing owner is banned/soft-banned/left/soft-deleted and force wasn’t set), owner_changed_concurrently (a concurrent write changed ownership between this request’s read and its write — retry), recipient_not_eligible (the incoming member is banned, soft-banned, left, or their contact is soft-deleted — force never overrides this one). 422 if new_owner_contact_id fails to resolve demote_to to one of the allowed values.

Member profile activity feed

GET /api/v1/hubs/{hub_id}/members/{contact_id}/activity/feed returns a keyset-paginated, reverse-chronological timeline of one member’s community activity.
Response is a JSON:API list of member_activity_events resources. Each resource’s id is "{source_type}:{source_id}"; attributes.subject shape varies by kind (see table). Notes:
  • Watched vs. Viewed vs. Completed come from the same underlying content-progress record. Watched fires once on first play of a video/audio lesson; Viewed is the equivalent for non-AV content (PDFs, images, documents, or lessons with no media); Completed fires once the member finishes the content — a single lesson can produce both a Watched/Viewed row and, later, a Completed row.
  • Mentions are outbound only — the feed shows mentions the profile owner made of other members, not mentions of them. Covers both comment @mentions and post-body @mentions.
  • Searches reflect the federated hub search bar (GET /api/v1/hubs/{hub_id}/search — distinct from the community-only search above), recorded server-side on a successful, non-blank query — there is no separate client call to make this show up.
  • Account-created (member_joined) reflects the member’s join date for this hub (hub_memberships.created_at), not their global account age.
  • content_watched / content_completed / content_viewed / search_performed are gated by the target’s hide_activity setting (see above) — any non-self viewer (elevated hub owner/admin/moderator included) viewing a member with hide_activity: true never sees those four kinds, but still sees everything else.
  • Paid-privacy content and restricted-space activity never appear, for any viewer.

Moderation

Admins can ban, warn, review reports, and manage report reasons:

Moderation queue enrichment

Queue items (type moderation_reports) now carry additional meta fields and an author relationship: meta fields on queue items: Author relationship: Queue items include a relationships.author linkage pointing to the contact who created the reported content. The corresponding author contact is returned in a top-level included array as a contacts resource with display_name and avatar_url. The /moderation/removed and /moderation/banned-members endpoints carry analogous author/banned_member relationships — the relevant contact is included in the included array using the same pattern. /moderation/removed items additionally carry meta.report_count (all-time report count for that content, 0 if it was never reported), meta.parent_comment_id (same rule as the queue, above), and meta.title (same rule as the queue, above). /moderation/banned-members items carry attributes.ban_note — the free-text note captured on the member’s most recent ban action, or null if no note was recorded. If a member is banned, unbanned, and banned again, this always reflects the latest ban’s note, regardless of whether the ban was a direct action or the result of resolving a report. Muted members (soft bans). /moderation/banned-members takes a filter[status] query parameter: Any other value returns 422 with code invalid_filter and source.parameter set to filter[status]. Each item carries attributes.soft_ban_until, the time the mute lifts (ISO-8601). It is null on a hard ban. banned_at is null on a member who was only ever muted (a member muted while hard-banned keeps the old stamp). A null banned_at sorts after every stamped row under the default -banned_at sort, and before them under banned_at. Paging with page[after] covers every row under every sort and filter. The 201 response to POST .../members/{contact_id}/soft_ban (a moderation_actions resource) also carries soft_ban_until: the expiry actually applied, which is 7 days from now when the request omits it. Reads of past moderation actions return null there, because the action record does not store the expiry. POST .../members/{contact_id}/unban only acts on a member who is banned or muted. On anyone else it returns 409 with code not_banned, and it writes no moderation action and sends no notification. Before this, it returned 201 with an unban_member action that undid nothing.

Restoring content resolves its reports

POST .../moderation/content/{content_type}/{content_id}/restore is the review. Any report still pending on that discussion or comment is closed as dismissed (status: actioned, resolution: dismissed), the same as dismissing it from the queue, so the item leaves the queue. A report filed after the restore is a new pending report. Restoring content that is not removed returns 409 with code not_removed. If another moderator is resolving one of its reports at that moment, restore returns 503 with code moderation_target_busy and changes nothing; retry it. report_count on discussion, comment and reply resources (present only for a hub owner, admin or moderator) counts pending reports. A restore or a dismissal clears it. GET .../moderation/content/{content_type}/{content_id}/report-summary counts and breaks down the same pending reports. The /moderation/removed list keeps its all-time meta.report_count, because removing content closes its reports.

New reports and the read cursor

Each moderator has their own read cursor per hub, which drives the “new report” indicators in the hub’s moderation console.
  • meta.is_new on a pending queue item is true when that item was reported after the caller’s cursor. With no cursor yet, every pending item is new.
  • attributes.unseen on GET .../moderation/counts is the number of pending items (counted the same way as queue) reported after the cursor.
  • POST .../moderation/mark-read moves the caller’s cursor forward and returns 204 No Content. The cursor never moves backwards.
To mark exactly what you’ve shown, send the newest last_reported_at you rendered as seen_through. A report that arrives after you fetched the queue then stays new:
seen_through must include a UTC offset, since a timestamp without one returns 422. It’s read at millisecond precision and clamped to the server’s current time. Without a body, the cursor moves to now. The cursor is queue-wide: marking read clears every pending item reported at or before it, including ones on pages you didn’t load. API-key callers have no cursor. They don’t get is_new or unseen, and their mark-read calls return 204 without changing anything.

Sorting the queue, removed, and banned-members lists

GET /moderation/queue, GET /moderation/removed, and GET /moderation/banned-members each accept a sort query parameter (sort=<field> for ascending, sort=-<field> for descending): Omitting sort reproduces each endpoint’s pre-sort-support default order exactly — no behavior change for existing integrators. An unrecognized sort value returns 422 with error code invalid_sort, listing the allowed values. Notes on the text sorts:
  • content/-content (queue, removed) orders by the effective visible content text — a discussion’s title, falling back to its body, or a comment’s body. reportable_type/content_type entries with no content (e.g. member or message reports on the queue) sort last in both directions.
  • author_name/-author_name (queue, removed) orders by the content author’s resolved display name; entries with no content author sort last in both directions.
  • member_name/ban_reason/banned_by_name (banned-members) order case-insensitively on the corresponding display value; missing values sort last regardless of direction.
The page[after] cursor encodes the active sort; reusing a cursor minted under a different sort always returns 422 with error code invalid_cursor. On /moderation/removed and /moderation/banned-members, a cursor that fails to parse at all (as opposed to one minted under a different sort) is treated as absent and pagination restarts from the first page; on /moderation/queue, any cursor that can’t be decoded returns 422 invalid_cursor.

Tab counts

Returns exact counts for the three list tabs, computed at read time (no caching, no denormalized counters):
queue counts distinct reported content items — a content item with multiple reports counts once. removed and banned mirror their respective lists’ filters exactly. A hub with no moderation activity returns {"queue": 0, "removed": 0, "banned": 0}.

Viewing reported content

content_type is discussion or comment. This is the only admin surface that can view removed content in full — it works for both live and already-removed content. Returns the full content body (not the 280-character preview used on the lists above), plus created_at, updated_at, and is_removed. Comments additionally include parent_comment_id and discussion_id; discussions include space_id and title. When the content has been removed, the response also includes removed_at and a resolved removed_by actor. The author is included via relationships.author plus a top-level included contact, matching the queue’s pattern. Returns 404 (content_not_found) for a missing, hard-deleted, or cross-hub id. Community search combines lexical search with semantic search when embeddings are enabled:

Access control

Anonymous callers (no JWT or non-member on a public hub) may search without authenticating. Results are limited to content in public spaces.

Rate limits

  • Members: 30 requests per 60 seconds, keyed by contact_id.
  • Anonymous: 10 requests per 60 seconds, keyed by client IP (X-Forwarded-For first, then socket).

Pagination metadata

The response meta object includes:
The community portal namespace is still being unified. Check the generated endpoint page for the exact path before hardcoding a route.