Tenant-keyed channels, members, messages, moderation delete, last_message previews, after_id catch-up, message search (q= /v1/search), thread replies (parent_id), and realtime SSE.
Auth: Channel and message routes require a tenant API key (Bearer). Unauthenticated list/post/stream return 401. Search and /v1/me require a key.
Help dual-writes into this core. Machine spec: /openapi.json · health: /v1/health.chat.bolrach.comapi.chat.bolrach.comrealtime.chat.bolrach.comEvery /v1 call needs your API key (except public health):
Authorization: Bearer sk_live_xxxxxxxx
curl https://api.chat.bolrach.com/v1/me -H "Authorization: Bearer sk_live_..."
curl -X POST https://api.chat.bolrach.com/v1/channels \
-H "Authorization: Bearer sk_live_..." -H "content-type: application/json" \
-d '{"slug":"general","name":"General"}'
curl -X POST https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages \
-H "Authorization: Bearer sk_live_..." -H "content-type: application/json" \
-d '{"user_ref":"u_123","display_name":"Ada","body":"hello"}'
Help dual-write: public Help chat starts are rate-limited at the Help API (20/10m per IP). Chat Core channel APIs use API keys and are not the widget start limiter.
curl -X PATCH https://api.chat.bolrach.com/v1/channels/CHANNEL_ID -H "Authorization: Bearer $KEY" -H "content-type: application/json" -d '{"archived":false}'
curl https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/members -H "Authorization: Bearer $KEY"
GET /v1/channels/{id}/members?q=ada&role=admin&limit=50 filters by user_ref/display_name substring and role. Supports offset / has_more.
GET /v1/channels/{id}/members?exclude_role=member (alias not_role) omits that role. Conflicts with role= (bad_role_filter).
GET /v1/channels/{id}/members?sort=name|role|user_ref|created (default created). Role order is admin, moderator, member. Combines with q and role.
GET /v1/channels/{id}/members?sort=last_read (aliases last_read_at/read) orders by last_read_at descending; never-read members sink to the end.
GET /v1/channels/{id}/members?sort=last_read_asc (aliases oldest_read/read_asc) orders by last_read_at ascending so stale readers surface first; never-read members sink to the end.
GET /v1/channels/{id}/members?has_display_name=1 or no_display_name=1 filters by presence of display_name. Mutually exclusive (bad_display_name_filter).
GET /v1/channels/{id}/members?has_last_read=1 or no_last_read=1 (aliases read/never_read) filters by whether last_read_at is set. Mutually exclusive (bad_last_read_filter).
curl -X PATCH https://api.chat.bolrach.com/v1/channels/CHANNEL_ID -H "Authorization: Bearer $KEY" -H "content-type: application/json" -d '{"name":"Support","topic":"Tier-1"}'
curl -X PATCH https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/members/USER_REF -H "Authorization: Bearer $KEY" -H "content-type: application/json" -d '{"role":"moderator"}'
Archive first, then DELETE. Live channels return 409.
curl -X PATCH https://api.chat.bolrach.com/v1/channels/CHANNEL_ID \
-H "Authorization: Bearer sk_live_..." -H "content-type: application/json" \
-d '{"archived":true}'
curl -X DELETE https://api.chat.bolrach.com/v1/channels/CHANNEL_ID \
-H "Authorization: Bearer sk_live_..."
Channel body search or tenant-wide hits across non-archived channels:
curl "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages?q=hello" \ -H "Authorization: Bearer sk_live_..." curl "https://api.chat.bolrach.com/v1/search?q=hello&limit=30" \ -H "Authorization: Bearer sk_live_..."
Pin keeps a message at the top of channel attention. List with ?pinned=1.
Reply with parent_id on POST. List a thread with ?parent_id=ROOT_ID. Top-level only with ?root=1 (includes reply_count). One-level threads: replies to a reply attach to the root.
curl -X POST "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages" -H "Authorization: Bearer sk_live_..." -H "content-type: application/json" -d '{"user_ref":"alice","body":"reply","parent_id":"ROOT_MESSAGE_UUID"}'
curl "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages?parent_id=ROOT_MESSAGE_UUID" -H "Authorization: Bearer sk_live_..."
curl "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages?root=1" -H "Authorization: Bearer sk_live_..."
List trash with ?deleted_only=1. PATCH on a deleted row returns 409 message_deleted (restore first).
DELETE marks deleted_at (tombstone). Default lists hide deleted messages. Pass ?include_deleted=1 to audit. Channel message_count / last_message also ignore soft-deleted rows.
curl -X DELETE "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages/MESSAGE_ID" -H "Authorization: Bearer sk_live_..." curl "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages?include_deleted=1" -H "Authorization: Bearer sk_live_..."
?edited_only=1 returns only messages with edited_at set.
GET /v1/channels/{id}/messages?user_ref=u1 returns only messages authored by that user_ref. Combines with q, pinned, root, and trash/edited filters.
GET /v1/channels/{id}/messages?exclude_user_ref=u1 (alias not_user_ref=) omits that author. Conflicts with user_ref (bad_user_ref_filter).
GET /v1/channels/{id}/messages?display_name=Ada (alias name=) case-insensitive substring match on display_name. Combines with other list filters.
GET /v1/channels/{id}/messages?after=2026-01-01T00:00:00Z&before=2026-12-31T23:59:59Z (aliases since/until) filters by created_at ISO bounds. Invalid timestamps return bad_after / bad_before.
Slugs are normalized to lowercase a-z0-9 with single hyphens, max 64. Invalid shape returns 400 bad_slug.
GET /v1/health includes a limits object (body_max, attachments_max, rate_limit) for clients and status probes.
Message body max 8000 characters. Over limit returns 400 body_too_long (no silent truncate).
Tenant keys: 120 requests / 10s. Over limit returns 429 with Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining: 0.
Message attachments is metadata only (URLs/names/sizes). Max 20 items; each name ≤200 chars; optional size ≤25 MB each; optional url must be http(s). Oversized or bad shapes return 400.
?offset=N with optional limit pages results after filter/sort. Response includes has_more.
?limit=N (1–500) caps the returned page after filter/sort. Response includes count (page) and total (pre-limit).
?sort=name|messages|members|pins|created (default created, newest first). Combines with q= and kind=.
GET /v1/channels?kind=group keeps channels whose kind matches exactly. Combines with q=.
GET /v1/channels?exclude_kind=dm (alias not_kind=) omits that kind. Conflicts with kind= (bad_kind_filter).
GET /v1/channels?has_pins=1 (alias pinned_channels=1) keeps channels with at least one live pin. Combines with q=, kind=, and sort.
GET /v1/channels?no_pins=1 (aliases without_pins/unpinned_channels) keeps channels with zero live pins. Conflicts with has_pins (bad_pins_filter).
GET /v1/channels?min_pins=2 keeps channels with at least N live pins. Combines with has_pins, size filters, and sort.
GET /v1/channels?max_pins=5 keeps channels with at most N live pins. Zero means no upper bound. Combines with min_pins.
GET /v1/channels?min_members=2&max_members=50&min_messages=10 filters by member_count and message_count after list load. Zero means no bound. Combines with has_pins, kind, and q.
GET /v1/channels?max_messages=100&has_topic=1 (alias with_topic=1) caps message_count and keeps only channels with a non-empty topic.
GET /v1/channels?no_topic=1 (alias without_topic=1) keeps channels with empty/null topic. Conflicts with has_topic (bad_topic_filter).
GET /v1/channels?archived_only=1 (alias only_archived=1) returns only archived channels (implies include_archived).
GET /v1/channels?q=support filters by name, slug, or topic. Response includes q and count.
Channel list rows include pin_count (live pinned messages) alongside message_count.
GET /v1/channels/{id}/stats returns message_count, member_count, pin_count, deleted_count, edited_count.
GET /v1/channels/{id}/stats also returns root_count, reply_count, reaction_count, and messages_with_attachments.
curl "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/stats" -H "Authorization: Bearer sk_live_..."
GET /v1/channels/{id}/pins returns pinned live messages (same as ?pinned=1 on the messages list).
GET /v1/channels/{id}/pins?exclude_user_ref=u1 (alias not_user_ref) omits that author. Conflicts with user_ref (bad_user_ref_filter).
GET /v1/channels/{id}/pins?display_name=ada (alias name) filters pins by author display_name substring (case-insensitive).
curl "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/pins" -H "Authorization: Bearer sk_live_..."
GET /v1/channels/{id}/pins?q=hello&user_ref=u1&limit=20 filters pinned messages. Supports offset / has_more.
GET /v1/channels/{id}/pins?sort=pinned|created|oldest|pinned_asc (default pinned = most recently pinned first). created/newest order by message time; oldest reverses creation time.
GET /v1/channels/{id}/pins?has_attachments=1 keeps only pinned messages with a non-empty attachments array.
GET /v1/channels/{id}/pins?no_attachments=1 (alias without_attachments) keeps pins with empty/null attachments. Conflicts with has_attachments (bad_attachments_filter).
GET /v1/channels/{id}/pins?has_reactions=1 keeps only pinned messages that have at least one reaction row.
GET /v1/channels/{id}/pins?no_reactions=1 (alias without_reactions) keeps pins with zero reaction rows. Conflicts with has_reactions / emoji (bad_reactions_filter).
GET /v1/channels/{id}/pins?emoji=%F0%9F%94%A5 (alias reaction=) keeps pins with that exact reaction emoji. Max 32 characters.
GET /v1/channels/{id}/messages?has_attachments=1 returns only messages with a non-empty attachments array.
GET /v1/channels/{id}/messages?no_attachments=1 (alias without_attachments) keeps messages with empty/null attachments. Conflicts with has_attachments (bad_attachments_filter).
GET /v1/channels/{id}/messages?has_reactions=1 returns only messages that have at least one reaction row.
GET /v1/channels/{id}/messages?no_reactions=1 (alias without_reactions) keeps messages with zero reaction rows. Conflicts with has_reactions / emoji (bad_reactions_filter).
GET /v1/channels/{id}/messages?emoji=%F0%9F%91%8D (alias reaction=) returns only messages with that exact reaction emoji. Max 32 characters.
GET /v1/channels/{id}/messages?has_replies=1 (alias replies=1) returns only messages that have at least one non-deleted reply. Pairs well with root=1.
GET /v1/channels/{id}/messages?is_reply=1 returns only messages with parent_id set. Conflicts with root=1 (bad_reply_root).
GET /v1/channels/{id}/messages?no_replies=1 (alias without_replies=1) returns only messages with zero live replies. Conflicts with has_replies (bad_replies_filter).
POST members enforces the same user_ref/display_name max and validates role (bad_role).
user_ref max 200, display_name max 80, reaction emoji max 32. Over limit returns 400 *_too_long.
Name max 120, topic max 500. Over limit returns 400 name_too_long / topic_too_long (no silent truncate).
POST /v1/channels/{id}/messages/purge-deleted hard-deletes every soft-deleted message in the channel (empty trash).
Soft-delete first (DELETE). Then POST .../purge permanently removes the tombstone and its reactions. Or DELETE .../messages/ID?hard=1 on an already-deleted row.
curl -X DELETE "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages/MESSAGE_ID" -H "Authorization: Bearer sk_live_..." curl -X POST "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages/MESSAGE_ID/purge" -H "Authorization: Bearer sk_live_..."
POST restore clears deleted_at so the message returns to default lists.
curl -X POST "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages/MESSAGE_ID/restore" -H "Authorization: Bearer sk_live_..."
POST /v1/read with {"user_ref":"u1"} sets last_read_at=now() on every membership for that user in the tenant. Pair with GET /v1/me?user_ref= unread totals.
curl -X POST "https://api.chat.bolrach.com/v1/read" -H "Authorization: Bearer sk_live_..." -H "content-type: application/json" -d '{"user_ref":"u1"}'
GET /v1/search?q=hello&sort=newest|oldest (default newest). Echoes sort on the response.
GET /v1/search?q=hello&sort=pinned (aliases pinned_first, pins) orders pinned messages first, then newest. Default remains newest; oldest still works.
GET /v1/search?q=hello&sort=edited (aliases edited_at, recently_edited) orders by most recently edited first.
GET /v1/me stats include reaction_top — up to 5 {emoji, count} pairs ordered by frequency.
GET /v1/me stats include messages_today (live messages created since 00:00 UTC) and channels_with_pins (active channels that have at least one pin).
GET /v1/me stats include channels_with_topic — active channels with a non-empty topic string.
GET /v1/me stats include members_with_display_name — membership rows with a non-empty display_name.
GET /v1/me stats include messages_week (live messages in the last 7 days UTC) and members_by_role (role → count map).
GET /v1/me stats include messages_month — live messages created in the last 30 days UTC.
GET /v1/me stats include members_with_last_read and members_never_read (membership rows by whether last_read_at is set).
GET /v1/me stats include messages_year — live messages created in the last 365 days UTC.
GET /v1/me stats include channels_without_topic (active channels with empty topic) and pins_month (pins whose pinned_at is in the last 30 days UTC).
GET /v1/me stats include pins_week — pins whose pinned_at is in the last 7 days UTC.
GET /v1/me stats include reactions_week and reactions_month — reaction rows created in the last 7 / 30 days UTC.
GET /v1/me stats include replies_week — live reply messages (parent_id set) created in the last 7 days UTC.
GET /v1/me stats include replies_month (live replies last 30 days UTC) and channels_without_pins (active channels with zero live pins).
GET /v1/me stats include edited_week (live messages with edited_at in the last 7 days UTC) and deleted_week (messages soft-deleted in the last 7 days UTC).
GET /v1/me stats include edited_month and deleted_month — same as the week counters but over the last 30 days UTC.
GET /v1/me stats include attachments_week (live messages with attachments created in the last 7 days UTC) and roots_week (root messages with no parent_id in the same window).
GET /v1/me stats include attachments_month — live messages with attachments created in the last 30 days UTC.
GET /v1/me stats include roots_month — root messages (no parent_id) created in the last 30 days UTC.
GET /v1/me stats include unique_authors_week — distinct live message authors in the last 7 days UTC.
GET /v1/me stats include unique_authors_month — distinct live message authors in the last 30 days UTC.
GET /v1/me stats include messages_hour — live messages created in the last hour UTC.
GET /v1/me stats include reactions_hour — reaction rows created in the last hour UTC.
GET /v1/me stats include pins_hour (pins whose pinned_at is in the last hour UTC) and replies_hour (live replies created in the last hour UTC).
GET /v1/me stats include edited_hour (live messages with edited_at in the last hour UTC) and deleted_hour (soft-deleted in the last hour UTC).
GET /v1/me stats include unique_authors_hour (distinct live message authors in the last hour UTC) and attachments_hour (live messages with attachments created in the last hour UTC).
GET /v1/me stats include roots_hour (root messages with no parent_id created in the last hour UTC) and channels_hour (channels created in the last hour UTC).
GET /v1/me stats include members_hour (memberships created in the last hour UTC) and messages_with_reactions_hour (live messages that have at least one reaction and were created in the last hour UTC).
GET /v1/me stats include unique_reaction_emoji_hour (distinct reaction emoji used in the last hour UTC) and archived_hour (channels whose archived_at is in the last hour UTC).
GET /v1/me stats include unique_reactors_hour (distinct reaction authors in the last hour UTC) and channels_active_hour (channels with at least one live message created in the last hour UTC).
GET /v1/me stats include unique_editors_hour (distinct authors of live messages with edited_at in the last hour UTC) and unique_pin_authors_hour (distinct authors of messages pinned in the last hour UTC).
Search responses include total (matches before limit/offset) plus page count and has_more.
GET /v1/me stats include unique_users (distinct member user_ref) and unique_authors (distinct live message authors).
GET /v1/me stats include reactions (tenant-wide reaction rows).
GET /v1/search?q=hello&limit=10&offset=10 pages hits. Response includes offset, limit, has_more.
GET /v1/search?q=hello&user_ref=u1 filters hits by author. Combines with channel_id.
GET /v1/search?q=hello&channel_id=UUID limits hits to one channel. Bad uuid returns 400 bad_channel_id.
Stats include channels_active, channels_archived, pins, and messages_edited.
GET /v1/me stats include channels_active, channels_archived, and pins in addition to channels/messages/members/messages_deleted.
GET /v1/me?user_ref=u1 returns stats.unread_total and stats.unread_channels across non-archived memberships.
curl "https://api.chat.bolrach.com/v1/me?user_ref=u1" -H "Authorization: Bearer sk_live_..."
Members track last_read_at. Pass ?user_ref= on channel list/get for unread_count (live messages after last_read_at). POST read marks the channel current for that user.
curl -X POST "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/read" -H "Authorization: Bearer sk_live_..." -H "content-type: application/json" -d '{"user_ref":"u1"}'
curl "https://api.chat.bolrach.com/v1/channels?user_ref=u1" -H "Authorization: Bearer sk_live_..."
Emoji reactions are per user per message. One emoji can be toggled with POST (add) and DELETE (remove).
curl -X POST "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages/MESSAGE_ID/reactions" -H "Authorization: Bearer $KEY" -H "content-type: application/json" -d '{"user_ref":"u1","emoji":"👍"}'
curl -X POST https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages/MESSAGE_ID/pin -H "Authorization: Bearer sk_live_..." curl -X DELETE https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages/MESSAGE_ID/pin -H "Authorization: Bearer sk_live_..." curl "https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/messages?pinned=1" -H "Authorization: Bearer sk_live_..."
Browsers cannot set Authorization on EventSource. Pass the key as a query param on the stream only:
const es = new EventSource(
"https://realtime.chat.bolrach.com/v1/channels/CHANNEL_ID/stream?api_key=sk_live_..."
);
es.addEventListener("message", e => console.log(JSON.parse(e.data)));
/v1/health public/v1/me tenant for this key/v1/channels · GET /v1/channels/:id/v1/search?q= tenant-wide message search