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
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:
| 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:
{
"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:
| 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):
{
"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:
| 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. |
sort | — | created_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] | 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.
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):
{
"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}
Search
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.