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:

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/segment-gated spaces are invisible to anonymous readers (they behave like a viewer with no membership and no segments).
  • 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.

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:

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. 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.

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 (READYCONSUMED) 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:

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 all of the caller’s conversations in the hub. Use it to drive badge counts in tab bars and sidebar nav without hydrating the list.

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 — the same shape as the admin-add response above.

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.

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.