> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Community workflow

> Spaces, discussions, comments, messages, profiles, moderation, and search.

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

## Spaces

Admins create and order spaces:

```text theme={null}
POST /api/v1/admin/teams/{team_id}/hubs/{hub_id}/spaces
GET  /api/v1/admin/teams/{team_id}/hubs/{hub_id}/spaces
```

Members list, join, leave, mute, and mark spaces read:

```text theme={null}
GET  /api/v1/hubs/{hub_id}/spaces
POST /api/v1/hubs/{hub_id}/spaces/{space_id}/join
POST /api/v1/hubs/{hub_id}/spaces/{space_id}/mark-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:

```text theme={null}
GET /api/v1/hubs/{hub_id}/public/spaces
GET /api/v1/hubs/{hub_id}/public/discussions
GET /api/v1/hubs/{hub_id}/public/spaces/{space_id}/discussions
GET /api/v1/hubs/{hub_id}/public/discussions/{discussion_id}
GET /api/v1/hubs/{hub_id}/public/discussions/{discussion_id}/reactions
GET /api/v1/hubs/{hub_id}/public/discussions/{discussion_id}/comments
GET /api/v1/hubs/{hub_id}/public/discussions/{discussion_id}/comments/{comment_id}/replies
```

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:

```text theme={null}
POST /api/v1/hubs/{hub_id}/spaces/{space_id}/discussions
GET  /api/v1/discussions/{discussion_id}
POST /api/v1/comments
GET  /api/v1/comments/{comment_id}/replies
```

### 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:

| Attribute     | Type    | Notes                                                                                                                                                                             |
| ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reply_count` | integer | Count of visible direct replies to this comment. `0` when none. Batch-computed (no N+1). Populated on the list and `/replies` endpoints; single-comment `GET` always returns `0`. |

### Hub-wide discussion feed

Fetch published discussions across all accessible spaces in one call — useful for activity feeds and notification inboxes:

```text theme={null}
GET /api/v1/hubs/{hub_id}/discussions
Authorization: Bearer <contact-access-token>
```

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:

| Parameter     | Default | Notes                                                     |
| ------------- | ------- | --------------------------------------------------------- |
| `page[size]`  | `20`    | Number of results, 1–100                                  |
| `page[after]` | —       | Keyset cursor from the previous page's `meta.next_cursor` |

Response shape:

```json theme={null}
{
  "data": [ /* array of discussion resources */ ],
  "meta": {
    "has_more": true,
    "next_cursor": "2026-06-09T10:00:00+00:00|disc_abc123"
  }
}
```

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:

```text theme={null}
PUT    /api/v1/discussions/{discussion_id}/schedule   # body: { "data": { "attributes": { "scheduled_at": "2026-06-10T14:00:00Z" } } }
DELETE /api/v1/discussions/{discussion_id}/schedule   # cancel — reverts the post to a draft
```

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

```text theme={null}
POST   /api/v1/hubs/{hub_id}/discussions/{discussion_id}/pin
DELETE /api/v1/discussions/{discussion_id}/unpin
```

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

```text theme={null}
# Admin sets is_broadcast via the admin discussion route:
PATCH /api/v1/admin/teams/{team_id}/hubs/{hub_id}/discussions/{discussion_id}
```

### 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:**

```text theme={null}
POST /api/v1/hub/{hub_id}/attachment-uploads
```

```json theme={null}
{
  "data": {
    "type": "attachment_upload_requests",
    "attributes": {
      "attachment_context": "discussion",
      "context_target_type": "space",
      "context_target_id": "<space_id>",
      "mime_type": "image/png",
      "filename": "diagram.png",
      "size_bytes": 245678
    }
  }
}
```

* `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`:

```json theme={null}
{
  "data": {
    "type": "attachment_uploads",
    "id": "<file_id>",
    "attributes": {
      "file_id": "<file_id>",
      "media_id": "<media_id>",
      "status_upload": "PENDING",
      "required_headers": { "Content-Type": "image/png" }
    }
  },
  "meta": { "upload_url": "https://…" }
}
```

**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:**

```text theme={null}
POST /api/v1/hub/{hub_id}/attachment-uploads/{file_id}/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:**

```text theme={null}
POST /api/v1/hubs/{hub_id}/spaces/{space_id}/discussions
POST /api/v1/comments
```

```json theme={null}
{
  "data": {
    "type": "discussions",
    "attributes": {
      "title": "Launch notes",
      "body": "See the deck",
      "attachments": ["<media_id_1>", "<media_id_2>"]
    }
  }
}
```

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:

| Field                      | Type            | Notes                                                                                                                        |
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `id`                       | string          | Attachment id                                                                                                                |
| `media_id`                 | string          |                                                                                                                              |
| `role`                     | string          | Currently always `attachment`                                                                                                |
| `file_id`                  | string \| null  |                                                                                                                              |
| `mime_type`                | string \| null  |                                                                                                                              |
| `asset_kind`               | string \| null  |                                                                                                                              |
| `filename`                 | string \| null  |                                                                                                                              |
| `size_bytes`               | integer \| null |                                                                                                                              |
| `status_upload`            | string \| null  |                                                                                                                              |
| `url`                      | string \| null  | Signed, time-limited URL — populated once the asset is `READY` **and** a CDN/imgproxy signer is configured; `null` otherwise |
| `url_expires_at`           | string \| null  |                                                                                                                              |
| `thumbnail_url`            | string \| null  | Images only                                                                                                                  |
| `thumbnail_url_expires_at` | string \| null  |                                                                                                                              |

### Polls

A discussion can carry a single-choice poll, attached atomically at create time — 2–10 options, a duration of 1–7 days.

```text theme={null}
POST /api/v1/hubs/{hub_id}/spaces/{space_id}/discussions
```

```json theme={null}
{
  "data": {
    "type": "discussions",
    "attributes": {
      "title": "What should we build next?",
      "poll": {
        "options": ["Mobile app", "Dark mode", "API webhooks"],
        "duration_days": 3
      }
    }
  }
}
```

The created (and every subsequently read) discussion carries a `poll` object in `attributes` whenever one is attached — `null`/absent otherwise:

| Field                  | Type           | Notes                                                                                              |
| ---------------------- | -------------- | -------------------------------------------------------------------------------------------------- |
| `id`                   | string         |                                                                                                    |
| `deadline_at`          | string         | ISO-8601, tz-aware. `created_at + duration_days`                                                   |
| `closed`               | boolean        | `true` once `deadline_at` has passed. Computed on every read — there is no background job          |
| `total_votes`          | integer        | Always present, regardless of result visibility                                                    |
| `options[].id`         | string         |                                                                                                    |
| `options[].label`      | string         |                                                                                                    |
| `options[].position`   | integer        | Screen order                                                                                       |
| `options[].vote_count` | integer        | **Present only when results are visible** — see below. The key is omitted (not `null`) when hidden |
| `viewer_voted`         | boolean        | Member scope only — omitted for an anonymous reader                                                |
| `viewer_option_id`     | string \| null | The option this viewer voted for, or `null` if they haven't. Member scope only                     |

**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:**

```text theme={null}
POST /api/v1/discussions/{discussion_id}/poll/votes?hub_id={hub_id}
```

```json theme={null}
{
  "data": {
    "type": "poll_votes",
    "attributes": { "option_id": "<option_id>" }
  }
}
```

A vote is single-choice and immutable — once cast it cannot be changed or withdrawn. The response (`201`) is the full, now-revealed poll:

```json theme={null}
{
  "data": {
    "type": "polls",
    "id": "<poll_id>",
    "attributes": {
      "deadline_at": "2026-08-20T10:00:00Z",
      "closed": false,
      "total_votes": 1,
      "options": [
        { "id": "<option_id>", "label": "Mobile app", "position": 0, "vote_count": 1 },
        { "id": "…", "label": "Dark mode", "position": 1, "vote_count": 0 }
      ],
      "viewer_voted": true,
      "viewer_option_id": "<option_id>"
    }
  }
}
```

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"):**

```text theme={null}
GET /api/v1/discussions/{discussion_id}/poll/votes?hub_id={hub_id}&option_id=<optional>&page[size]=20&page[after]=<cursor>
```

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.

```json theme={null}
{
  "data": [
    {
      "type": "poll_voters",
      "id": "<vote_id>",
      "attributes": {
        "contact_id": "<contact_id>",
        "display_name": "Ada Lovelace",
        "avatar_url": "https://…",
        "avatar_url_expires_at": "2026-08-20T11:00:00Z",
        "option_id": "<option_id>",
        "created_at": "2026-08-13T10:05:00Z"
      }
    }
  ],
  "meta": {
    "has_more": false,
    "next_cursor": null,
    "option_counts": [
      { "option_id": "<option_id>", "count": 1 },
      { "option_id": "…", "count": 0 }
    ]
  }
}
```

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

```text theme={null}
PATCH /api/v1/discussions/{discussion_id}?hub_id={hub_id}
```

```json theme={null}
{
  "data": {
    "attributes": {
      "poll": {
        "options": ["Mobile app", "Dark mode", "API webhooks"],
        "duration_days": 5
      }
    }
  }
}
```

`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

```text theme={null}
GET /api/v1/admin/teams/{team_id}/hubs/{hub_id}/discussions/{discussion_id}
```

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:

```text theme={null}
POST /api/v1/admin/teams/{team_id}/hubs/{hub_id}/discussions
```

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"`):

```json theme={null}
{
  "data": {
    "type": "discussions",
    "attributes": {
      "space_id": "spc_abc123",
      "title": "Welcome to the community!",
      "body": "Glad you're here.",
      "is_published": true
    }
  }
}
```

`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

```text theme={null}
GET /api/v1/hubs/{hub_id}/conversations          # keyset-paginated list
GET /api/v1/conversations/{id}                   # single conversation
```

Each conversation resource includes:

| Attribute              | Type         | Notes                                           |
| ---------------------- | ------------ | ----------------------------------------------- |
| `hub_id`               | string       | Hub the conversation belongs to                 |
| `is_group`             | boolean      | `true` for group DMs                            |
| `title`                | string\|null | Group DM title; null for 1:1                    |
| `last_message_at`      | ISO-8601     | Timestamp of the most recent message            |
| `last_message_preview` | string\|null | Up to 200 characters of the latest message body |
| `unread_count`         | integer      | Messages the caller hasn't read yet             |
| `participants`         | array        | See below                                       |
| `created_at`           | ISO-8601     | When the conversation was created               |

`participants` is an array of plain objects (not JSON:API resources):

```json theme={null}
{
  "contact_id": "con_abc123",
  "display_name": "Priya Sharma",
  "photo_url": "https://…",
  "last_seen_at": "2026-06-08T14:00:00Z",
  "role": "member"
}
```

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:

```text theme={null}
GET /api/v1/hubs/{hub_id}/conversations/unread-total
```

Response:

```json theme={null}
{
  "data": {
    "type": "conversation_unread_totals",
    "id": "<hub_id>",
    "attributes": {
      "total": 4
    }
  }
}
```

`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:

```text theme={null}
POST /api/v1/hubs/{hub_id}/conversations/mark-all-read
```

Request body (JSON:API envelope):

```json theme={null}
{
  "data": {
    "type": "conversation_read_receipts",
    "attributes": {
      "up_to": "2026-06-09T10:30:00Z"
    }
  }
}
```

`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`:

```json theme={null}
{
  "data": {
    "type": "conversation_read_receipts",
    "id": "<hub_id>",
    "attributes": {
      "read_at": "2026-06-09T10:30:00Z",
      "marked_count": 3
    }
  }
}
```

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

```text theme={null}
GET   /api/v1/portal/hubs/{hub_id}/me/profile
PATCH /api/v1/portal/hubs/{hub_id}/me/profile
GET   /api/v1/hubs/{hub_id}/members
```

### Member resource attributes

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

| Attribute                        | Type           | Notes                                                                                                                                                             |
| -------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `display_name`                   | string\|null   | Member's display name                                                                                                                                             |
| `photo_url`                      | string\|null   | Full-size avatar URL                                                                                                                                              |
| `photo_url_expires_at`           | ISO-8601\|null | UTC expiry of `photo_url`; null when no avatar or imgproxy unconfigured. Re-fetch the resource before this time for a fresh URL.                                  |
| `photo_thumbnail_url`            | string\|null   | Thumbnail avatar URL                                                                                                                                              |
| `photo_thumbnail_url_expires_at` | ISO-8601\|null | UTC expiry of `photo_thumbnail_url`; mirrors `photo_url_expires_at`.                                                                                              |
| `bio`                            | string\|null   | Profile bio                                                                                                                                                       |
| `hide_profile`                   | boolean\|null  | Whether the member has hidden their profile                                                                                                                       |
| `created_at`                     | ISO-8601\|null | When the membership was created                                                                                                                                   |
| `role`                           | string         | Hub role: `"owner"`, `"admin"`, `"moderator"`, or `"member"`. Members with no elevated role return `"member"`.                                                    |
| `last_seen_at`                   | ISO-8601\|null | Last portal heartbeat timestamp; `null` if the member has never connected. Updated by the portal heartbeat, the same value used by the online-presence indicator. |

### Query parameters

| Parameter                     | Default | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| ----------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filter[search]`              | —       | Lexical search across display names                                                                                                                                                                                                                                                                                                                                                                                                           |
| `filter[online]`              | —       | Pass `true` to return only members seen in the last 5 minutes. Any other value returns `422`.                                                                                                                                                                                                                                                                                                                                                 |
| `filter[role]`                | —       | Comma-separated role tokens: `owner`, `admin`, `moderator`, `member`. `member` matches contacts with no elevated role. Invalid tokens return `422`.                                                                                                                                                                                                                                                                                           |
| `filter[attributes][i][slug]` | —       | Attribute filter group — see below.                                                                                                                                                                                                                                                                                                                                                                                                           |
| `sort`                        | —       | `created_at`, `-created_at`, `last_seen_at`, `-last_seen_at`, `name`, or `-name`. Omitting `sort` returns results in id ascending order. `null` `last_seen_at` values always sort last regardless of direction. `name`/`-name` orders case-insensitively by the member's resolved display identity (`display_name`, else the contact's first + last name) — the same name shown in the `display_name` attribute. Invalid values return `422`. |
| `page[size]`                  | `20`    | Number of results, 1–100                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `page[after]`                 | —       | Keyset cursor from the previous page's `meta.next_cursor`                                                                                                                                                                                                                                                                                                                                                                                     |

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:**

| Parameter                         | Required | Notes                                                                  |
| --------------------------------- | -------- | ---------------------------------------------------------------------- |
| `filter[attributes][i][slug]`     | Yes      | Public slug of the attribute definition (e.g. `city`, `interests`)     |
| `filter[attributes][i][operator]` | Yes      | Operator — valid values depend on the attribute type (see table below) |

**Additional parameters by attribute type:**

| Type       | Operators                                        | Extra parameters                                                                                                |
| ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `text`     | `equals`, `contains`, `starts_with`, `ends_with` | `filter[attributes][i][value]` — the text to match (case-insensitive)                                           |
| `number`   | `eq`, `gte`, `lte`                               | `filter[attributes][i][value]` — the number                                                                     |
| `number`   | `between`                                        | `filter[attributes][i][value]` — lower bound (inclusive); `filter[attributes][i][to]` — upper bound (inclusive) |
| `boolean`  | `is_true`, `is_false`                            | *(none)*                                                                                                        |
| `date`     | `before`, `after`                                | `filter[attributes][i][value]` — ISO 8601 date (`YYYY-MM-DD`)                                                   |
| `date`     | `between`                                        | `filter[attributes][i][value]` — start date; `filter[attributes][i][to]` — end date                             |
| `multiple` | `has`, `has_not`, `has_any`, `has_all`           | `filter[attributes][i][option_slugs][]` — one or more option slugs (repeated key)                               |

**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:**

```
GET /api/hubs/{hub_id}/members
  ?filter[attributes][0][slug]=city
  &filter[attributes][0][operator]=equals
  &filter[attributes][0][value]=London
  &filter[attributes][1][slug]=interests
  &filter[attributes][1][operator]=has
  &filter[attributes][1][option_slugs][]=hiking
```

### 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:

| Field             | Description                                           |
| ----------------- | ----------------------------------------------------- |
| `total`           | Total visible members (All tab badge)                 |
| `online_count`    | Members seen in the last 5 minutes (Online tab badge) |
| `moderator_count` | Owners + admins + moderators (Moderators tab badge)   |

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

```json theme={null}
{
  "data": [ /* array of member resources */ ],
  "meta": {
    "has_more": true,
    "next_cursor": "<opaque-string>",
    "total": 142,
    "online_count": 7,
    "moderator_count": 3
  }
}
```

### Hiding your activity

`PATCH /api/v1/portal/hubs/{hub_id}/me/profile` also accepts `hide_activity` (boolean, default `false`), separate from `hide_profile`:

```json theme={null}
{ "data": { "type": "community_profiles", "attributes": { "hide_activity": true } } }
```

`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:

```text theme={null}
POST /api/v1/admin/teams/{team_id}/hubs/{hub_id}/members
```

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"`):

```json theme={null}
{
  "data": {
    "type": "hub_memberships",
    "attributes": {
      "contact_id": "con_abc123",
      "role": "moderator"
    }
  }
}
```

`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

```text theme={null}
GET /api/v1/admin/teams/{team_id}/hubs/{hub_id}/members
```

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:

| Parameter        | Default | Notes                                                                           |
| ---------------- | ------- | ------------------------------------------------------------------------------- |
| `page[after]`    | —       | Keyset cursor (member `contact_id`) from the previous page's `meta.next_cursor` |
| `page[size]`     | `50`    | Number of results, 1–100                                                        |
| `filter[status]` | —       | One of `active`, `banned`, `soft_banned`, `left`. Invalid value returns `422`.  |

Resource type is `hub_memberships` — the same shape as the admin-add response above.

### Transfer hub ownership

```text theme={null}
POST /api/v1/admin/teams/{team_id}/hubs/{hub_id}/ownership-transfer
```

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"`):

```json theme={null}
{
  "data": {
    "type": "hub_memberships",
    "attributes": {
      "new_owner_contact_id": "con_abc123",
      "force": false,
      "demote_to": "admin"
    }
  }
}
```

| Field                  | Default                  | Notes                                                                                                                                                                                                                   |
| ---------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `new_owner_contact_id` | required                 | Must already hold a `hub_memberships` row on this hub — this route never enrolls a new member.                                                                                                                          |
| `force`                | `false`                  | See below.                                                                                                                                                                                                              |
| `demote_to`            | **required, no default** | `"admin"`, `"moderator"`, or `null`. **Only honored when `force` is `false`.** Omitting the field entirely returns `422` — callers must state intent explicitly rather than relying on an implicit "admin" (see below). |

**`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):

```json theme={null}
{
  "meta": {
    "old_owner_contact_id": "con_prev",
    "new_owner_contact_id": "con_abc123",
    "demote_to": "admin",
    "force": false,
    "transferred_at": "2026-07-31T18:00:00Z"
  }
}
```

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

```text theme={null}
GET /api/v1/hubs/{hub_id}/members/{contact_id}/activity/feed
  ?page[size]=20
  &page[after]=<opaque-cursor>
```

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

| `kind`              | `verb`      | Summary example         | `subject` shape                                                                 |
| ------------------- | ----------- | ----------------------- | ------------------------------------------------------------------------------- |
| `post_created`      | `posted`    | "Started a discussion"  | `{ type: "discussion", id, title, space_id, space_slug }`                       |
| `comment_created`   | `commented` | body snippet            | `{ type: "discussion", id, discussion_id, title, space_id, space_slug }`        |
| `reply_created`     | `replied`   | body snippet            | `{ type: "comment", id, discussion_id, title, space_id, space_slug }`           |
| `reaction_created`  | `reacted`   | "Reacted to a post"     | `{ type: "discussion" \| "comment" \| "reply", id, ... }`                       |
| `content_watched`   | `watched`   | "Watched {title}"       | `{ type: "content_node", id, title }`                                           |
| `content_completed` | `completed` | "Completed {title}"     | `{ type: "content_node", id, title }`                                           |
| `content_viewed`    | `viewed`    | "Viewed {title}"        | `{ type: "content_node", id, title }`                                           |
| `member_joined`     | `joined`    | "Created account"       | `{ type: "hub_membership", id }`                                                |
| `mention_created`   | `mentioned` | "Mentioned {name}"      | `{ type: "contact", mentioned_contact_id, display_name, discussion_id, title }` |
| `search_performed`  | `searched`  | `Searched for "{term}"` | `{ query }`                                                                     |

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:

```text theme={null}
GET  /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/queue
GET  /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/removed
GET  /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/banned-members
GET  /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/counts
GET  /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/content/{content_type}/{content_id}
POST /api/v1/admin/teams/{team_id}/hubs/{hub_id}/members/{contact_id}/ban
PATCH /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/reports/{report_id}
```

### Moderation queue enrichment

Queue items (type `moderation_reports`) now carry additional `meta` fields and an author relationship:

**`meta` fields on queue items:**

| Field               | Type             | Notes                                                                                                                      |
| ------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `report_count`      | integer          | Total number of reports filed for this content item                                                                        |
| `first_reported_at` | ISO-8601         | When the first report was received                                                                                         |
| `last_reported_at`  | ISO-8601         | When the most recent report on this content item was received                                                              |
| `content_preview`   | string or null   | Up to 280 characters of the reported content body; null when content is unavailable or has no body                         |
| `title`             | string or null   | The reported discussion's title; null for comments, replies, members, and messages                                         |
| `reason_breakdown`  | array of strings | List of distinct report reason labels submitted by reporters                                                               |
| `parent_comment_id` | string or null   | The parent comment's id when the reported item is a reply; null for discussions, top-level comments, members, and messages |

**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):

| Endpoint                     | Allowed `sort` values                                                                                                     | Default             |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| `/moderation/queue`          | `last_reported_at`, `report_count`, `reportable_type`, `content`, `author_name` (and their `-`-prefixed descending forms) | `-last_reported_at` |
| `/moderation/removed`        | `removed_at`, `content_type`, `content`, `author_name` (and their `-`-prefixed descending forms)                          | `-removed_at`       |
| `/moderation/banned-members` | `banned_at`, `member_name`, `ban_reason`, `banned_by_name` (and their `-`-prefixed descending forms)                      | `-banned_at`        |

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

```text theme={null}
GET /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/counts
```

Returns exact counts for the three list tabs, computed at read time (no caching, no denormalized counters):

```json theme={null}
{
  "data": {
    "type": "moderation_counts",
    "attributes": { "queue": 3, "removed": 1, "banned": 2 }
  }
}
```

`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

```text theme={null}
GET /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/content/{content_type}/{content_id}
```

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

## Search

Community search combines lexical search with semantic search when embeddings are enabled:

```text theme={null}
GET /api/v1/hubs/{hub_id}/search/community
```

### Access control

| Caller                   | Hub visibility    | Result                             |
| ------------------------ | ----------------- | ---------------------------------- |
| Authenticated hub member | Public or private | Spaces visible to the member       |
| Authenticated non-member | Public hub        | Public spaces only (anonymous ACL) |
| Authenticated non-member | Private hub       | `403 hub_membership_required`      |
| Unauthenticated          | Public hub        | Public spaces only (anonymous ACL) |
| Unauthenticated          | Private hub       | `403 hub_private`                  |

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:

| Field       | Type    | Description                                                                                                   |
| ----------- | ------- | ------------------------------------------------------------------------------------------------------------- |
| `has_more`  | boolean | `true` if additional results exist beyond the current page                                                    |
| `is_capped` | boolean | `true` if the internal RRF candidate set hit its limit (100); a wider query may produce different top results |

<Note>
  The community portal namespace is still being unified. Check the generated endpoint page for the exact path before hardcoding a route.
</Note>
