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

Discussions and comments

Members create discussions inside spaces and comments under supported targets:
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

Hub-wide discussion feed

Fetch published discussions across all accessible spaces in one call — useful for activity feeds and notification inboxes:
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:
ParameterDefaultNotes
page[size]20Number of results, 1–100
page[after]Keyset cursor from the previous page’s meta.next_cursor
Response shape:
{
  "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.

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

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.
# Admin sets is_broadcast via the admin discussion route:
PATCH /api/v1/admin/teams/{team_id}/hubs/{hub_id}/discussions/{discussion_id}

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

GET /api/v1/hubs/{hub_id}/conversations          # keyset-paginated list
GET /api/v1/conversations/{id}                   # single conversation
Each conversation resource includes:
AttributeTypeNotes
hub_idstringHub the conversation belongs to
is_groupbooleantrue for group DMs
titlestring|nullGroup DM title; null for 1:1
last_message_atISO-8601Timestamp of the most recent message
last_message_previewstring|nullUp to 200 characters of the latest message body
unread_countintegerMessages the caller hasn’t read yet
participantsarraySee below
created_atISO-8601When the conversation was created
participants is an array of plain objects (not JSON:API resources):
{
  "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:
GET /api/v1/hubs/{hub_id}/conversations/unread-total
Response:
{
  "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:
POST /api/v1/hubs/{hub_id}/conversations/mark-all-read
Request body (JSON:API envelope):
{
  "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:
{
  "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.
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:
AttributeTypeNotes
display_namestring|nullMember’s display name
photo_urlstring|nullFull-size avatar URL
photo_url_expires_atISO-8601|nullUTC expiry of photo_url; null when no avatar or imgproxy unconfigured. Re-fetch the resource before this time for a fresh URL.
photo_thumbnail_urlstring|nullThumbnail avatar URL
photo_thumbnail_url_expires_atISO-8601|nullUTC expiry of photo_thumbnail_url; mirrors photo_url_expires_at.
biostring|nullProfile bio
hide_profileboolean|nullWhether the member has hidden their profile
created_atISO-8601|nullWhen the membership was created
rolestringHub role: "owner", "admin", "moderator", or "member". Members with no elevated role return "member".
last_seen_atISO-8601|nullLast 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

ParameterDefaultNotes
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.
sortcreated_at, -created_at, last_seen_at, or -last_seen_at. Omitting sort returns results in id ascending order. null last_seen_at values always sort last regardless of direction. Invalid values return 422.
page[size]20Number of results, 1–100
page[after]Keyset cursor from the previous page’s meta.next_cursor
All filters and filter[search] combine with AND.

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:
FieldDescription
totalTotal visible members (All tab badge)
online_countMembers seen in the last 5 minutes (Online tab badge)
moderator_countOwners + 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):
{
  "data": [ /* array of member resources */ ],
  "meta": {
    "has_more": true,
    "next_cursor": "<opaque-string>",
    "total": 142,
    "online_count": 7,
    "moderator_count": 3
  }
}

Moderation

Admins can ban, warn, review reports, and manage report reasons:
GET  /api/v1/admin/teams/{team_id}/hubs/{hub_id}/moderation/queue
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}
Community search combines lexical search with semantic search when embeddings are enabled:
GET /api/v1/hubs/{hub_id}/search/community
The community portal namespace is still being unified. Check the generated endpoint page for the exact path before hardcoding a route.