BolrachChat
Private build · L8 · v1.5.13

Chat Core for products.

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.

Product · chat.bolrach.com
REST · api.chat.bolrach.com
Realtime (SSE) · realtime.chat.bolrach.com

Authenticate

Every /v1 call needs your API key (except public health):

Authorization: Bearer sk_live_xxxxxxxx

Who am I?

curl https://api.chat.bolrach.com/v1/me -H "Authorization: Bearer sk_live_..."

Create a channel

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"}'

Post a message

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.

Unarchive channel

curl -X PATCH https://api.chat.bolrach.com/v1/channels/CHANNEL_ID   -H "Authorization: Bearer $KEY" -H "content-type: application/json"   -d '{"archived":false}'

List channel members

curl https://api.chat.bolrach.com/v1/channels/CHANNEL_ID/members   -H "Authorization: Bearer $KEY"

Members list filters (v1.5.7)

GET /v1/channels/{id}/members?q=ada&role=admin&limit=50 filters by user_ref/display_name substring and role. Supports offset / has_more.

Members exclude_role (v1.5.7)

GET /v1/channels/{id}/members?exclude_role=member (alias not_role) omits that role. Conflicts with role= (bad_role_filter).

Members list sort (v1.5.7)

GET /v1/channels/{id}/members?sort=name|role|user_ref|created (default created). Role order is admin, moderator, member. Combines with q and role.

Members sort last_read (v1.5.7)

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.

Members sort last_read_asc (v1.5.7)

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.

Members has_display_name (v1.5.7)

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

Members has_last_read (v1.5.7)

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

Rename / topic (v1.4)

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"}'

Member role (v1.4.7)

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"}'

Hard-delete archived channel (v1.4.7)

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

Search (v1.4)

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 message (v1.4.7)

Pin keeps a message at the top of channel attention. List with ?pinned=1.

Thread replies (v1.4.8)

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

Soft-delete (v1.4.9)

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 list (v1.5.7)

?edited_only=1 returns only messages with edited_at set.

Messages user_ref (v1.5.7)

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.

Messages exclude_user_ref (v1.5.7)

GET /v1/channels/{id}/messages?exclude_user_ref=u1 (alias not_user_ref=) omits that author. Conflicts with user_ref (bad_user_ref_filter).

Messages display_name (v1.5.7)

GET /v1/channels/{id}/messages?display_name=Ada (alias name=) case-insensitive substring match on display_name. Combines with other list filters.

Messages after/before (v1.5.7)

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.

Channel slug rules (v1.5.7)

Slugs are normalized to lowercase a-z0-9 with single hyphens, max 64. Invalid shape returns 400 bad_slug.

Health limits (v1.5.7)

GET /v1/health includes a limits object (body_max, attachments_max, rate_limit) for clients and status probes.

Body length limit (v1.5.7)

Message body max 8000 characters. Over limit returns 400 body_too_long (no silent truncate).

Rate limit headers (v1.5.7)

Tenant keys: 120 requests / 10s. Over limit returns 429 with Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining: 0.

Attachment metadata limits (v1.5.7)

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.

Channel list offset (v1.5.7)

?offset=N with optional limit pages results after filter/sort. Response includes has_more.

Channel list limit (v1.5.7)

?limit=N (1–500) caps the returned page after filter/sort. Response includes count (page) and total (pre-limit).

Channel list sort (v1.5.7)

?sort=name|messages|members|pins|created (default created, newest first). Combines with q= and kind=.

Channel list kind filter (v1.5.7)

GET /v1/channels?kind=group keeps channels whose kind matches exactly. Combines with q=.

Channel list exclude_kind (v1.5.7)

GET /v1/channels?exclude_kind=dm (alias not_kind=) omits that kind. Conflicts with kind= (bad_kind_filter).

Channel list has_pins (v1.5.7)

GET /v1/channels?has_pins=1 (alias pinned_channels=1) keeps channels with at least one live pin. Combines with q=, kind=, and sort.

Channel list no_pins (v1.5.7)

GET /v1/channels?no_pins=1 (aliases without_pins/unpinned_channels) keeps channels with zero live pins. Conflicts with has_pins (bad_pins_filter).

Channel list min_pins (v1.5.7)

GET /v1/channels?min_pins=2 keeps channels with at least N live pins. Combines with has_pins, size filters, and sort.

Channel list max_pins (v1.5.7)

GET /v1/channels?max_pins=5 keeps channels with at most N live pins. Zero means no upper bound. Combines with min_pins.

Channel list size filters (v1.5.7)

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.

Channel list max_messages / has_topic (v1.5.7)

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.

Channel list no_topic (v1.5.7)

GET /v1/channels?no_topic=1 (alias without_topic=1) keeps channels with empty/null topic. Conflicts with has_topic (bad_topic_filter).

Channel list archived_only (v1.5.7)

GET /v1/channels?archived_only=1 (alias only_archived=1) returns only archived channels (implies include_archived).

Channel list search (v1.5.7)

GET /v1/channels?q=support filters by name, slug, or topic. Response includes q and count.

Channel list pin_count (v1.5.7)

Channel list rows include pin_count (live pinned messages) alongside message_count.

Channel stats (v1.5.7)

GET /v1/channels/{id}/stats returns message_count, member_count, pin_count, deleted_count, edited_count.

Channel stats depth (v1.5.7)

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

List pins (v1.5.7)

GET /v1/channels/{id}/pins returns pinned live messages (same as ?pinned=1 on the messages list).

Pins exclude_user_ref (v1.5.7)

GET /v1/channels/{id}/pins?exclude_user_ref=u1 (alias not_user_ref) omits that author. Conflicts with user_ref (bad_user_ref_filter).

Pins display_name (v1.5.7)

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

Pins list filters (v1.5.7)

GET /v1/channels/{id}/pins?q=hello&user_ref=u1&limit=20 filters pinned messages. Supports offset / has_more.

Pins list sort (v1.5.7)

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.

Pins has_attachments (v1.5.7)

GET /v1/channels/{id}/pins?has_attachments=1 keeps only pinned messages with a non-empty attachments array.

Pins no_attachments (v1.5.7)

GET /v1/channels/{id}/pins?no_attachments=1 (alias without_attachments) keeps pins with empty/null attachments. Conflicts with has_attachments (bad_attachments_filter).

Pins has_reactions (v1.5.7)

GET /v1/channels/{id}/pins?has_reactions=1 keeps only pinned messages that have at least one reaction row.

Pins no_reactions (v1.5.7)

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

Pins emoji filter (v1.5.7)

GET /v1/channels/{id}/pins?emoji=%F0%9F%94%A5 (alias reaction=) keeps pins with that exact reaction emoji. Max 32 characters.

Messages has_attachments (v1.5.7)

GET /v1/channels/{id}/messages?has_attachments=1 returns only messages with a non-empty attachments array.

Messages no_attachments (v1.5.7)

GET /v1/channels/{id}/messages?no_attachments=1 (alias without_attachments) keeps messages with empty/null attachments. Conflicts with has_attachments (bad_attachments_filter).

Messages has_reactions (v1.5.7)

GET /v1/channels/{id}/messages?has_reactions=1 returns only messages that have at least one reaction row.

Messages no_reactions (v1.5.7)

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

Messages emoji filter (v1.5.7)

GET /v1/channels/{id}/messages?emoji=%F0%9F%91%8D (alias reaction=) returns only messages with that exact reaction emoji. Max 32 characters.

Messages has_replies (v1.5.7)

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.

Messages is_reply list (v1.5.7)

GET /v1/channels/{id}/messages?is_reply=1 returns only messages with parent_id set. Conflicts with root=1 (bad_reply_root).

Messages no_replies (v1.5.7)

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

Member identity limits (v1.5.7)

POST members enforces the same user_ref/display_name max and validates role (bad_role).

Identity field limits (v1.5.7)

user_ref max 200, display_name max 80, reaction emoji max 32. Over limit returns 400 *_too_long.

Channel name/topic limits (v1.5.7)

Name max 120, topic max 500. Over limit returns 400 name_too_long / topic_too_long (no silent truncate).

Empty trash (v1.5.7)

POST /v1/channels/{id}/messages/purge-deleted hard-deletes every soft-deleted message in the channel (empty trash).

Hard purge soft-delete (v1.5.7)

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

Restore soft-delete (v1.5.7)

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

Mark all read (v1.5.7)

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"}'

Search sort (v1.5.7)

GET /v1/search?q=hello&sort=newest|oldest (default newest). Echoes sort on the response.

Search sort pinned (v1.5.7)

GET /v1/search?q=hello&sort=pinned (aliases pinned_first, pins) orders pinned messages first, then newest. Default remains newest; oldest still works.

Search sort edited (v1.5.7)

GET /v1/search?q=hello&sort=edited (aliases edited_at, recently_edited) orders by most recently edited first.

Me reaction_top (v1.5.7)

GET /v1/me stats include reaction_top — up to 5 {emoji, count} pairs ordered by frequency.

Me messages_today (v1.5.7)

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

Me channels_with_topic (v1.5.7)

GET /v1/me stats include channels_with_topic — active channels with a non-empty topic string.

Me members_with_display_name (v1.5.7)

GET /v1/me stats include members_with_display_name — membership rows with a non-empty display_name.

Me messages_week + members_by_role (v1.5.7)

GET /v1/me stats include messages_week (live messages in the last 7 days UTC) and members_by_role (role → count map).

Me messages_month (v1.5.7)

GET /v1/me stats include messages_month — live messages created in the last 30 days UTC.

Me members last_read counts (v1.5.7)

GET /v1/me stats include members_with_last_read and members_never_read (membership rows by whether last_read_at is set).

Me messages_year (v1.5.7)

GET /v1/me stats include messages_year — live messages created in the last 365 days UTC.

Me channels_without_topic + pins_month (v1.5.7)

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

Me pins_week (v1.5.7)

GET /v1/me stats include pins_week — pins whose pinned_at is in the last 7 days UTC.

Me reactions_week + reactions_month (v1.5.7)

GET /v1/me stats include reactions_week and reactions_month — reaction rows created in the last 7 / 30 days UTC.

Me replies_week (v1.5.7)

GET /v1/me stats include replies_week — live reply messages (parent_id set) created in the last 7 days UTC.

Me replies_month + channels_without_pins (v1.5.7)

GET /v1/me stats include replies_month (live replies last 30 days UTC) and channels_without_pins (active channels with zero live pins).

Me edited_week + deleted_week (v1.5.7)

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

Me edited_month + deleted_month (v1.5.7)

GET /v1/me stats include edited_month and deleted_month — same as the week counters but over the last 30 days UTC.

Me attachments_week + roots_week (v1.5.7)

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

Me attachments_month (v1.5.7)

GET /v1/me stats include attachments_month — live messages with attachments created in the last 30 days UTC.

Me roots_month (v1.5.7)

GET /v1/me stats include roots_month — root messages (no parent_id) created in the last 30 days UTC.

Me unique_authors_week (v1.5.7)

GET /v1/me stats include unique_authors_week — distinct live message authors in the last 7 days UTC.

Me unique_authors_month (v1.5.7)

GET /v1/me stats include unique_authors_month — distinct live message authors in the last 30 days UTC.

Me messages_hour (v1.5.7)

GET /v1/me stats include messages_hour — live messages created in the last hour UTC.

Me reactions_hour (v1.5.7)

GET /v1/me stats include reactions_hour — reaction rows created in the last hour UTC.

Me pins_hour + replies_hour (v1.5.7)

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

Me edited_hour + deleted_hour (v1.5.7)

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

Me unique_authors_hour + attachments_hour (v1.5.8)

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

Me roots_hour + channels_hour (v1.5.9)

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

Me members_hour + messages_with_reactions_hour (v1.5.10)

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

Me unique_reaction_emoji_hour + archived_hour (v1.5.11)

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

Me unique_reactors_hour + channels_active_hour (v1.5.12)

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

Me unique_editors_hour + unique_pin_authors_hour (v1.5.13)

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 total (v1.5.7)

Search responses include total (matches before limit/offset) plus page count and has_more.

Me unique users (v1.5.7)

GET /v1/me stats include unique_users (distinct member user_ref) and unique_authors (distinct live message authors).

Me stats reactions (v1.5.7)

GET /v1/me stats include reactions (tenant-wide reaction rows).

Search offset (v1.4.38)

GET /v1/search?q=hello&limit=10&offset=10 pages hits. Response includes offset, limit, has_more.

Search user_ref (v1.5.7)

GET /v1/search?q=hello&user_ref=u1 filters hits by author. Combines with channel_id.

Search channel scope (v1.5.7)

GET /v1/search?q=hello&channel_id=UUID limits hits to one channel. Bad uuid returns 400 bad_channel_id.

Tenant stats (v1.5.7)

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.

Unread total (v1.5.7)

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

Mark read / unread (v1.5.7)

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

Message reactions (v1.4.7)

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

Subscribe (SSE)

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

Surface map